autonomous-sdlc-harness 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. package/README.md +2 -2
  2. package/dist/cli.js +0 -0
  3. package/dist/commands/docs.js +2 -1
  4. package/dist/commands/docs.js.map +1 -1
  5. package/dist/commands/doctor.js +27 -6
  6. package/dist/commands/doctor.js.map +1 -1
  7. package/dist/commands/init.js +102 -20
  8. package/dist/commands/init.js.map +1 -1
  9. package/dist/config/check.js +11 -4
  10. package/dist/config/check.js.map +1 -1
  11. package/dist/config/model.js +25 -0
  12. package/dist/config/model.js.map +1 -1
  13. package/dist/core/git.js +29 -0
  14. package/dist/core/git.js.map +1 -1
  15. package/dist/core/paths.js +22 -2
  16. package/dist/core/paths.js.map +1 -1
  17. package/dist/core/prompt.js +6 -2
  18. package/dist/core/prompt.js.map +1 -1
  19. package/dist/core/writer.js +2 -0
  20. package/dist/core/writer.js.map +1 -1
  21. package/dist/doctor/checks.js +343 -28
  22. package/dist/doctor/checks.js.map +1 -1
  23. package/dist/generators/githubWorkflows.js +66 -0
  24. package/dist/generators/githubWorkflows.js.map +1 -0
  25. package/dist/generators/notifications.js +105 -19
  26. package/dist/generators/notifications.js.map +1 -1
  27. package/dist/generators/outerLoopScripts.js +17 -3
  28. package/dist/generators/outerLoopScripts.js.map +1 -1
  29. package/dist/generators/permissionProfile.js +101 -12
  30. package/dist/generators/permissionProfile.js.map +1 -1
  31. package/dist/generators/repoRoot.js +18 -2
  32. package/dist/generators/repoRoot.js.map +1 -1
  33. package/dist/generators/stateDir.js +9 -3
  34. package/dist/generators/stateDir.js.map +1 -1
  35. package/dist/machine/paths.js +4 -1
  36. package/dist/machine/paths.js.map +1 -1
  37. package/dist/remote/githubActions.js +86 -0
  38. package/dist/remote/githubActions.js.map +1 -0
  39. package/dist/retrieval/queryLog.js +6 -4
  40. package/dist/retrieval/queryLog.js.map +1 -1
  41. package/dist/retrieval/runtime.js +12 -22
  42. package/dist/retrieval/runtime.js.map +1 -1
  43. package/dist/retrieval/search.js +21 -10
  44. package/dist/retrieval/search.js.map +1 -1
  45. package/dist/retrieval/server.js +1 -1
  46. package/dist/retrieval/setup.js +2 -1
  47. package/dist/retrieval/setup.js.map +1 -1
  48. package/package.json +1 -1
  49. package/templates/README.md +3 -2
  50. package/templates/claude/context/conventions.md +1 -1
  51. package/templates/claude/context/layer.md +1 -1
  52. package/templates/claude/push-notify.env.example +7 -2
  53. package/templates/claude/settings.autonomous.json +1 -1
  54. package/templates/github/workflows/harness-resume.yml +124 -0
  55. package/templates/github/workflows/harness-run.yml +446 -0
  56. package/templates/repo/gitignore +5 -0
  57. package/templates/scripts/README.md +1 -1
  58. package/templates/scripts/autonomous-watcher.sh +1141 -143
  59. package/templates/scripts/flow-walker.sh +629 -0
  60. package/templates/scripts/flows/task_plan_writing.graph.json +192 -0
  61. package/templates/scripts/lib/flow-walker-gates.sh +165 -0
  62. package/templates/scripts/lib/harness-run-lib.sh +433 -18
  63. package/templates/scripts/remote-run.sh +1785 -0
  64. package/templates/scripts/restart-watcher.sh +21 -1
  65. package/templates/scripts/run-test-suite.sh +182 -0
  66. package/templates/scripts/scratch-run.sh +2 -1
  67. package/templates/state-dir/README-root.md +1 -1
  68. package/templates/state-dir/autonomous_logs/README.md +1 -1
  69. package/templates/state-dir/improvement_observations/README.md +2 -0
  70. package/templates/state-dir/scratch/README.md +1 -1
  71. package/templates/state-dir/test_fix_plan_reviews/README.md +9 -0
  72. package/templates/state-dir/test_fix_plans/README.md +9 -0
  73. package/templates/state-dir/test_fix_point_reviews/README.md +9 -0
  74. package/templates/state-dir/test_run_logs/README.md +11 -0
@@ -4,7 +4,9 @@
4
4
  # `harness.config.json` at run time, answers "is this branch protected?", and
5
5
  # derives the anchors (main checkout, work root, worktree directory, repo slug,
6
6
  # state-dir paths) the scripts would otherwise each re-derive slightly
7
- # differently.
7
+ # differently. It also implements the run registry's reads and writes for the
8
+ # 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.
8
10
  #
9
11
  # WHO SOURCES THIS, AND HOW. Every script in the configured `scriptsDir` that
10
12
  # needs this library sources it by a path computed from `${BASH_SOURCE[0]}` —
@@ -43,19 +45,43 @@
43
45
  # nowhere else, where `<repo_root>` is resolved from a bare
44
46
  # `git rev-parse --show-toplevel` at the directory the caller names. Nothing
45
47
  # here reads an environment variable in place of a configured value, and nothing
46
- # here writes anything inside a repository.
47
- #
48
- # THE ONE EXCEPTION TO "WRITES NOTHING", AND ITS FENCE: the machine-level usage
49
- # lane at the bottom of this file publishes a state record and takes an advisory
50
- # lock. Both live under `hr_lane_dir` — a machine-local path outside every
51
- # repository — and nothing else here writes at all, so a caller that never calls
52
- # an `hr_lane_*` function still gets a library that only reads. The lane's
53
- # ceilings are the only environment values here that carry policy, because the
54
- # lane is machine-scoped and has no configuration key to carry them; each is
48
+ # here writes inside a repository except through the named write exceptions
49
+ # below.
50
+ #
51
+ # THE WRITE EXCEPTIONS TO "WRITES NOTHING", AND THEIR FENCES. Each entry names
52
+ # the section that writes, the functions that write, and where. Nothing outside
53
+ # this list writes at all; a section that adds a writer adds its entry here.
54
+ #
55
+ # 1. The machine-level usage lane (the section at the bottom of this file)
56
+ # publishes a state record and takes an advisory lock. Fence: both live
57
+ # under `hr_lane_dir` — a machine-local path outside every repository —
58
+ # and are written only by the `hr_lane_*` functions.
59
+ # 2. THE RUN REGISTRY writes the registry file its caller names. Fence: that
60
+ # file is `<root>/<state_dir>/autonomous_logs/registry.json`, resolved
61
+ # through `hr_state_path`, and it is written only by `hr_registry_init`
62
+ # and `hr_registry_set`.
63
+ # 3. THE REMOTE STATE BUNDLE writes the files its format lists. Fence: inside
64
+ # `<root>/<state_dir>/` (resolved through `hr_state_dir`), only
65
+ # `autonomous_logs/remote_status.json`, `clarifications/<branch>/`,
66
+ # `PAUSE_PROGRESS.md`, `.flow_walker_state` and the move-aside directory
67
+ # `autonomous_logs/remote_superseded/`; outside it, only the caller-named
68
+ # `<out_dir>` of `hr_remote_bundle_write` and the caller-named `<out_json>`
69
+ # of `hr_remote_status_write`. Written only by `hr_remote_status_write`,
70
+ # `hr_remote_bundle_write` and `hr_remote_bundle_restore`, and nothing
71
+ # there but a writer's own failed temp file is ever removed.
72
+ #
73
+ # A caller that calls no `hr_lane_*`, `hr_registry_init`, `hr_registry_set`,
74
+ # `hr_remote_status_write`, `hr_remote_bundle_write` or
75
+ # `hr_remote_bundle_restore` function still gets a library that only reads. The
76
+ # lane's ceilings are the only environment values here that carry policy, because
77
+ # the lane is machine-scoped and has no configuration key to carry them; each is
55
78
  # named where it is used. `XDG_STATE_HOME`, `XDG_CONFIG_HOME`, `XDG_CACHE_HOME`,
56
79
  # `HOME` and `PWD` are also read, as location anchors only, and `PATH` is read by
57
80
  # `hr_path_with_fallbacks` alone — as that function's input, which it prints back
58
- # transformed and never assigns.
81
+ # transformed and never assigns. `GITHUB_RUN_ID`, `GITHUB_SERVER_URL`,
82
+ # `GITHUB_REPOSITORY` and `HARNESS_INPUT_CHAIN` are read by
83
+ # `hr_remote_status_write` alone, as provenance values copied into
84
+ # `status.json` — never a configured value and never policy.
59
85
  #
60
86
  # CONFIGURATION IS READ AT RUN TIME, NOT FROZEN AT GENERATION TIME. That is the
61
87
  # whole reason the shipped scripts carry no `{{token}}`: a guard whose protected
@@ -84,8 +110,10 @@
84
110
  # and return 2 when the configuration could not be read. A caller must be able
85
111
  # to tell "the key is empty" from "the file could not be read", because the
86
112
  # first is an ordinary project and the second is a repository this script has no
87
- # business acting in. A reader that wants the finer distinction between "no
88
- # `harness.config.json` at all" and "one that would not parse" calls
113
+ # business acting in. `hr_phase_enabled` answers through the same three
114
+ # statuses and prints nothing: 0 is `true`, 1 is `false` or unset, and 2 also
115
+ # covers an unknown phase name or a non-boolean value. A reader that wants the
116
+ # finer distinction between "no `harness.config.json` at all" and "one that would not parse" calls
89
117
  # `hr_config_load` directly, which returns 1 for the first and 2 for the second.
90
118
  #
91
119
  # THE PROTECTED SET. It is *(`protectedBranches` if present, else that key's
@@ -131,10 +159,13 @@
131
159
  # dialect marker for editors and linters). No `set -e` and no `set -u` — a
132
160
  # sourced library must not change its caller's shell — but every parameter
133
161
  # expansion here is defaulted, so it is safe to source into a caller that sets
134
- # both. No top-level side effects, no exiting, no writes outside the lane
135
- # directory named above, and no diagnostics on stdout OR stderr: every reader is
136
- # silent on failure and signals through its return status, because callers
137
- # capture stdout. That silence is why the lane reports a lock it BROKE through a
162
+ # both. No top-level side effects, no exiting, no writes outside the fences of
163
+ # the write exceptions named above, and no diagnostics on stdout OR stderr:
164
+ # every reader is silent on failure and signals through its return status,
165
+ # because callers capture stdout. The one pass-through is the registry's two
166
+ # writers, `hr_registry_init` and `hr_registry_set`, which leave the shell's,
167
+ # `mktemp`'s and `jq`'s own stderr on a failed write to the caller, as the
168
+ # watcher's bodies they replaced did — that stream is the watcher's log. That silence is why the lane reports a lock it BROKE through a
138
169
  # variable instead of a log line — the caller owns the log.
139
170
  #
140
171
  # NAMING. Every function is prefixed `hr_`; every variable this file touches
@@ -145,7 +176,8 @@
145
176
  # `HR_CFG_VALUE`, `HR_CFG_COMMAND_KEYS`, `HR_PROTECTED_DEFAULT`, and the lane's
146
177
  # `HR_LANE_RANK`, `HR_LANE_STATE`, `HR_LANE_RESUME_AT`, `HR_LANE_OBSERVED_AT`,
147
178
  # `HR_LANE_OBSERVED_REPO`, `HR_LANE_OWNER_SLUG`, `HR_LANE_OWNER_PID`,
148
- # `HR_LANE_OWNER_AT` and `HR_LANE_BROKEN_OWNER`. Every one of them is assigned
179
+ # `HR_LANE_OWNER_AT` and `HR_LANE_BROKEN_OWNER`, and the remote state bundle's
180
+ # names, which `hr_remote_names_var` assigns. Every one of them is assigned
149
181
  # before it is read by the function that owns it, so an inherited value from a
150
182
  # parent process is overwritten rather than believed.
151
183
  #
@@ -186,6 +218,13 @@
186
218
  # hr_branch_is_protected "$root" "$(hr_current_branch "$root")"; echo $?
187
219
  # -> the resolved set, then 0 or 1
188
220
  # hr_state_dir "$root"; hr_command "$root" test
221
+ # phase toggles each against its own fresh fixture, so the cache is cold:
222
+ # {"defaultBranch":"main","phases":{"qa":true}}
223
+ # hr_phase_enabled "$root" qa; echo $? -> 0, prints nothing
224
+ # {"defaultBranch":"main"}
225
+ # hr_phase_enabled "$root" qa; echo $? -> 1
226
+ # {"defaultBranch":"main","phases":{"qa":"yes"}}
227
+ # hr_phase_enabled "$root" qa; echo $? -> 2
189
228
  # adopted + broken printf 'x' > "$root/harness.config.json"
190
229
  # hr_branch_is_protected "$root" <branch>; echo $? -> 2
191
230
  # hr_state_dir "$root"; echo $? -> 2, prints nothing
@@ -522,6 +561,10 @@ hr_config_load() {
522
561
  # `protectedBranches.present` records that the key was there AS AN ARRAY,
523
562
  # which is what lets an explicitly EMPTY list read as a configured set rather
524
563
  # than as an absent one.
564
+ #
565
+ # A `phases.*` value that is not a boolean is emitted as `invalid`, so the
566
+ # string `"true"` — which `tostring` would otherwise make indistinguishable
567
+ # from `true` — reaches `hr_phase_enabled` as a value it refuses (2).
525
568
  out=$(jq -n -r '
526
569
  def s($k; $v):
527
570
  if $v == null then empty
@@ -552,6 +595,10 @@ hr_config_load() {
552
595
  s("commands.build"; try .commands.build catch null),
553
596
  s("commands.devServer"; try .commands.devServer catch null),
554
597
  s("commands.depInstall"; try .commands.depInstall catch null),
598
+ s("phases.parity"; try (.phases.parity | if type == "boolean" or . == null then . else "invalid" end) catch null),
599
+ s("phases.qa"; try (.phases.qa | if type == "boolean" or . == null then . else "invalid" end) catch null),
600
+ s("phases.docs"; try (.phases.docs | if type == "boolean" or . == null then . else "invalid" end) catch null),
601
+ s("execution.target"; try .execution.target catch null),
555
602
  s("protectedBranches.present";
556
603
  try (if (.protectedBranches | type) == "array" then "1" else null end) catch null),
557
604
  l("protectedBranches"; try .protectedBranches catch null)
@@ -775,6 +822,49 @@ hr_command() {
775
822
  hr_config_scalar "$root" "commands.$key" ""
776
823
  }
777
824
 
825
+ # `phases.<phase>` — whether one optional phase runs. THE ONE TYPED READER THAT
826
+ # PRINTS NOTHING: the answer is the status. 0 = `true`; 1 = `false` or unset (an
827
+ # unset flag is false, as the flow-progress ledger's `phases:` line records it);
828
+ # 2 = the configuration is unresolvable, <phase> is not `parity` / `qa` /
829
+ # `docs`, or the stored value is not a boolean — a value the schema forbids,
830
+ # which this refuses rather than guesses about.
831
+ hr_phase_enabled() {
832
+ local root="${1-}" phase="${2-}"
833
+ case "$phase" in
834
+ parity|qa|docs) ;;
835
+ *) return 2 ;;
836
+ esac
837
+ hr_config_load "$root" || return 2
838
+ hr_cfg_scalar_var "phases.$phase" || return 1
839
+ case "$HR_CFG_VALUE" in
840
+ true) return 0 ;;
841
+ false) return 1 ;;
842
+ esac
843
+ return 2
844
+ }
845
+
846
+ # `execution.target` — where an unattended run executes: `local` or
847
+ # `github-actions`. THE ONE READER OF THE KEY IN THIS FAMILY; a script that
848
+ # needs it calls this. Schema default `local`, so an absent key prints `local`
849
+ # and 1 is never returned. 2 — printing nothing — when the configuration is
850
+ # unresolvable or the stored value is outside the schema's enum, which this
851
+ # refuses rather than guesses about, as `hr_phase_enabled` does.
852
+ hr_execution_target() {
853
+ local root="${1-}"
854
+ hr_config_load "$root" || return 2
855
+ if ! hr_cfg_scalar_var "execution.target"; then
856
+ printf 'local\n'
857
+ return 0
858
+ fi
859
+ case "$HR_CFG_VALUE" in
860
+ local|github-actions)
861
+ printf '%s\n' "$HR_CFG_VALUE"
862
+ return 0
863
+ ;;
864
+ esac
865
+ return 2
866
+ }
867
+
778
868
  # ---------------------------------------------------------------------------
779
869
  # The protected-branch trichotomy.
780
870
  # ---------------------------------------------------------------------------
@@ -1008,6 +1098,331 @@ hr_push_env_files() {
1008
1098
  return 0
1009
1099
  }
1010
1100
 
1101
+ # ---------------------------------------------------------------------------
1102
+ # THE RUN REGISTRY.
1103
+ #
1104
+ # THE CONTRACT IS NOT THIS SECTION'S. The registry's JSON shape
1105
+ # (`{"runs": {"<branch>": {…}}}`), its field set and its status vocabulary are
1106
+ # stated in `autonomous-watcher.sh`'s header and registry comment block; this
1107
+ # section only reads and writes that shape, for every script that shares the
1108
+ # file, so no second copy of a writer exists.
1109
+ #
1110
+ # Each function takes the registry file as its first argument — the caller
1111
+ # resolves it as `hr_state_path <root> autonomous_logs/registry.json` — and is
1112
+ # write exception 2 in the header. Every value is written as a JSON string;
1113
+ # every write stamps `branch` and `updated_at` on the record and replaces the
1114
+ # file through a `mktemp` + `mv`. No shell option is assumed: the caller may set
1115
+ # `-e`, `-u` or neither.
1116
+ # ---------------------------------------------------------------------------
1117
+
1118
+ # Create an empty registry at <file> when none exists.
1119
+ hr_registry_init() {
1120
+ local file="${1-}"
1121
+ [ -n "$file" ] || return 1
1122
+ [ -f "$file" ] || printf '{"runs":{}}\n' >"$file"
1123
+ }
1124
+
1125
+ # hr_registry_set <file> <branch> <key> <value>
1126
+ hr_registry_set() {
1127
+ local file="${1-}" branch="${2-}" key="${3-}" value="${4-}" tmp
1128
+ hr_registry_init "$file" || :
1129
+ tmp="$(mktemp)" || return 1
1130
+ if jq --arg b "$branch" --arg k "$key" --arg v "$value" --arg now "$(date '+%Y-%m-%dT%H:%M:%S')" '
1131
+ .runs[$b] = ((.runs[$b] // {}) + {($k): $v, "branch": $b, "updated_at": $now})
1132
+ ' "$file" >"$tmp"; then
1133
+ mv "$tmp" "$file"
1134
+ else
1135
+ rm -f "$tmp"
1136
+ return 1
1137
+ fi
1138
+ }
1139
+
1140
+ # hr_registry_get <file> <branch> <key> -> the value, or nothing
1141
+ hr_registry_get() {
1142
+ local file="${1-}"
1143
+ hr_registry_init "$file" || :
1144
+ jq -r --arg b "${2-}" --arg k "${3-}" '.runs[$b][$k] // empty' "$file" 2>/dev/null
1145
+ }
1146
+
1147
+ # Every branch in the registry at <file>, one per line. Prints nothing when the
1148
+ # file cannot be read as a registry, which leaves each caller iterating over an
1149
+ # empty set.
1150
+ hr_registry_branches() {
1151
+ local file="${1-}"
1152
+ hr_registry_init "$file" || :
1153
+ jq -r '.runs | keys[]' "$file" 2>/dev/null
1154
+ }
1155
+
1156
+ # ---------------------------------------------------------------------------
1157
+ # THE REMOTE STATE BUNDLE.
1158
+ #
1159
+ # THE FORMAT OF RECORD. What a remote job carries across a job boundary and
1160
+ # reports back, uploaded as the Actions artifact `harness-state`. Every name
1161
+ # below is a variable `hr_remote_names_var` assigns; no function spells one.
1162
+ #
1163
+ # <bundle>/status.json the job's record, fixed schema below
1164
+ # <bundle>/clarifications/<branch>/ the whole branch directory, answered/ included
1165
+ # <bundle>/PAUSE_PROGRESS.md when present
1166
+ # <bundle>/flow_walker_state <state_dir>/.flow_walker_state, WITHOUT its dot
1167
+ # <bundle>/run.log <state_dir>/autonomous_logs/<branch>.log; never restored
1168
+ #
1169
+ # The walker state loses its dot because `actions/upload-artifact` skips hidden
1170
+ # files by default. NOTHING IN THE BUNDLE IS EVER COMMITTED: every file in it is
1171
+ # gitignored machine-local state, and the remote-status and move-aside paths sit
1172
+ # under `autonomous_logs/`, whose ignore rule already covers them.
1173
+ #
1174
+ # WHO READS EACH FILE. `status.json`: `remote-run.sh sync` / `status` (into the
1175
+ # local registry), `continue` / `poll` (the decision, `chain`, the reset time)
1176
+ # and the next job's seed. The clarification directory and `PAUSE_PROGRESS.md`:
1177
+ # the next job, and the user's local mirror. The walker state: the next job
1178
+ # only. `run.log`: the user only — `sync` copies it to the main checkout's logs
1179
+ # directory itself, and no restore places it.
1180
+ #
1181
+ # `status.json` — schema `HR_REMOTE_STATE_SCHEMA`; every value a JSON string:
1182
+ # schema a reader that does not recognise it treats the bundle as absent
1183
+ # branch, engine the run's branch; `task` | `user_review` | `docs`
1184
+ # status `running` | `parked` | `park_loop` | `paused` | `completed` | `failed`
1185
+ # pause_reason `usage` | `budget` | `user` | `overload` | empty. The registry's
1186
+ # registry-only `killed` and `expired` are never written
1187
+ # here: `sync` derives each from a run and its artifact
1188
+ # list, not from a bundle
1189
+ # usage_resume_at the epoch second a usage pause may resume at, or empty
1190
+ # park_loop_cycles, resume_max_question_index, auto_resumes, stall_restarts
1191
+ # the counters that must survive a job boundary
1192
+ # chain the writing job's OWN input `HARNESS_INPUT_CHAIN`, never
1193
+ # a value carried from an earlier bundle
1194
+ # control_polled_at the epoch second up to which the job checked for a
1195
+ # `harness pause <branch>` run, or empty
1196
+ # decision `continue` | `wait-poller` | `stop`
1197
+ # detail one human-readable line
1198
+ # run_id, run_url, written_at
1199
+ # `GITHUB_RUN_ID`, the run's URL, the epoch second written
1200
+ # ---------------------------------------------------------------------------
1201
+
1202
+ hr_remote_names_var() {
1203
+ HR_REMOTE_STATE_SCHEMA='1'
1204
+ HR_REMOTE_STATUS_FILE='status.json'
1205
+ HR_REMOTE_CLARIFY_DIR='clarifications'
1206
+ HR_REMOTE_PAUSE_FILE='PAUSE_PROGRESS.md'
1207
+ HR_REMOTE_WALKER_FILE='flow_walker_state'
1208
+ HR_REMOTE_WALKER_SOURCE=".$HR_REMOTE_WALKER_FILE"
1209
+ HR_REMOTE_LOG_FILE='run.log'
1210
+ HR_REMOTE_LOGS_DIR='autonomous_logs'
1211
+ HR_REMOTE_STATUS_SOURCE="$HR_REMOTE_LOGS_DIR/remote_status.json"
1212
+ HR_REMOTE_SUPERSEDED_DIR="$HR_REMOTE_LOGS_DIR/remote_superseded"
1213
+ }
1214
+
1215
+ # hr_remote_status_write <registry_file> <branch> <out_json> <decision> <detail>
1216
+ #
1217
+ # Reads <branch>'s record through `hr_registry_get` and replaces <out_json> by
1218
+ # rename, so a concurrent reader sees the old file or the new one, never half.
1219
+ # Prints nothing. 1 — writing nothing — when an argument is missing, <decision>
1220
+ # is outside its vocabulary, the record has no in-vocabulary `status`, or the
1221
+ # write fails. A `pause_reason` outside the bundle's vocabulary is written empty.
1222
+ hr_remote_status_write() {
1223
+ local registry="${1-}" branch="${2-}" out="${3-}" decision="${4-}" detail="${5-}"
1224
+ local status engine reason chain run_id run_url tmp
1225
+ [ -n "$registry" ] && [ -n "$branch" ] && [ -n "$out" ] || return 1
1226
+ case "$decision" in
1227
+ continue|wait-poller|stop) ;;
1228
+ *) return 1 ;;
1229
+ esac
1230
+ hr_remote_names_var
1231
+ status=$(hr_registry_get "$registry" "$branch" status)
1232
+ case "$status" in
1233
+ running|parked|park_loop|paused|completed|failed) ;;
1234
+ *) return 1 ;;
1235
+ esac
1236
+ engine=$(hr_registry_get "$registry" "$branch" engine)
1237
+ reason=$(hr_registry_get "$registry" "$branch" pause_reason)
1238
+ case "$reason" in
1239
+ usage|budget|user|overload) ;;
1240
+ *) reason="" ;;
1241
+ esac
1242
+ chain="${HARNESS_INPUT_CHAIN-}"
1243
+ case "$chain" in
1244
+ ''|*[!0-9]*) chain="" ;;
1245
+ esac
1246
+ run_id="${GITHUB_RUN_ID-}"
1247
+ run_url=""
1248
+ if [ -n "$run_id" ] && [ -n "${GITHUB_SERVER_URL-}" ] && [ -n "${GITHUB_REPOSITORY-}" ]; then
1249
+ run_url="${GITHUB_SERVER_URL%/}/${GITHUB_REPOSITORY}/actions/runs/${run_id}"
1250
+ fi
1251
+ detail=${detail//$'\r'/ }
1252
+ detail=${detail//$'\n'/ }
1253
+ # The temp file sits beside <out_json> so the `mv` is a same-filesystem rename.
1254
+ tmp=$(mktemp "${out}.tmp.XXXXXX" 2>/dev/null) || return 1
1255
+ if jq -n \
1256
+ --arg schema "$HR_REMOTE_STATE_SCHEMA" \
1257
+ --arg branch "$branch" \
1258
+ --arg engine "$engine" \
1259
+ --arg status "$status" \
1260
+ --arg pause_reason "$reason" \
1261
+ --arg usage_resume_at "$(hr_registry_get "$registry" "$branch" usage_resume_at)" \
1262
+ --arg park_loop_cycles "$(hr_registry_get "$registry" "$branch" park_loop_cycles)" \
1263
+ --arg resume_max_question_index "$(hr_registry_get "$registry" "$branch" resume_max_question_index)" \
1264
+ --arg auto_resumes "$(hr_registry_get "$registry" "$branch" auto_resumes)" \
1265
+ --arg stall_restarts "$(hr_registry_get "$registry" "$branch" stall_restarts)" \
1266
+ --arg chain "$chain" \
1267
+ --arg control_polled_at "$(hr_registry_get "$registry" "$branch" control_polled_at)" \
1268
+ --arg decision "$decision" \
1269
+ --arg detail "$detail" \
1270
+ --arg run_id "$run_id" \
1271
+ --arg run_url "$run_url" \
1272
+ --arg written_at "$(date +%s)" \
1273
+ '{schema: $schema, branch: $branch, engine: $engine, status: $status,
1274
+ pause_reason: $pause_reason, usage_resume_at: $usage_resume_at,
1275
+ park_loop_cycles: $park_loop_cycles,
1276
+ resume_max_question_index: $resume_max_question_index,
1277
+ auto_resumes: $auto_resumes, stall_restarts: $stall_restarts,
1278
+ chain: $chain, control_polled_at: $control_polled_at,
1279
+ decision: $decision, detail: $detail,
1280
+ run_id: $run_id, run_url: $run_url, written_at: $written_at}' \
1281
+ >"$tmp" 2>/dev/null && mv "$tmp" "$out" 2>/dev/null; then
1282
+ return 0
1283
+ fi
1284
+ rm -f "$tmp"
1285
+ return 1
1286
+ }
1287
+
1288
+ # hr_remote_status_get <status_json> <key> -> the value
1289
+ #
1290
+ # 0 with the value printed; 1 when <key> is absent; 2 when the file is
1291
+ # unreadable, is not JSON, or its `schema` is not `HR_REMOTE_STATE_SCHEMA` —
1292
+ # the bundle is then treated as absent, whatever <key> holds.
1293
+ hr_remote_status_get() {
1294
+ local file="${1-}" key="${2-}" out
1295
+ [ -n "$file" ] && [ -r "$file" ] || return 2
1296
+ hr_remote_names_var
1297
+ # One `jq`, one marker character: `S` a schema it does not recognise, `A` an
1298
+ # absent key, `V` a value — an exit status cannot carry three outcomes.
1299
+ out=$(jq -r --arg k "$key" --arg s "$HR_REMOTE_STATE_SCHEMA" '
1300
+ if type != "object" or .schema != $s then "S"
1301
+ elif (.[$k] | type) == "string" then "V" + .[$k]
1302
+ else "A" end' "$file" 2>/dev/null) || return 2
1303
+ case "$out" in
1304
+ V*) printf '%s\n' "${out#V}" ;;
1305
+ A) return 1 ;;
1306
+ *) return 2 ;;
1307
+ esac
1308
+ }
1309
+
1310
+ # hr_remote_bundle_write <root> <branch> <registry_file> <out_dir>
1311
+ #
1312
+ # Assembles the format above in <out_dir>, which must be absent or empty, from
1313
+ # <root>'s configured state directory. `status.json` is the job's own
1314
+ # `autonomous_logs/remote_status.json` when present; otherwise it is written
1315
+ # from <branch>'s registry record with decision `stop`, because a job that never
1316
+ # wrote its status never decided to continue. 0 written; 1 a missing argument,
1317
+ # a non-empty <out_dir> or a failed copy; 2 <root>'s configuration unresolvable.
1318
+ hr_remote_bundle_write() {
1319
+ local root="${1-}" branch="${2-}" registry="${3-}" out="${4-}" state base clarify
1320
+ [ -n "$root" ] && [ -n "$branch" ] && [ -n "$registry" ] && [ -n "$out" ] || return 1
1321
+ state=$(hr_state_dir "$root") || return 2
1322
+ hr_remote_names_var
1323
+ base="${root%/}/$state"
1324
+ out=${out%/}
1325
+ if [ -e "$out" ]; then
1326
+ [ -d "$out" ] || return 1
1327
+ [ -z "$(ls -A "$out" 2>/dev/null)" ] || return 1
1328
+ fi
1329
+ mkdir -p "$out" 2>/dev/null || return 1
1330
+
1331
+ if [ -f "$base/$HR_REMOTE_STATUS_SOURCE" ]; then
1332
+ cp "$base/$HR_REMOTE_STATUS_SOURCE" "$out/$HR_REMOTE_STATUS_FILE" 2>/dev/null || return 1
1333
+ else
1334
+ hr_remote_status_write "$registry" "$branch" "$out/$HR_REMOTE_STATUS_FILE" stop \
1335
+ "the job wrote no status; derived from the registry record when the bundle was saved" || return 1
1336
+ fi
1337
+ if [ -d "$base/$HR_REMOTE_CLARIFY_DIR/$branch" ]; then
1338
+ clarify="$out/$HR_REMOTE_CLARIFY_DIR/$branch"
1339
+ mkdir -p "${clarify%/*}" 2>/dev/null || return 1
1340
+ # No trailing slash on the source: BSD `cp -R dir/` copies the contents instead.
1341
+ cp -R "$base/$HR_REMOTE_CLARIFY_DIR/$branch" "$clarify" 2>/dev/null || return 1
1342
+ fi
1343
+ if [ -f "$base/$HR_REMOTE_PAUSE_FILE" ]; then
1344
+ cp "$base/$HR_REMOTE_PAUSE_FILE" "$out/$HR_REMOTE_PAUSE_FILE" 2>/dev/null || return 1
1345
+ fi
1346
+ if [ -f "$base/$HR_REMOTE_WALKER_SOURCE" ]; then
1347
+ cp "$base/$HR_REMOTE_WALKER_SOURCE" "$out/$HR_REMOTE_WALKER_FILE" 2>/dev/null || return 1
1348
+ fi
1349
+ if [ -f "$base/$HR_REMOTE_LOGS_DIR/$branch.log" ]; then
1350
+ cp "$base/$HR_REMOTE_LOGS_DIR/$branch.log" "$out/$HR_REMOTE_LOG_FILE" 2>/dev/null || return 1
1351
+ fi
1352
+ return 0
1353
+ }
1354
+
1355
+ # hr_remote_bundle_restore <bundle_dir> <root> <branch> <mode>
1356
+ #
1357
+ # <mode> `job` places the clarification directory, `PAUSE_PROGRESS.md`, the
1358
+ # walker state (back under its dotted name) and `status.json` (as
1359
+ # `autonomous_logs/remote_status.json`); `mirror` places the first two only. The
1360
+ # run log is placed by neither.
1361
+ #
1362
+ # THE CLARIFICATION DIRECTORY IS REPLACED WHOLESALE, AND NOTHING IS DELETED. An
1363
+ # existing target is moved aside with one `mv` into
1364
+ # `autonomous_logs/remote_superseded/<epoch>/clarifications/<branch>` (`<epoch>-<n>`
1365
+ # when an earlier restore took that second) before the
1366
+ # bundle's copy goes in, so a stale local pair cannot survive and no recursive
1367
+ # removal is ever shelled out. A bundle carrying no clarification directory
1368
+ # leaves the target alone: in a mirror it may hold an answer not yet relayed.
1369
+ #
1370
+ # 0 restored; 1 a missing argument, an unknown <mode> or a failed copy; 2 —
1371
+ # touching nothing — when the bundle is unrecognised (no readable `status.json`,
1372
+ # a schema other than `HR_REMOTE_STATE_SCHEMA`, or a `branch` other than
1373
+ # <branch>) or <root>'s configuration is unresolvable.
1374
+ hr_remote_bundle_restore() {
1375
+ local bundle="${1-}" root="${2-}" branch="${3-}" mode="${4-}"
1376
+ local state base named target epoch aside n tmp
1377
+ [ -n "$bundle" ] && [ -n "$root" ] && [ -n "$branch" ] || return 1
1378
+ case "$mode" in
1379
+ job|mirror) ;;
1380
+ *) return 1 ;;
1381
+ esac
1382
+ hr_remote_names_var
1383
+ bundle=${bundle%/}
1384
+ named=$(hr_remote_status_get "$bundle/$HR_REMOTE_STATUS_FILE" branch) || return 2
1385
+ [ "$named" = "$branch" ] || return 2
1386
+ state=$(hr_state_dir "$root") || return 2
1387
+ base="${root%/}/$state"
1388
+
1389
+ if [ -d "$bundle/$HR_REMOTE_CLARIFY_DIR/$branch" ]; then
1390
+ target="$base/$HR_REMOTE_CLARIFY_DIR/$branch"
1391
+ if [ -e "$target" ]; then
1392
+ epoch=$(date +%s)
1393
+ aside="$base/$HR_REMOTE_SUPERSEDED_DIR/$epoch"
1394
+ n=0
1395
+ while [ -e "$aside/$HR_REMOTE_CLARIFY_DIR/$branch" ]; do
1396
+ n=$((n + 1))
1397
+ aside="$base/$HR_REMOTE_SUPERSEDED_DIR/$epoch-$n"
1398
+ done
1399
+ aside="$aside/$HR_REMOTE_CLARIFY_DIR/$branch"
1400
+ mkdir -p "${aside%/*}" 2>/dev/null || return 1
1401
+ mv "$target" "$aside" 2>/dev/null || return 1
1402
+ fi
1403
+ mkdir -p "${target%/*}" 2>/dev/null || return 1
1404
+ cp -R "$bundle/$HR_REMOTE_CLARIFY_DIR/$branch" "$target" 2>/dev/null || return 1
1405
+ fi
1406
+ if [ -f "$bundle/$HR_REMOTE_PAUSE_FILE" ]; then
1407
+ mkdir -p "$base" 2>/dev/null || return 1
1408
+ cp "$bundle/$HR_REMOTE_PAUSE_FILE" "$base/$HR_REMOTE_PAUSE_FILE" 2>/dev/null || return 1
1409
+ fi
1410
+ [ "$mode" = job ] || return 0
1411
+
1412
+ if [ -f "$bundle/$HR_REMOTE_WALKER_FILE" ]; then
1413
+ mkdir -p "$base" 2>/dev/null || return 1
1414
+ cp "$bundle/$HR_REMOTE_WALKER_FILE" "$base/$HR_REMOTE_WALKER_SOURCE" 2>/dev/null || return 1
1415
+ fi
1416
+ mkdir -p "$base/$HR_REMOTE_LOGS_DIR" 2>/dev/null || return 1
1417
+ tmp=$(mktemp "$base/$HR_REMOTE_STATUS_SOURCE.tmp.XXXXXX" 2>/dev/null) || return 1
1418
+ if cp "$bundle/$HR_REMOTE_STATUS_FILE" "$tmp" 2>/dev/null \
1419
+ && mv "$tmp" "$base/$HR_REMOTE_STATUS_SOURCE" 2>/dev/null; then
1420
+ return 0
1421
+ fi
1422
+ rm -f "$tmp"
1423
+ return 1
1424
+ }
1425
+
1011
1426
  # ---------------------------------------------------------------------------
1012
1427
  # THE MACHINE-LEVEL USAGE LANE.
1013
1428
  #