autonomous-sdlc-harness 0.5.0 → 0.6.1

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 (37) hide show
  1. package/dist/cli.js +0 -0
  2. package/dist/commands/init.js +17 -8
  3. package/dist/commands/init.js.map +1 -1
  4. package/dist/config/check.js +27 -5
  5. package/dist/config/check.js.map +1 -1
  6. package/dist/config/model.js +54 -11
  7. package/dist/config/model.js.map +1 -1
  8. package/dist/core/pluginIdentity.js +2 -0
  9. package/dist/core/pluginIdentity.js.map +1 -1
  10. package/dist/core/writer.js +1 -0
  11. package/dist/core/writer.js.map +1 -1
  12. package/dist/doctor/checks.js +317 -40
  13. package/dist/doctor/checks.js.map +1 -1
  14. package/dist/generators/githubWorkflows.js +21 -11
  15. package/dist/generators/githubWorkflows.js.map +1 -1
  16. package/dist/remote/githubActions.js +66 -4
  17. package/dist/remote/githubActions.js.map +1 -1
  18. package/dist/retrieval/pythonBackend.js +114 -0
  19. package/dist/retrieval/pythonBackend.js.map +1 -0
  20. package/dist/retrieval/setup.js +8 -0
  21. package/dist/retrieval/setup.js.map +1 -1
  22. package/package.json +1 -1
  23. package/templates/README.md +1 -1
  24. package/templates/github/workflows/harness-control.yml +214 -0
  25. package/templates/github/workflows/harness-resume.yml +7 -0
  26. package/templates/github/workflows/harness-run.yml +127 -3
  27. package/templates/github/workflows/harness-trigger.yml +5 -4
  28. package/templates/scripts/README.md +1 -1
  29. package/templates/scripts/autonomous-watcher.sh +162 -30
  30. package/templates/scripts/docs-search-server.sh +88 -17
  31. package/templates/scripts/lib/harness-run-lib.sh +264 -53
  32. package/templates/scripts/remote-run.sh +3224 -162
  33. package/templates/scripts/scratch-run.sh +54 -73
  34. package/templates/state-dir/README-root.md +1 -1
  35. package/templates/state-dir/clarifications/README.md +1 -1
  36. package/templates/state-dir/scratch/README.md +4 -2
  37. package/templates/state-dir/user_reviews/README.md +2 -2
@@ -1,7 +1,6 @@
1
1
  #!/usr/bin/env bash
2
2
  # docs-search-server.sh — start the docs-retrieval MCP server for the checkout
3
- # this script sits in, by `exec`ing the machine-shared retrieval runtime's
4
- # `docs serve`.
3
+ # this script sits in, on the backend `docs.retrievalBackend` selects.
5
4
  #
6
5
  # WHO RUNS IT. The agent runner, from the `harness-docs` entry `init` writes into
7
6
  # `.mcp.json` when `docs.retrieval` is on — never a dispatched agent's Bash call,
@@ -9,29 +8,52 @@
9
8
  # `agentInvocable: false` and the generated permission profile carries no entry
10
9
  # for it.
11
10
  #
12
- # STDOUT BELONGS TO THE MCP TRANSPORT. Nothing here may print to stdout before
13
- # the `exec`: a stray byte there is a malformed frame for the client. Every
14
- # diagnostic goes to stderr.
11
+ # STDOUT BELONGS TO THE MCP TRANSPORT. Nothing here may print to stdout: a stray
12
+ # byte there is a malformed frame for the client. Every diagnostic goes to
13
+ # stderr.
15
14
  #
16
- # THE RUNTIME IS THE ONLY THING THIS RUNS, with no fallback to any other
17
- # installation: a committed `.mcp.json` cannot know where an adopter's own CLI
18
- # lives. `doctor`'s `retrieval-dependencies` check and `init`'s install both key
19
- # on the same entry file this script tests. `PATH` is settled through the
20
- # library's fallback list before the `exec`, because the starter is the agent
21
- # runner rather than a login shell.
15
+ # WHICH SERVER. Two backends, chosen by `docs.retrievalBackend` read at run
16
+ # time, with no fallback between them and no fallback to any other
17
+ # installation. The key is read ONLY WHEN `phases.docs` and `docs.retrieval` are
18
+ # both `true` (`hr_docs_retrieval_applies`, the shell mirror of
19
+ # `retrievalApplies`): `init` merges `.mcp.json` and removes nothing, so this
20
+ # entry outlives turning retrieval off, and with the gate closed the TypeScript
21
+ # runtime is `exec`ed whatever the key holds. `typescript`, or the key absent,
22
+ # `exec`s the machine-shared runtime's `docs serve`; `doctor`'s
23
+ # `retrieval-dependencies` check and `init`'s install both key on the same entry
24
+ # file this script tests. `python` starts the Python package's stdio server.
25
+ # This script checks no database and runs no `self-check`: grading the backend's
26
+ # prerequisites is `doctor`'s. `PATH` is settled through the library's fallback
27
+ # list first, because the starter is the agent runner rather than a login shell.
28
+ #
29
+ # WHY THE PYTHON BRANCH IS NOT `exec`ED. Its failure must surface as exit 3,
30
+ # which an `exec` could not report. It runs as a child with stdin passed
31
+ # explicitly, and a `TERM` or an `INT` the launcher receives is passed on to it
32
+ # as `TERM`, the signal the server stops on: bash starts a background child with
33
+ # `SIGINT` ignored when job control is off, and Python installs no handler over
34
+ # an ignored `SIGINT`, so an `INT` passed on as itself would never arrive.
22
35
  #
23
36
  # MIRRORS — a change to any owner below is an edit here too:
24
37
  # `retrieval/runtime` and `node_modules/autonomous-sdlc-harness/dist/cli.js`
25
38
  # mirror `RETRIEVAL_CACHE_DIRNAME`, `RETRIEVAL_RUNTIME_DIRNAME` and
26
39
  # `RUNTIME_CLI_RELATIVE` in `cli/src/retrieval/runtime.ts`;
27
- # `hr_cache_dir` mirrors `machineCacheDir()` in `cli/src/machine/paths.ts`.
40
+ # `hr_cache_dir` mirrors `machineCacheDir()` in `cli/src/machine/paths.ts`;
41
+ # `harness-docs-retrieval`, `serve-mcp`, `HARNESS_DOCS_RETRIEVAL_DATABASE_URL`,
42
+ # the default connection string and exit 3 mirror `PYTHON_RETRIEVAL_COMMAND`,
43
+ # `PYTHON_SERVE_SUB_COMMAND`, `PYTHON_DATABASE_URL_VARIABLE`,
44
+ # `PYTHON_DEFAULT_DATABASE_URL` and `PYTHON_BACKEND_UNAVAILABLE_EXIT` in
45
+ # `cli/src/retrieval/pythonBackend.ts`.
28
46
  #
29
47
  # Exit contract:
30
- # 1 no runtime entry at the resolved path, no cache directory to resolve
31
- # it under, or no repository at this script's location; stderr names
32
- # which
33
- # N otherwise `docs serve`'s own status, through `exec`; a failure to
34
- # source the library exits non-zero before that, on stderr only
48
+ # 0 the Python backend exited 0
49
+ # 1 no repository at this script's location; `docs.retrievalBackend`
50
+ # outside the enum; or, on the TypeScript backend, no runtime entry at
51
+ # the resolved path or no cache directory to resolve it under; stderr
52
+ # names which
53
+ # 3 the Python backend is selected but cannot serve: its command does not
54
+ # resolve on `PATH`, or it exited non-zero; stderr names which
55
+ # N on the TypeScript backend, `docs serve`'s own status, through `exec`; a
56
+ # failure to source the library exits non-zero before that, on stderr only
35
57
 
36
58
  set -euo pipefail
37
59
 
@@ -50,6 +72,55 @@ if ! root="$(hr_repo_root "$script_dir")"; then
50
72
  exit 1
51
73
  fi
52
74
 
75
+ backend=typescript
76
+ config_status=0
77
+ hr_config_load "$root" || config_status=$?
78
+ if [ "$config_status" -eq 2 ]; then
79
+ echo "docs-search-server: $root/harness.config.json could not be read, so the default backend (typescript) was taken" >&2
80
+ elif [ "$config_status" -eq 0 ] && hr_docs_retrieval_applies "$root"; then
81
+ backend_status=0
82
+ backend="$(hr_docs_retrieval_backend "$root")" || backend_status=$?
83
+ if [ "$backend_status" -ne 0 ]; then
84
+ echo "docs-search-server: docs.retrievalBackend in $root/harness.config.json is neither \`typescript\` nor \`python\`; set one of them, then run \`npx autonomous-sdlc-harness doctor\`" >&2
85
+ exit 1
86
+ fi
87
+ fi
88
+
89
+ if [ "$backend" = python ]; then
90
+ if ! command -v harness-docs-retrieval >/dev/null 2>&1; then
91
+ echo "docs-search-server: the Python backend is selected but \`harness-docs-retrieval\` does not resolve on PATH (interpreter or package missing); see docs/retrieval.md and run \`npx autonomous-sdlc-harness doctor\`" >&2
92
+ exit 3
93
+ fi
94
+ if [ -z "${HARNESS_DOCS_RETRIEVAL_DATABASE_URL-}" ]; then
95
+ HARNESS_DOCS_RETRIEVAL_DATABASE_URL='postgresql://harness:harness@127.0.0.1:5432/docs_retrieval'
96
+ fi
97
+ export HARNESS_DOCS_RETRIEVAL_DATABASE_URL
98
+
99
+ # A background job's stdin is /dev/null unless it is passed explicitly.
100
+ harness-docs-retrieval serve-mcp --repo "$root" <&0 &
101
+ child=$!
102
+ trapped=0
103
+ trap 'trapped=1; kill -TERM "$child" 2>/dev/null || true' TERM
104
+ trap 'trapped=1; kill -TERM "$child" 2>/dev/null || true' INT
105
+
106
+ # A trapped signal interrupts `wait` before the child's status is collected, so wait again. 127
107
+ # means the first `wait` had already collected it.
108
+ child_status=0
109
+ wait "$child" || child_status=$?
110
+ while [ "$trapped" -eq 1 ]; do
111
+ trapped=0
112
+ again=0
113
+ wait "$child" 2>/dev/null || again=$?
114
+ [ "$again" -eq 127 ] || child_status=$again
115
+ done
116
+
117
+ if [ "$child_status" -eq 0 ]; then
118
+ exit 0
119
+ fi
120
+ echo "docs-search-server: the Python backend exited with status $child_status before or while serving; the line above (\`harness-docs-retrieval: …\`) names why; run \`npx autonomous-sdlc-harness doctor\`" >&2
121
+ exit 3
122
+ fi
123
+
53
124
  if ! cache_dir="$(hr_cache_dir)"; then
54
125
  echo "docs-search-server: neither XDG_CACHE_HOME nor HOME is set, so the retrieval runtime cannot be located" >&2
55
126
  exit 1
@@ -3,7 +3,9 @@
3
3
  # the repository it is operating on, reads that repository's
4
4
  # `harness.config.json` at run time, answers "is this branch protected?",
5
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
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
7
9
  # artifact in a working copy and commits and pushes it, and derives the
8
10
  # anchors (main checkout, work root, worktree directory, repo slug,
9
11
  # state-dir paths) the scripts would otherwise each re-derive slightly
@@ -77,9 +79,12 @@
77
79
  # files under the eight planning paths `hr_remote_planning_paths` assigns;
78
80
  # outside it, only the caller-named `<out_dir>` (its `planning/` included)
79
81
  # of `hr_remote_bundle_write` and the caller-named `<out_json>`
80
- # of `hr_remote_status_write`. Written only by `hr_remote_status_write`,
81
- # `hr_remote_bundle_write` and `hr_remote_bundle_restore`, and nothing
82
- # there but a writer's own failed temp file is ever removed.
82
+ # of `hr_remote_status_write`. The move-aside directory receives the
83
+ # clarification directory and `PAUSE_PROGRESS.md`, both moved there by
84
+ # `hr_remote_move_aside` alone. Written only by `hr_remote_status_write`,
85
+ # `hr_remote_bundle_write`, `hr_remote_bundle_restore` and
86
+ # `hr_remote_move_aside`, and nothing there but a writer's own failed temp
87
+ # file is ever removed.
83
88
  # 4. THE ARTIFACT PLACEMENT writes one artifact into a working copy. Fence:
84
89
  # the caller-named `<worktree>/<rel>`, its parent directories and that
85
90
  # path's index entry, plus whatever the two caller-named wrappers do.
@@ -93,7 +98,7 @@
93
98
  #
94
99
  # A caller that calls no `hr_lane_*`, `hr_registry_init`, `hr_registry_set`,
95
100
  # `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`,
101
+ # `hr_remote_bundle_write`, `hr_remote_bundle_restore`, `hr_remote_move_aside`, `hr_place_artifact`,
97
102
  # `hr_commit_placed` or `hr_push_landed` function still gets a library that only reads. The
98
103
  # lane's ceilings are the only environment values here that carry policy, because
99
104
  # the lane is machine-scoped and has no configuration key to carry them; each is
@@ -209,7 +214,8 @@
209
214
  # `HR_LANE_OWNER_AT` and `HR_LANE_BROKEN_OWNER`, and the remote state bundle's
210
215
  # names, which `hr_remote_names_var` assigns, with `HR_REMOTE_PLANNING_PATHS`
211
216
  # (`hr_remote_planning_paths`) and `HR_REMOTE_PLANNING_PLACED` /
212
- # `HR_REMOTE_PLANNING_KEPT` (`hr_remote_bundle_restore`). Every one of them is assigned
217
+ # `HR_REMOTE_PLANNING_KEPT` (`hr_remote_bundle_restore`) and `HR_REMOTE_ASIDE`
218
+ # (`hr_remote_move_aside`). Every one of them is assigned
213
219
  # before it is read by the function that owns it, so an inherited value from a
214
220
  # parent process is overwritten rather than believed.
215
221
  #
@@ -594,9 +600,10 @@ hr_config_load() {
594
600
  # which is what lets an explicitly EMPTY list read as a configured set rather
595
601
  # than as an absent one.
596
602
  #
597
- # A `phases.*` value that is not a boolean is emitted as `invalid`, so the
598
- # string `"true"` — which `tostring` would otherwise make indistinguishable
599
- # from `true` — reaches `hr_phase_enabled` as a value it refuses (2).
603
+ # A `phases.*` or `docs.retrieval` value that is not a boolean is emitted as
604
+ # `invalid`, so the string `"true"` — which `tostring` would otherwise make
605
+ # indistinguishable from `true` — reaches `hr_phase_enabled` or
606
+ # `hr_docs_retrieval_applies` as a value it refuses (2).
600
607
  out=$(jq -n -r '
601
608
  def s($k; $v):
602
609
  if $v == null then empty
@@ -631,6 +638,8 @@ hr_config_load() {
631
638
  s("phases.qa"; try (.phases.qa | if type == "boolean" or . == null then . else "invalid" end) catch null),
632
639
  s("phases.docs"; try (.phases.docs | if type == "boolean" or . == null then . else "invalid" end) catch null),
633
640
  s("execution.target"; try .execution.target catch null),
641
+ s("docs.retrievalBackend"; try .docs.retrievalBackend catch null),
642
+ s("docs.retrieval"; try (.docs.retrieval | if type == "boolean" or . == null then . else "invalid" end) catch null),
634
643
  s("forge"; try .forge catch null),
635
644
  s("protectedBranches.present";
636
645
  try (if (.protectedBranches | type) == "array" then "1" else null end) catch null),
@@ -918,6 +927,53 @@ hr_forge() {
918
927
  return 2
919
928
  }
920
929
 
930
+ # Whether the docs phase's retrieval step runs: `phases.docs` and
931
+ # `docs.retrieval` both `true`. The shell mirror of `cli/src/config/model.ts` →
932
+ # `retrievalApplies`: change the predicate there and here together. PRINTS
933
+ # NOTHING: the answer is the status, as `hr_phase_enabled`'s is. 0 = both
934
+ # `true`; 1 = either `false` or unset; 2 = the configuration is unresolvable or
935
+ # either value is not a boolean — refused rather than guessed about.
936
+ hr_docs_retrieval_applies() {
937
+ local root="${1-}" status
938
+ hr_phase_enabled "$root" docs
939
+ status=$?
940
+ [ "$status" -eq 0 ] || return "$status"
941
+ hr_cfg_scalar_var "docs.retrieval" || return 1
942
+ case "$HR_CFG_VALUE" in
943
+ true) return 0 ;;
944
+ false) return 1 ;;
945
+ esac
946
+ return 2
947
+ }
948
+
949
+ # `docs.retrievalBackend` — which runtime the docs-retrieval index uses:
950
+ # `typescript` or `python`. THE ONE READER OF THE KEY IN THIS FAMILY, and the
951
+ # shell mirror of `cli/src/config/model.ts` → `RETRIEVAL_BACKENDS` /
952
+ # `DEFAULT_RETRIEVAL_BACKEND`: change the enum there and here together. Schema
953
+ # default `typescript`, so an absent key prints `typescript` and 1 is never
954
+ # returned; a `docs` parent of the wrong type (`"docs": "x"`) reads as absent
955
+ # too, because `hr_config_load`'s `try … catch` nulls that key alone — refusing
956
+ # that shape is the schema's job. 2 — printing nothing — when the configuration
957
+ # is unresolvable or the value is outside the enum, a refusal rather than a
958
+ # guess. It does NOT consult `phases.docs` / `docs.retrieval`: the key is read
959
+ # only inside the gate, so a caller asks `hr_docs_retrieval_applies` first and
960
+ # calls this only on its status 0.
961
+ hr_docs_retrieval_backend() {
962
+ local root="${1-}"
963
+ hr_config_load "$root" || return 2
964
+ if ! hr_cfg_scalar_var "docs.retrievalBackend"; then
965
+ printf 'typescript\n'
966
+ return 0
967
+ fi
968
+ case "$HR_CFG_VALUE" in
969
+ typescript|python)
970
+ printf '%s\n' "$HR_CFG_VALUE"
971
+ return 0
972
+ ;;
973
+ esac
974
+ return 2
975
+ }
976
+
921
977
  # ---------------------------------------------------------------------------
922
978
  # Inbox routing — the one owner of the drop filename patterns.
923
979
  # ---------------------------------------------------------------------------
@@ -1140,6 +1196,92 @@ hr_state_path() {
1140
1196
  fi
1141
1197
  }
1142
1198
 
1199
+ # Judge whether <path> resolves STRICTLY INSIDE `<root>/<state_dir>/scratch/`.
1200
+ # Usage: hr_scratch_path_var <root> <path> [<base>]
1201
+ #
1202
+ # Two callers, differing only in what they do with an accepted path:
1203
+ # `scratch-run.sh` executes a file there and `remote-run.sh discard` removes a
1204
+ # directory there. So this reports a keyword in `HR_SCRATCH_WHY` and leaves every
1205
+ # message and exit code to its caller.
1206
+ #
1207
+ # The rule, stated once:
1208
+ # - `..` is refused ANYWHERE in the argument, not only as a whole segment —
1209
+ # strictly stronger, and free, because nothing in that directory depends on
1210
+ # a file's name. Then any character outside `A-Za-z0-9._/-` is refused. Both
1211
+ # tests run on <path> as the caller received it, before anything resolves.
1212
+ # - A relative <path> resolves against <base> when given, else <root>; an
1213
+ # absolute one is taken as given.
1214
+ # - Both sides of the comparison are resolved PHYSICALLY (`cd … && pwd -P`), so
1215
+ # a symlinked directory planted inside the scratch tree cannot widen the
1216
+ # fence. The target's parent must be the scratch directory or beneath it, and
1217
+ # a basename of `.` (the scratch directory itself) is refused.
1218
+ # - A target that is itself a symlink is refused rather than followed: its
1219
+ # destination is outside this judgement. The target need not exist.
1220
+ #
1221
+ # Clears the other four, then sets: `HR_SCRATCH_SUBDIR` (`scratch`, mirroring the `scratch` row
1222
+ # of `STATE_DIR_ENTRIES` in `cli/src/generators/stateDir.ts`), `HR_SCRATCH_DIR`
1223
+ # (physical), `HR_SCRATCH_PARENT` (physical), `HR_SCRATCH_TARGET`
1224
+ # (`<parent>/<basename>`, the path a caller acts on) and `HR_SCRATCH_WHY`.
1225
+ # Status / `HR_SCRATCH_WHY`:
1226
+ # 0 accepted (WHY empty)
1227
+ # 1 refused — `dotdot`, `charset`, `outside`, `itself`, `symlink`
1228
+ # 2 the target's parent does not resolve — `no-parent`
1229
+ # 3 the scratch directory cannot be located — `no-config`, `no-scratch`
1230
+ # 4 <root> or <path> is empty — `usage`
1231
+ hr_scratch_path_var() {
1232
+ local root="${1-}" path="${2-}" base="${3-}" scratch candidate name
1233
+ HR_SCRATCH_DIR=""
1234
+ HR_SCRATCH_PARENT=""
1235
+ HR_SCRATCH_TARGET=""
1236
+ HR_SCRATCH_WHY=""
1237
+ HR_SCRATCH_SUBDIR='scratch'
1238
+ if [ -z "$root" ] || [ -z "$path" ]; then
1239
+ HR_SCRATCH_WHY="usage"
1240
+ return 4
1241
+ fi
1242
+ case "$path" in
1243
+ *..*) HR_SCRATCH_WHY="dotdot"; return 1 ;;
1244
+ esac
1245
+ case "$path" in
1246
+ *[!A-Za-z0-9._/-]*) HR_SCRATCH_WHY="charset"; return 1 ;;
1247
+ esac
1248
+ scratch=$(hr_state_path "$root" "$HR_SCRATCH_SUBDIR")
1249
+ if [ "$?" -ne 0 ] || [ -z "$scratch" ]; then
1250
+ HR_SCRATCH_WHY="no-config"
1251
+ return 3
1252
+ fi
1253
+ HR_SCRATCH_DIR=$(cd "$scratch" 2>/dev/null && pwd -P)
1254
+ if [ -z "$HR_SCRATCH_DIR" ]; then
1255
+ HR_SCRATCH_WHY="no-scratch"
1256
+ return 3
1257
+ fi
1258
+ [ -n "$base" ] || base="$root"
1259
+ case "$path" in
1260
+ /*) candidate="$path" ;;
1261
+ *) candidate="${base%/}/$path" ;;
1262
+ esac
1263
+ HR_SCRATCH_PARENT=$(cd "$(dirname "$candidate")" 2>/dev/null && pwd -P)
1264
+ if [ -z "$HR_SCRATCH_PARENT" ]; then
1265
+ HR_SCRATCH_WHY="no-parent"
1266
+ return 2
1267
+ fi
1268
+ case "$HR_SCRATCH_PARENT" in
1269
+ "$HR_SCRATCH_DIR" | "$HR_SCRATCH_DIR"/*) ;;
1270
+ *) HR_SCRATCH_WHY="outside"; return 1 ;;
1271
+ esac
1272
+ name=$(basename "$candidate")
1273
+ if [ "$name" = "." ]; then
1274
+ HR_SCRATCH_WHY="itself"
1275
+ return 1
1276
+ fi
1277
+ HR_SCRATCH_TARGET="$HR_SCRATCH_PARENT/$name"
1278
+ if [ -L "$HR_SCRATCH_TARGET" ]; then
1279
+ HR_SCRATCH_WHY="symlink"
1280
+ return 1
1281
+ fi
1282
+ return 0
1283
+ }
1284
+
1143
1285
  # The machine-local settings directory — one place an operator keeps values that
1144
1286
  # vary per machine rather than per repository, which is why it is not a config
1145
1287
  # key and not a repository file. Return 1 when there is no home to anchor it to.
@@ -1442,8 +1584,10 @@ hr_remote_record_init() {
1442
1584
  # limit and readable in the Actions run list. GitHub documents no ref limit.
1443
1585
  #
1444
1586
  # 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.
1587
+ # caller that wants `origin` fresh fetches first. Given a <gh_cli>, it also asks
1588
+ # GitHub one read per candidate — the run workflow's runs under that name — and
1589
+ # still writes nothing. A registry is read only when it already exists, because
1590
+ # `hr_registry_get` creates an absent one.
1447
1591
  # ---------------------------------------------------------------------------
1448
1592
 
1449
1593
  # The two limits the rule above names.
@@ -1525,11 +1669,37 @@ EOF
1525
1669
  return 0
1526
1670
  }
1527
1671
 
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.
1672
+ # hr_branch_run_history <root> <gh_cli> <name> — 0 when the run workflow
1673
+ # (`HR_REMOTE_WORKFLOW_RUN_FILE`) lists a run under <name>, 1 when it lists
1674
+ # none, 2 when <gh_cli> cannot run, exits non-zero, or answers anything but a
1675
+ # JSON array. Sets no `HR_` variable: the names are assigned inside the
1676
+ # subshell that `cd`s to <root>.
1677
+ #
1678
+ # GitHub keeps a deleted branch's runs listed under its name, and a later run
1679
+ # of that name inherits them: Gate 12 round 5 found run `36569531374` of the
1680
+ # deleted `feat_invoices` (`docs/development.md` → Gate 12 → Round 5, finding
1681
+ # 1). One bounded read per candidate, not one listing of every branch, because
1682
+ # a bounded all-branch listing could miss an old name. Bash 3.2 and jq 1.5.
1683
+ hr_branch_run_history() {
1684
+ local root="${1-}" gh_cli="${2-}" name="${3-}" answer verdict
1685
+ [ -n "$root" ] && [ -n "$gh_cli" ] && [ -n "$name" ] || return 2
1686
+ answer=$(cd "$root" 2>/dev/null && hr_remote_names_var &&
1687
+ "$gh_cli" run list --workflow "$HR_REMOTE_WORKFLOW_RUN_FILE" --branch "$name" --limit 1 --json databaseId 2>/dev/null) || return 2
1688
+ 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
1689
+ case "$verdict" in
1690
+ history) return 0 ;;
1691
+ none) return 1 ;;
1692
+ esac
1693
+ return 2
1694
+ }
1695
+
1696
+ # Judge <name> against the listings `hr_branch_taken_lists_var` last read, then,
1697
+ # given a <gh_cli>, against the run workflow's history — last, so the free local
1698
+ # checks answer first. The answers and `HR_TAKEN_WHY` are
1699
+ # `hr_branch_name_taken`'s.
1530
1700
  hr_branch_taken_judge() {
1531
1701
  local LC_ALL=C
1532
- local root="${1-}" name="${2-}" registry="${3-}" lower status nl
1702
+ local root="${1-}" name="${2-}" registry="${3-}" gh_cli="${4-}" lower status nl
1533
1703
  nl='
1534
1704
  '
1535
1705
  HR_TAKEN_WHY=""
@@ -1557,18 +1727,29 @@ hr_branch_taken_judge() {
1557
1727
  HR_TAKEN_WHY="a run registry record"
1558
1728
  return 0
1559
1729
  fi
1730
+ if [ -n "$gh_cli" ]; then
1731
+ status=0
1732
+ hr_branch_run_history "$root" "$gh_cli" "$name" || status=$?
1733
+ case "$status" in
1734
+ 0) HR_TAKEN_WHY="a run of the run workflow listed under that name"; return 0 ;;
1735
+ 1) ;;
1736
+ *) HR_TAKEN_WHY="the run history of $name could not be listed"; return 2 ;;
1737
+ esac
1738
+ fi
1560
1739
  return 1
1561
1740
  }
1562
1741
 
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>.
1742
+ # hr_branch_name_taken <root> <name> [<registry>] [<gh_cli>] — 0 taken, 1 free,
1743
+ # 2 cannot tell. Sets `HR_TAKEN_WHY` to a short phrase naming the collision or
1744
+ # the failure. A caller with no registry passes `""` before <gh_cli>. Taken: a
1745
+ # protected name; a branch on `origin`, compared case-insensitively; a local
1746
+ # branch; under `<state_dir>` on `origin/<defaultBranch>`, a directory named
1747
+ # <name> or a file `<name>_task_prompt.md`, `<name>_story_plan.md` or
1748
+ # `<name>_docs.md` — which a merged and deleted branch still leaves; a record in
1749
+ # an existing <registry>; given a <gh_cli>, any run of the run workflow listed
1750
+ # under <name> (`hr_branch_run_history`), whose listing failing is a 2.
1570
1751
  hr_branch_name_taken() {
1571
- local root="${1-}" name="${2-}" registry="${3-}"
1752
+ local root="${1-}" name="${2-}" registry="${3-}" gh_cli="${4-}"
1572
1753
  HR_TAKEN_WHY=""
1573
1754
  if [ -z "$root" ] || [ -z "$name" ]; then
1574
1755
  HR_TAKEN_WHY="no branch name to judge"
@@ -1576,20 +1757,21 @@ hr_branch_name_taken() {
1576
1757
  fi
1577
1758
  hr_config_load "$root" || :
1578
1759
  hr_branch_taken_lists_var "$root" || return 2
1579
- hr_branch_taken_judge "$root" "$name" "$registry"
1760
+ hr_branch_taken_judge "$root" "$name" "$registry" "$gh_cli"
1580
1761
  }
1581
1762
 
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.
1763
+ # hr_derive_branch <root> <text> <fallback> [<registry>] [<gh_cli>] — print the
1764
+ # derived name and return 0; return 2, printing nothing, when a `taken`
1765
+ # judgement could not tell or no base routes back to itself; return 3, printing
1766
+ # nothing, when every suffix through `HR_BRANCH_SUFFIX_MAX` is taken. Never a
1767
+ # guessed name. <gh_cli> reaches every judgement as `hr_branch_name_taken`'s.
1586
1768
  #
1587
1769
  # THE BASE MUST ROUTE BACK TO ITSELF: `hr_inbox_route_var` on each drop filename
1588
1770
  # a run of that name produces has to give the base as its branch, so no derived
1589
1771
  # name makes the inbox patterns ambiguous. A base that does not is replaced by
1590
1772
  # <fallback> once.
1591
1773
  hr_derive_branch() {
1592
- local root="${1-}" text="${2-}" fallback="${3-}" registry="${4-}"
1774
+ local root="${1-}" text="${2-}" fallback="${3-}" registry="${4-}" gh_cli="${5-}"
1593
1775
  local base="" candidate suffix routes n status
1594
1776
  hr_branch_limits_var
1595
1777
  candidate=$(hr_branch_slug "$text") || candidate="$fallback"
@@ -1615,7 +1797,7 @@ hr_derive_branch() {
1615
1797
  n=1
1616
1798
  while :; do
1617
1799
  status=0
1618
- hr_branch_taken_judge "$root" "$candidate" "$registry" || status=$?
1800
+ hr_branch_taken_judge "$root" "$candidate" "$registry" "$gh_cli" || status=$?
1619
1801
  case "$status" in
1620
1802
  1) printf '%s\n' "$candidate"; return 0 ;;
1621
1803
  2) return 2 ;;
@@ -1704,8 +1886,9 @@ hr_push_landed() {
1704
1886
  # names. Every name below is a variable `hr_remote_names_var` assigns, and it
1705
1887
  # also assigns `HR_REMOTE_WORKFLOW_RUN_FILE`, the run workflow's file name, for
1706
1888
  # 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.
1889
+ # `hr_github_resume_route` and for the run-history probe `hr_branch_run_history`.
1890
+ # Those two are mirrors of the header's table; no function spells either one,
1891
+ # or any other name here.
1709
1892
  #
1710
1893
  # <bundle>/status.json the job's record, fixed schema below
1711
1894
  # <bundle>/clarifications/<branch>/ the whole branch directory, answered/ included
@@ -2000,6 +2183,45 @@ EOF
2000
2183
  return 0
2001
2184
  }
2002
2185
 
2186
+ # hr_remote_move_aside <root> <rel_path>
2187
+ #
2188
+ # Moves `<root>/<state_dir>/<rel_path>`, a file or a directory, with one `mv`
2189
+ # into `autonomous_logs/remote_superseded/<epoch>/<rel_path>` (`<epoch>-<n>`,
2190
+ # the first `n` = 1, 2, … where that path is free, when an earlier move took
2191
+ # that second), creating the parents and removing nothing. On success
2192
+ # `HR_REMOTE_ASIDE` holds the absolute destination; it is empty at entry.
2193
+ #
2194
+ # 0 moved; 1 a missing argument, a <rel_path> that is absolute or has a `..`
2195
+ # segment, or a failed `mkdir` / `mv`; 2 — touching nothing — <root>'s
2196
+ # configuration is unresolvable; 3 — touching nothing — no source exists.
2197
+ hr_remote_move_aside() {
2198
+ local root="${1-}" rel="${2-}" state base epoch aside n
2199
+ HR_REMOTE_ASIDE=''
2200
+ [ -n "$root" ] && [ -n "$rel" ] || return 1
2201
+ case "$rel" in
2202
+ /*) return 1 ;;
2203
+ esac
2204
+ case "/$rel/" in
2205
+ */../*) return 1 ;;
2206
+ esac
2207
+ state=$(hr_state_dir "$root") || return 2
2208
+ hr_remote_names_var
2209
+ base="${root%/}/$state"
2210
+ [ -e "$base/$rel" ] || [ -L "$base/$rel" ] || return 3
2211
+ epoch=$(date +%s)
2212
+ aside="$base/$HR_REMOTE_SUPERSEDED_DIR/$epoch"
2213
+ n=0
2214
+ while [ -e "$aside/$rel" ] || [ -L "$aside/$rel" ]; do
2215
+ n=$((n + 1))
2216
+ aside="$base/$HR_REMOTE_SUPERSEDED_DIR/$epoch-$n"
2217
+ done
2218
+ aside="$aside/$rel"
2219
+ mkdir -p "${aside%/*}" 2>/dev/null || return 1
2220
+ mv "$base/$rel" "$aside" 2>/dev/null || return 1
2221
+ HR_REMOTE_ASIDE=$aside
2222
+ return 0
2223
+ }
2224
+
2003
2225
  # hr_remote_bundle_restore <bundle_dir> <root> <branch> <mode>
2004
2226
  #
2005
2227
  # <mode> `job` places the clarification directory, `PAUSE_PROGRESS.md`, the
@@ -2016,20 +2238,18 @@ EOF
2016
2238
  # stay `0` in `mirror` mode.
2017
2239
  #
2018
2240
  # THE CLARIFICATION DIRECTORY IS REPLACED WHOLESALE, AND NOTHING IS DELETED. An
2019
- # existing target is moved aside with one `mv` into
2020
- # `autonomous_logs/remote_superseded/<epoch>/clarifications/<branch>` (`<epoch>-<n>`
2021
- # when an earlier restore took that second) before the
2022
- # bundle's copy goes in, so a stale local pair cannot survive and no recursive
2023
- # removal is ever shelled out. A bundle carrying no clarification directory
2024
- # leaves the target alone: in a mirror it may hold an answer not yet relayed.
2025
- #
2026
- # 0 restored; 1 a missing argument, an unknown <mode> or a failed copy; 2 —
2027
- # touching nothing — when the bundle is unrecognised (no readable `status.json`,
2028
- # a schema other than `HR_REMOTE_STATE_SCHEMA`, or a `branch` other than
2029
- # <branch>) or <root>'s configuration is unresolvable.
2241
+ # existing target is moved aside by `hr_remote_move_aside` before the bundle's
2242
+ # copy goes in, so a stale local pair cannot survive and no recursive removal is
2243
+ # ever shelled out. A bundle carrying no clarification directory leaves the
2244
+ # target alone: in a mirror it may hold an answer not yet relayed.
2245
+ #
2246
+ # 0 restored; 1 a missing argument, an unknown <mode>, a failed move aside or a
2247
+ # failed copy; 2 — touching nothing — when the bundle is unrecognised (no
2248
+ # readable `status.json`, a schema other than `HR_REMOTE_STATE_SCHEMA`, or a
2249
+ # `branch` other than <branch>) or <root>'s configuration is unresolvable.
2030
2250
  hr_remote_bundle_restore() {
2031
2251
  local bundle="${1-}" root="${2-}" branch="${3-}" mode="${4-}"
2032
- local state base named target epoch aside n tmp pdir file rel p inset
2252
+ local state base named target tmp pdir file rel p inset
2033
2253
  HR_REMOTE_PLANNING_PLACED=0
2034
2254
  HR_REMOTE_PLANNING_KEPT=0
2035
2255
  [ -n "$bundle" ] && [ -n "$root" ] && [ -n "$branch" ] || return 1
@@ -2047,16 +2267,7 @@ hr_remote_bundle_restore() {
2047
2267
  if [ -d "$bundle/$HR_REMOTE_CLARIFY_DIR/$branch" ]; then
2048
2268
  target="$base/$HR_REMOTE_CLARIFY_DIR/$branch"
2049
2269
  if [ -e "$target" ]; then
2050
- epoch=$(date +%s)
2051
- aside="$base/$HR_REMOTE_SUPERSEDED_DIR/$epoch"
2052
- n=0
2053
- while [ -e "$aside/$HR_REMOTE_CLARIFY_DIR/$branch" ]; do
2054
- n=$((n + 1))
2055
- aside="$base/$HR_REMOTE_SUPERSEDED_DIR/$epoch-$n"
2056
- done
2057
- aside="$aside/$HR_REMOTE_CLARIFY_DIR/$branch"
2058
- mkdir -p "${aside%/*}" 2>/dev/null || return 1
2059
- mv "$target" "$aside" 2>/dev/null || return 1
2270
+ hr_remote_move_aside "$root" "$HR_REMOTE_CLARIFY_DIR/$branch" || return 1
2060
2271
  fi
2061
2272
  mkdir -p "${target%/*}" 2>/dev/null || return 1
2062
2273
  cp -R "$bundle/$HR_REMOTE_CLARIFY_DIR/$branch" "$target" 2>/dev/null || return 1