autonomous-sdlc-harness 0.2.0 → 0.4.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 (78) 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 +56 -6
  6. package/dist/commands/doctor.js.map +1 -1
  7. package/dist/commands/init.js +107 -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/defaultBranchPush.js +26 -0
  14. package/dist/core/defaultBranchPush.js.map +1 -0
  15. package/dist/core/git.js +61 -0
  16. package/dist/core/git.js.map +1 -1
  17. package/dist/core/paths.js +22 -2
  18. package/dist/core/paths.js.map +1 -1
  19. package/dist/core/prompt.js +6 -2
  20. package/dist/core/prompt.js.map +1 -1
  21. package/dist/core/writer.js +2 -0
  22. package/dist/core/writer.js.map +1 -1
  23. package/dist/doctor/checks.js +482 -73
  24. package/dist/doctor/checks.js.map +1 -1
  25. package/dist/generators/githubWorkflows.js +66 -0
  26. package/dist/generators/githubWorkflows.js.map +1 -0
  27. package/dist/generators/notifications.js +105 -19
  28. package/dist/generators/notifications.js.map +1 -1
  29. package/dist/generators/outerLoopScripts.js +17 -3
  30. package/dist/generators/outerLoopScripts.js.map +1 -1
  31. package/dist/generators/permissionProfile.js +141 -21
  32. package/dist/generators/permissionProfile.js.map +1 -1
  33. package/dist/generators/projectSettings.js +2 -1
  34. package/dist/generators/projectSettings.js.map +1 -1
  35. package/dist/generators/repoRoot.js +23 -4
  36. package/dist/generators/repoRoot.js.map +1 -1
  37. package/dist/generators/stateDir.js +9 -3
  38. package/dist/generators/stateDir.js.map +1 -1
  39. package/dist/machine/paths.js +4 -1
  40. package/dist/machine/paths.js.map +1 -1
  41. package/dist/remote/githubActions.js +86 -0
  42. package/dist/remote/githubActions.js.map +1 -0
  43. package/dist/retrieval/queryLog.js +6 -4
  44. package/dist/retrieval/queryLog.js.map +1 -1
  45. package/dist/retrieval/runtime.js +46 -33
  46. package/dist/retrieval/runtime.js.map +1 -1
  47. package/dist/retrieval/search.js +21 -10
  48. package/dist/retrieval/search.js.map +1 -1
  49. package/dist/retrieval/server.js +1 -1
  50. package/dist/retrieval/setup.js +2 -1
  51. package/dist/retrieval/setup.js.map +1 -1
  52. package/package.json +2 -2
  53. package/templates/README.md +3 -2
  54. package/templates/claude/context/conventions.md +1 -1
  55. package/templates/claude/context/layer.md +1 -1
  56. package/templates/claude/push-notify.env.example +7 -2
  57. package/templates/claude/settings.autonomous.json +1 -1
  58. package/templates/github/workflows/harness-resume.yml +124 -0
  59. package/templates/github/workflows/harness-run.yml +446 -0
  60. package/templates/repo/gitignore +11 -1
  61. package/templates/scripts/README.md +1 -1
  62. package/templates/scripts/autonomous-watcher.sh +1296 -169
  63. package/templates/scripts/flow-walker.sh +629 -0
  64. package/templates/scripts/flows/task_plan_writing.graph.json +192 -0
  65. package/templates/scripts/lib/flow-walker-gates.sh +165 -0
  66. package/templates/scripts/lib/harness-run-lib.sh +595 -18
  67. package/templates/scripts/remote-run.sh +1784 -0
  68. package/templates/scripts/restart-watcher.sh +21 -1
  69. package/templates/scripts/run-test-suite.sh +182 -0
  70. package/templates/scripts/scratch-run.sh +2 -1
  71. package/templates/state-dir/README-root.md +1 -1
  72. package/templates/state-dir/autonomous_logs/README.md +2 -2
  73. package/templates/state-dir/improvement_observations/README.md +2 -0
  74. package/templates/state-dir/scratch/README.md +1 -1
  75. package/templates/state-dir/test_fix_plan_reviews/README.md +9 -0
  76. package/templates/state-dir/test_fix_plans/README.md +9 -0
  77. package/templates/state-dir/test_fix_point_reviews/README.md +9 -0
  78. 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,47 @@
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`, plus the lock directory `<file>.lock` (and its
62
+ # `.stale.*` move-aside and `.break` mutex while a stale one is broken)
63
+ # and the temp files
64
+ # `.registry.*` in the registry's own directory. Written only by
65
+ # `hr_registry_init`, `hr_registry_set` and the `hr_registry_lock` /
66
+ # `hr_registry_unlock` pair `hr_registry_set` calls.
67
+ # 3. THE REMOTE STATE BUNDLE writes the files its format lists. Fence: inside
68
+ # `<root>/<state_dir>/` (resolved through `hr_state_dir`), only
69
+ # `autonomous_logs/remote_status.json`, `clarifications/<branch>/`,
70
+ # `PAUSE_PROGRESS.md`, `.flow_walker_state` and the move-aside directory
71
+ # `autonomous_logs/remote_superseded/`; outside it, only the caller-named
72
+ # `<out_dir>` of `hr_remote_bundle_write` and the caller-named `<out_json>`
73
+ # of `hr_remote_status_write`. Written only by `hr_remote_status_write`,
74
+ # `hr_remote_bundle_write` and `hr_remote_bundle_restore`, and nothing
75
+ # there but a writer's own failed temp file is ever removed.
76
+ #
77
+ # A caller that calls no `hr_lane_*`, `hr_registry_init`, `hr_registry_set`,
78
+ # `hr_registry_lock`, `hr_registry_unlock`, `hr_remote_status_write`,
79
+ # `hr_remote_bundle_write` or `hr_remote_bundle_restore` function still gets a library that only reads. The
80
+ # lane's ceilings are the only environment values here that carry policy, because
81
+ # the lane is machine-scoped and has no configuration key to carry them; each is
55
82
  # named where it is used. `XDG_STATE_HOME`, `XDG_CONFIG_HOME`, `XDG_CACHE_HOME`,
56
83
  # `HOME` and `PWD` are also read, as location anchors only, and `PATH` is read by
57
84
  # `hr_path_with_fallbacks` alone — as that function's input, which it prints back
58
- # transformed and never assigns.
85
+ # transformed and never assigns. `GITHUB_RUN_ID`, `GITHUB_SERVER_URL`,
86
+ # `GITHUB_REPOSITORY` and `HARNESS_INPUT_CHAIN` are read by
87
+ # `hr_remote_status_write` alone, as provenance values copied into
88
+ # `status.json` — never a configured value and never policy.
59
89
  #
60
90
  # CONFIGURATION IS READ AT RUN TIME, NOT FROZEN AT GENERATION TIME. That is the
61
91
  # whole reason the shipped scripts carry no `{{token}}`: a guard whose protected
@@ -84,8 +114,10 @@
84
114
  # and return 2 when the configuration could not be read. A caller must be able
85
115
  # to tell "the key is empty" from "the file could not be read", because the
86
116
  # 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
117
+ # business acting in. `hr_phase_enabled` answers through the same three
118
+ # statuses and prints nothing: 0 is `true`, 1 is `false` or unset, and 2 also
119
+ # covers an unknown phase name or a non-boolean value. A reader that wants the
120
+ # finer distinction between "no `harness.config.json` at all" and "one that would not parse" calls
89
121
  # `hr_config_load` directly, which returns 1 for the first and 2 for the second.
90
122
  #
91
123
  # THE PROTECTED SET. It is *(`protectedBranches` if present, else that key's
@@ -131,10 +163,15 @@
131
163
  # dialect marker for editors and linters). No `set -e` and no `set -u` — a
132
164
  # sourced library must not change its caller's shell — but every parameter
133
165
  # 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
166
+ # both. No top-level side effects, no exiting, no writes outside the fences of
167
+ # the write exceptions named above, and no diagnostics on stdout OR stderr:
168
+ # every reader is silent on failure and signals through its return status,
169
+ # because callers capture stdout. The one pass-through is the registry's two
170
+ # writers, `hr_registry_init` and `hr_registry_set`, which leave the shell's,
171
+ # `mktemp`'s and `jq`'s own stderr on a failed write to the caller, as the
172
+ # watcher's bodies they replaced did — that stream is the watcher's log — and
173
+ # add one line of their own, naming the lock, when `hr_registry_set` cannot
174
+ # take the registry lock. That silence is why the lane reports a lock it BROKE through a
138
175
  # variable instead of a log line — the caller owns the log.
139
176
  #
140
177
  # NAMING. Every function is prefixed `hr_`; every variable this file touches
@@ -145,7 +182,8 @@
145
182
  # `HR_CFG_VALUE`, `HR_CFG_COMMAND_KEYS`, `HR_PROTECTED_DEFAULT`, and the lane's
146
183
  # `HR_LANE_RANK`, `HR_LANE_STATE`, `HR_LANE_RESUME_AT`, `HR_LANE_OBSERVED_AT`,
147
184
  # `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
185
+ # `HR_LANE_OWNER_AT` and `HR_LANE_BROKEN_OWNER`, and the remote state bundle's
186
+ # names, which `hr_remote_names_var` assigns. Every one of them is assigned
149
187
  # before it is read by the function that owns it, so an inherited value from a
150
188
  # parent process is overwritten rather than believed.
151
189
  #
@@ -186,6 +224,13 @@
186
224
  # hr_branch_is_protected "$root" "$(hr_current_branch "$root")"; echo $?
187
225
  # -> the resolved set, then 0 or 1
188
226
  # hr_state_dir "$root"; hr_command "$root" test
227
+ # phase toggles each against its own fresh fixture, so the cache is cold:
228
+ # {"defaultBranch":"main","phases":{"qa":true}}
229
+ # hr_phase_enabled "$root" qa; echo $? -> 0, prints nothing
230
+ # {"defaultBranch":"main"}
231
+ # hr_phase_enabled "$root" qa; echo $? -> 1
232
+ # {"defaultBranch":"main","phases":{"qa":"yes"}}
233
+ # hr_phase_enabled "$root" qa; echo $? -> 2
189
234
  # adopted + broken printf 'x' > "$root/harness.config.json"
190
235
  # hr_branch_is_protected "$root" <branch>; echo $? -> 2
191
236
  # hr_state_dir "$root"; echo $? -> 2, prints nothing
@@ -522,6 +567,10 @@ hr_config_load() {
522
567
  # `protectedBranches.present` records that the key was there AS AN ARRAY,
523
568
  # which is what lets an explicitly EMPTY list read as a configured set rather
524
569
  # than as an absent one.
570
+ #
571
+ # A `phases.*` value that is not a boolean is emitted as `invalid`, so the
572
+ # string `"true"` — which `tostring` would otherwise make indistinguishable
573
+ # from `true` — reaches `hr_phase_enabled` as a value it refuses (2).
525
574
  out=$(jq -n -r '
526
575
  def s($k; $v):
527
576
  if $v == null then empty
@@ -552,6 +601,10 @@ hr_config_load() {
552
601
  s("commands.build"; try .commands.build catch null),
553
602
  s("commands.devServer"; try .commands.devServer catch null),
554
603
  s("commands.depInstall"; try .commands.depInstall catch null),
604
+ s("phases.parity"; try (.phases.parity | if type == "boolean" or . == null then . else "invalid" end) catch null),
605
+ s("phases.qa"; try (.phases.qa | if type == "boolean" or . == null then . else "invalid" end) catch null),
606
+ s("phases.docs"; try (.phases.docs | if type == "boolean" or . == null then . else "invalid" end) catch null),
607
+ s("execution.target"; try .execution.target catch null),
555
608
  s("protectedBranches.present";
556
609
  try (if (.protectedBranches | type) == "array" then "1" else null end) catch null),
557
610
  l("protectedBranches"; try .protectedBranches catch null)
@@ -775,6 +828,49 @@ hr_command() {
775
828
  hr_config_scalar "$root" "commands.$key" ""
776
829
  }
777
830
 
831
+ # `phases.<phase>` — whether one optional phase runs. THE ONE TYPED READER THAT
832
+ # PRINTS NOTHING: the answer is the status. 0 = `true`; 1 = `false` or unset (an
833
+ # unset flag is false, as the flow-progress ledger's `phases:` line records it);
834
+ # 2 = the configuration is unresolvable, <phase> is not `parity` / `qa` /
835
+ # `docs`, or the stored value is not a boolean — a value the schema forbids,
836
+ # which this refuses rather than guesses about.
837
+ hr_phase_enabled() {
838
+ local root="${1-}" phase="${2-}"
839
+ case "$phase" in
840
+ parity|qa|docs) ;;
841
+ *) return 2 ;;
842
+ esac
843
+ hr_config_load "$root" || return 2
844
+ hr_cfg_scalar_var "phases.$phase" || return 1
845
+ case "$HR_CFG_VALUE" in
846
+ true) return 0 ;;
847
+ false) return 1 ;;
848
+ esac
849
+ return 2
850
+ }
851
+
852
+ # `execution.target` — where an unattended run executes: `local` or
853
+ # `github-actions`. THE ONE READER OF THE KEY IN THIS FAMILY; a script that
854
+ # needs it calls this. Schema default `local`, so an absent key prints `local`
855
+ # and 1 is never returned. 2 — printing nothing — when the configuration is
856
+ # unresolvable or the stored value is outside the schema's enum, which this
857
+ # refuses rather than guesses about, as `hr_phase_enabled` does.
858
+ hr_execution_target() {
859
+ local root="${1-}"
860
+ hr_config_load "$root" || return 2
861
+ if ! hr_cfg_scalar_var "execution.target"; then
862
+ printf 'local\n'
863
+ return 0
864
+ fi
865
+ case "$HR_CFG_VALUE" in
866
+ local|github-actions)
867
+ printf '%s\n' "$HR_CFG_VALUE"
868
+ return 0
869
+ ;;
870
+ esac
871
+ return 2
872
+ }
873
+
778
874
  # ---------------------------------------------------------------------------
779
875
  # The protected-branch trichotomy.
780
876
  # ---------------------------------------------------------------------------
@@ -1008,6 +1104,487 @@ hr_push_env_files() {
1008
1104
  return 0
1009
1105
  }
1010
1106
 
1107
+ # ---------------------------------------------------------------------------
1108
+ # THE RUN REGISTRY.
1109
+ #
1110
+ # THE CONTRACT IS NOT THIS SECTION'S. The registry's JSON shape
1111
+ # (`{"runs": {"<branch>": {…}}}`), its field set and its status vocabulary are
1112
+ # stated in `autonomous-watcher.sh`'s header and registry comment block; this
1113
+ # section only reads and writes that shape, for every script that shares the
1114
+ # file, so no second copy of a writer exists.
1115
+ #
1116
+ # Each function takes the registry file as its first argument — the caller
1117
+ # resolves it as `hr_state_path <root> autonomous_logs/registry.json` — and is
1118
+ # write exception 2 in the header. Every value is written as a JSON string;
1119
+ # every write stamps `branch` and `updated_at` on the record. No shell option is
1120
+ # assumed: the caller may set `-e`, `-u` or neither.
1121
+ #
1122
+ # EVERY WRITE IS A LOCKED READ-MODIFY-WRITE. Every script sharing the file
1123
+ # writes it, and so does the watcher's own `( … ) &` engine subshell, so two
1124
+ # unserialized writers each read the same record and the second `mv` silently
1125
+ # drops the first one's key. The lock is a `mkdir` of `<file>.lock` holding an
1126
+ # `owner` file with a per-call token — the `mktemp` name of that call's temp
1127
+ # file, because `$$` inside a subshell is the parent's pid and bash 3.2 has no
1128
+ # per-subshell pid variable — and only a call whose token is still in `owner`
1129
+ # releases it.
1130
+ #
1131
+ # STALENESS IS THE LOCK DIRECTORY'S AGE, NEVER A PID. The stall watchdog kills
1132
+ # the engine subshell mid-write, so a dead holder is expected, and a pid says
1133
+ # nothing about a lock the killed subshell took under its parent's `$$`. A write
1134
+ # is one `jq` over a file of a few records, well under a second, so a lock 10 s
1135
+ # old is a crashed holder; a waiter polls every 0.05 s for up to 12 s — past the
1136
+ # stale age, so a waiter that arrives just after a crash breaks the lock rather
1137
+ # than giving up. Both are constants here, not environment values: the header
1138
+ # allows policy-carrying environment values for the lane only.
1139
+ #
1140
+ # BREAKERS ARE SERIALIZED, AND NONE RENAMES A LIVE LOCK. A waiter that judges
1141
+ # the lock stale takes a second `mkdir` mutex, `<file>.lock.break`, judges the
1142
+ # lock's age again under it, and only then renames the lock aside and empties
1143
+ # it, as `hr_lane_acquire` does. A lock re-taken since the first judgement is
1144
+ # fresh, so the second leaves it alone. Between the second judgement and the
1145
+ # rename, nothing but a breaker or the lock's own holder frees the path:
1146
+ # breakers are serialized, and the holder of a lock that old is dead by the
1147
+ # stale rule. The watchdog can kill a breaker inside the mutex, so a mutex 10 s
1148
+ # old is removed by the next waiter. That removal is the one window left open.
1149
+ # Two waiters that judge one abandoned mutex old together can both remove it,
1150
+ # and the second can remove the fresh mutex the first has just taken. Two
1151
+ # breakers then judge the lock at once, so reaching it takes a breaker killed
1152
+ # inside the mutex, a stale lock and three concurrent writers.
1153
+ #
1154
+ # A write that cannot take the lock prints one stderr line naming it, writes
1155
+ # nothing and returns 1.
1156
+ #
1157
+ # ONE CALL, SEVERAL KEYS. `hr_registry_set <file> <branch> <key> <value> [<key>
1158
+ # <value> …]` writes every pair in one `jq` pass under one lock and one `mv`.
1159
+ # A caller writing two keys that a concurrent reader must never see apart — a
1160
+ # status and the reason for it — MUST pass them in one call.
1161
+ #
1162
+ # READERS TAKE NO LOCK. The temp file sits beside the registry, so the `mv` is a
1163
+ # same-filesystem rename and `hr_registry_get`, `hr_registry_branches` and any
1164
+ # other `jq` over the file see the old record or the new one, never a partial
1165
+ # one. `hr_registry_init` creates the file by `ln`-ing a complete temp file to
1166
+ # the registry name, which fails when the name exists, so a reader's create
1167
+ # never truncates a registry a writer has just created.
1168
+ # ---------------------------------------------------------------------------
1169
+
1170
+ # The directory holding <file> — where its temp files go, so every `mv` over it
1171
+ # is a same-filesystem rename.
1172
+ hr_registry_dir() {
1173
+ local file="${1-}" dir
1174
+ case "$file" in
1175
+ */*)
1176
+ dir="${file%/*}"
1177
+ [ -n "$dir" ] || dir=/
1178
+ ;;
1179
+ *) dir=. ;;
1180
+ esac
1181
+ printf '%s\n' "$dir"
1182
+ }
1183
+
1184
+ # Create an empty registry at <file> when none exists. Atomic: the name is
1185
+ # either absent or a complete `{"runs":{}}` — never truncated, never partial.
1186
+ hr_registry_init() {
1187
+ local file="${1-}" dir tmp
1188
+ [ -n "$file" ] || return 1
1189
+ [ -f "$file" ] && return 0
1190
+ dir=$(hr_registry_dir "$file")
1191
+ tmp=$(mktemp "$dir/.registry.XXXXXX") || return 1
1192
+ if printf '{"runs":{}}\n' >"$tmp"; then
1193
+ ln "$tmp" "$file" 2>/dev/null || :
1194
+ fi
1195
+ rm -f "$tmp"
1196
+ [ -f "$file" ]
1197
+ }
1198
+
1199
+ # hr_registry_lock <file> <token> — take `<file>.lock` for <token>, breaking a
1200
+ # stale one (see the section comment). 0 when held; 1, with one stderr line,
1201
+ # when the wait ceiling passed.
1202
+ hr_registry_lock() {
1203
+ local lock="${1-}.lock" token="${2-}" polls=0 start="" now m stale bm
1204
+ # 12 — the wait ceiling and 10 — the stale age, both in seconds. The wait is
1205
+ # judged by the clock, because each poll's forks cost more than its sleep;
1206
+ # 240 polls is the ceiling only when `date` gives no epoch.
1207
+ while :; do
1208
+ if mkdir "$lock" 2>/dev/null; then
1209
+ if printf '%s\n' "$token" >"$lock/owner" 2>/dev/null; then
1210
+ return 0
1211
+ fi
1212
+ rm -f "$lock/owner" 2>/dev/null || :
1213
+ rmdir "$lock" 2>/dev/null || :
1214
+ break
1215
+ fi
1216
+ m=$(hr_lane_mtime "$lock")
1217
+ now=$(date +%s 2>/dev/null) || now=0
1218
+ case "$now" in '' | *[!0-9]*) now=0 ;; esac
1219
+ [ -n "$start" ] || start="$now"
1220
+ if [ "$now" -gt 0 ]; then
1221
+ [ $((now - start)) -lt 12 ] || break
1222
+ else
1223
+ [ "$polls" -lt 240 ] || break
1224
+ fi
1225
+ if [ "$m" -gt 0 ] && [ "$now" -gt 0 ] && [ $((now - m)) -ge 10 ]; then
1226
+ if mkdir "$lock.break" 2>/dev/null; then
1227
+ # One breaker at a time: judge the age again under the mutex, so a
1228
+ # lock re-taken since the judgement above is fresh and left alone.
1229
+ m=$(hr_lane_mtime "$lock")
1230
+ now=$(date +%s 2>/dev/null) || now=0
1231
+ case "$now" in '' | *[!0-9]*) now=0 ;; esac
1232
+ if [ "$m" -gt 0 ] && [ "$now" -gt 0 ] && [ $((now - m)) -ge 10 ]; then
1233
+ stale="$lock.stale.${token##*.}"
1234
+ if mv "$lock" "$stale" 2>/dev/null; then
1235
+ rm -f "$stale/owner" 2>/dev/null || :
1236
+ rmdir "$stale" 2>/dev/null || :
1237
+ fi
1238
+ fi
1239
+ rmdir "$lock.break" 2>/dev/null || :
1240
+ polls=$((polls + 1))
1241
+ continue
1242
+ fi
1243
+ # Another breaker holds the mutex. One the watchdog killed inside it
1244
+ # left it behind: a mutex 10 s old is removed here.
1245
+ bm=$(hr_lane_mtime "$lock.break")
1246
+ if [ "$bm" -gt 0 ] && [ $((now - bm)) -ge 10 ]; then
1247
+ rmdir "$lock.break" 2>/dev/null || :
1248
+ fi
1249
+ fi
1250
+ sleep 0.05
1251
+ polls=$((polls + 1))
1252
+ done
1253
+ printf 'hr_registry_set: could not take the registry lock %s\n' "$lock" >&2
1254
+ return 1
1255
+ }
1256
+
1257
+ # hr_registry_unlock <file> <token> — release `<file>.lock` only while its
1258
+ # `owner` still holds <token>; a lock broken and re-taken is someone else's.
1259
+ hr_registry_unlock() {
1260
+ local lock="${1-}.lock" token="${2-}" owner=""
1261
+ [ -r "$lock/owner" ] && { IFS= read -r owner <"$lock/owner" || :; } 2>/dev/null
1262
+ [ -n "$token" ] && [ "$owner" = "$token" ] || return 0
1263
+ rm -f "$lock/owner" 2>/dev/null || :
1264
+ rmdir "$lock" 2>/dev/null || :
1265
+ }
1266
+
1267
+ # hr_registry_set <file> <branch> <key> <value> [<key> <value> …]
1268
+ # 1, writing nothing, on no key/value pair or an odd count of them. Each pair
1269
+ # reaches `jq` as `--arg kN` / `--arg vN` — jq 1.5 has no `$ARGS` — and only
1270
+ # those generated names enter the program text, never a value.
1271
+ hr_registry_set() {
1272
+ local file="${1-}" branch="${2-}" dir tmp fields="" i=0 status=0
1273
+ local -a args
1274
+ [ -n "$file" ] && [ "$#" -ge 4 ] && [ $(($# % 2)) -eq 0 ] || return 1
1275
+ shift 2
1276
+ args=(--arg b "$branch" --arg now "$(date '+%Y-%m-%dT%H:%M:%S')")
1277
+ while [ "$#" -gt 0 ]; do
1278
+ args+=(--arg "k$i" "$1" --arg "v$i" "$2")
1279
+ fields="$fields(\$k$i): \$v$i, "
1280
+ i=$((i + 1))
1281
+ shift 2
1282
+ done
1283
+ dir=$(hr_registry_dir "$file")
1284
+ tmp=$(mktemp "$dir/.registry.XXXXXX") || return 1
1285
+ if ! hr_registry_lock "$file" "$tmp"; then
1286
+ rm -f "$tmp"
1287
+ return 1
1288
+ fi
1289
+ hr_registry_init "$file" || :
1290
+ if jq "${args[@]}" "
1291
+ .runs[\$b] = ((.runs[\$b] // {}) + {$fields\"branch\": \$b, \"updated_at\": \$now})
1292
+ " "$file" >"$tmp"; then
1293
+ mv "$tmp" "$file" || status=1
1294
+ else
1295
+ status=1
1296
+ fi
1297
+ [ "$status" -eq 0 ] || rm -f "$tmp"
1298
+ hr_registry_unlock "$file" "$tmp"
1299
+ return "$status"
1300
+ }
1301
+
1302
+ # hr_registry_get <file> <branch> <key> -> the value, or nothing
1303
+ hr_registry_get() {
1304
+ local file="${1-}"
1305
+ hr_registry_init "$file" || :
1306
+ jq -r --arg b "${2-}" --arg k "${3-}" '.runs[$b][$k] // empty' "$file" 2>/dev/null
1307
+ }
1308
+
1309
+ # Every branch in the registry at <file>, one per line. Prints nothing when the
1310
+ # file cannot be read as a registry, which leaves each caller iterating over an
1311
+ # empty set.
1312
+ hr_registry_branches() {
1313
+ local file="${1-}"
1314
+ hr_registry_init "$file" || :
1315
+ jq -r '.runs | keys[]' "$file" 2>/dev/null
1316
+ }
1317
+
1318
+ # ---------------------------------------------------------------------------
1319
+ # THE REMOTE STATE BUNDLE.
1320
+ #
1321
+ # THE FORMAT OF RECORD. What a remote job carries across a job boundary and
1322
+ # reports back, uploaded as the Actions artifact `harness-state`. Every name
1323
+ # below is a variable `hr_remote_names_var` assigns; no function spells one.
1324
+ #
1325
+ # <bundle>/status.json the job's record, fixed schema below
1326
+ # <bundle>/clarifications/<branch>/ the whole branch directory, answered/ included
1327
+ # <bundle>/PAUSE_PROGRESS.md when present
1328
+ # <bundle>/flow_walker_state <state_dir>/.flow_walker_state, WITHOUT its dot
1329
+ # <bundle>/run.log <state_dir>/autonomous_logs/<branch>.log; never restored
1330
+ #
1331
+ # The walker state loses its dot because `actions/upload-artifact` skips hidden
1332
+ # files by default. NOTHING IN THE BUNDLE IS EVER COMMITTED: every file in it is
1333
+ # gitignored machine-local state, and the remote-status and move-aside paths sit
1334
+ # under `autonomous_logs/`, whose ignore rule already covers them.
1335
+ #
1336
+ # WHO READS EACH FILE. `status.json`: `remote-run.sh sync` / `status` (into the
1337
+ # local registry), `continue` / `poll` (the decision, `chain`, the reset time)
1338
+ # and the next job's seed. The clarification directory and `PAUSE_PROGRESS.md`:
1339
+ # the next job, and the user's local mirror. The walker state: the next job
1340
+ # only. `run.log`: the user only — `sync` copies it to the main checkout's logs
1341
+ # directory itself, and no restore places it.
1342
+ #
1343
+ # `status.json` — schema `HR_REMOTE_STATE_SCHEMA`; every value a JSON string:
1344
+ # schema a reader that does not recognise it treats the bundle as absent
1345
+ # branch, engine the run's branch; `task` | `user_review` | `docs`
1346
+ # status `running` | `parked` | `park_loop` | `paused` | `completed` | `failed`
1347
+ # pause_reason `usage` | `budget` | `user` | `overload` | empty. The registry's
1348
+ # registry-only `killed` and `expired` are never written
1349
+ # here: `sync` derives each from a run and its artifact
1350
+ # list, not from a bundle
1351
+ # usage_resume_at the epoch second a usage pause may resume at, or empty
1352
+ # park_loop_cycles, resume_max_question_index, auto_resumes, stall_restarts
1353
+ # the counters that must survive a job boundary
1354
+ # chain the writing job's OWN input `HARNESS_INPUT_CHAIN`, never
1355
+ # a value carried from an earlier bundle
1356
+ # control_polled_at the epoch second up to which the job checked for a
1357
+ # `harness pause <branch>` run, or empty
1358
+ # decision `continue` | `wait-poller` | `stop`
1359
+ # detail one human-readable line
1360
+ # run_id, run_url, written_at
1361
+ # `GITHUB_RUN_ID`, the run's URL, the epoch second written
1362
+ # ---------------------------------------------------------------------------
1363
+
1364
+ hr_remote_names_var() {
1365
+ HR_REMOTE_STATE_SCHEMA='1'
1366
+ HR_REMOTE_STATUS_FILE='status.json'
1367
+ HR_REMOTE_CLARIFY_DIR='clarifications'
1368
+ HR_REMOTE_PAUSE_FILE='PAUSE_PROGRESS.md'
1369
+ HR_REMOTE_WALKER_FILE='flow_walker_state'
1370
+ HR_REMOTE_WALKER_SOURCE=".$HR_REMOTE_WALKER_FILE"
1371
+ HR_REMOTE_LOG_FILE='run.log'
1372
+ HR_REMOTE_LOGS_DIR='autonomous_logs'
1373
+ HR_REMOTE_STATUS_SOURCE="$HR_REMOTE_LOGS_DIR/remote_status.json"
1374
+ HR_REMOTE_SUPERSEDED_DIR="$HR_REMOTE_LOGS_DIR/remote_superseded"
1375
+ }
1376
+
1377
+ # hr_remote_status_write <registry_file> <branch> <out_json> <decision> <detail>
1378
+ #
1379
+ # Reads <branch>'s record through `hr_registry_get` and replaces <out_json> by
1380
+ # rename, so a concurrent reader sees the old file or the new one, never half.
1381
+ # Prints nothing. 1 — writing nothing — when an argument is missing, <decision>
1382
+ # is outside its vocabulary, the record has no in-vocabulary `status`, or the
1383
+ # write fails. A `pause_reason` outside the bundle's vocabulary is written empty.
1384
+ hr_remote_status_write() {
1385
+ local registry="${1-}" branch="${2-}" out="${3-}" decision="${4-}" detail="${5-}"
1386
+ local status engine reason chain run_id run_url tmp
1387
+ [ -n "$registry" ] && [ -n "$branch" ] && [ -n "$out" ] || return 1
1388
+ case "$decision" in
1389
+ continue|wait-poller|stop) ;;
1390
+ *) return 1 ;;
1391
+ esac
1392
+ hr_remote_names_var
1393
+ status=$(hr_registry_get "$registry" "$branch" status)
1394
+ case "$status" in
1395
+ running|parked|park_loop|paused|completed|failed) ;;
1396
+ *) return 1 ;;
1397
+ esac
1398
+ engine=$(hr_registry_get "$registry" "$branch" engine)
1399
+ reason=$(hr_registry_get "$registry" "$branch" pause_reason)
1400
+ case "$reason" in
1401
+ usage|budget|user|overload) ;;
1402
+ *) reason="" ;;
1403
+ esac
1404
+ chain="${HARNESS_INPUT_CHAIN-}"
1405
+ case "$chain" in
1406
+ ''|*[!0-9]*) chain="" ;;
1407
+ esac
1408
+ run_id="${GITHUB_RUN_ID-}"
1409
+ run_url=""
1410
+ if [ -n "$run_id" ] && [ -n "${GITHUB_SERVER_URL-}" ] && [ -n "${GITHUB_REPOSITORY-}" ]; then
1411
+ run_url="${GITHUB_SERVER_URL%/}/${GITHUB_REPOSITORY}/actions/runs/${run_id}"
1412
+ fi
1413
+ detail=${detail//$'\r'/ }
1414
+ detail=${detail//$'\n'/ }
1415
+ # The temp file sits beside <out_json> so the `mv` is a same-filesystem rename.
1416
+ tmp=$(mktemp "${out}.tmp.XXXXXX" 2>/dev/null) || return 1
1417
+ if jq -n \
1418
+ --arg schema "$HR_REMOTE_STATE_SCHEMA" \
1419
+ --arg branch "$branch" \
1420
+ --arg engine "$engine" \
1421
+ --arg status "$status" \
1422
+ --arg pause_reason "$reason" \
1423
+ --arg usage_resume_at "$(hr_registry_get "$registry" "$branch" usage_resume_at)" \
1424
+ --arg park_loop_cycles "$(hr_registry_get "$registry" "$branch" park_loop_cycles)" \
1425
+ --arg resume_max_question_index "$(hr_registry_get "$registry" "$branch" resume_max_question_index)" \
1426
+ --arg auto_resumes "$(hr_registry_get "$registry" "$branch" auto_resumes)" \
1427
+ --arg stall_restarts "$(hr_registry_get "$registry" "$branch" stall_restarts)" \
1428
+ --arg chain "$chain" \
1429
+ --arg control_polled_at "$(hr_registry_get "$registry" "$branch" control_polled_at)" \
1430
+ --arg decision "$decision" \
1431
+ --arg detail "$detail" \
1432
+ --arg run_id "$run_id" \
1433
+ --arg run_url "$run_url" \
1434
+ --arg written_at "$(date +%s)" \
1435
+ '{schema: $schema, branch: $branch, engine: $engine, status: $status,
1436
+ pause_reason: $pause_reason, usage_resume_at: $usage_resume_at,
1437
+ park_loop_cycles: $park_loop_cycles,
1438
+ resume_max_question_index: $resume_max_question_index,
1439
+ auto_resumes: $auto_resumes, stall_restarts: $stall_restarts,
1440
+ chain: $chain, control_polled_at: $control_polled_at,
1441
+ decision: $decision, detail: $detail,
1442
+ run_id: $run_id, run_url: $run_url, written_at: $written_at}' \
1443
+ >"$tmp" 2>/dev/null && mv "$tmp" "$out" 2>/dev/null; then
1444
+ return 0
1445
+ fi
1446
+ rm -f "$tmp"
1447
+ return 1
1448
+ }
1449
+
1450
+ # hr_remote_status_get <status_json> <key> -> the value
1451
+ #
1452
+ # 0 with the value printed; 1 when <key> is absent; 2 when the file is
1453
+ # unreadable, is not JSON, or its `schema` is not `HR_REMOTE_STATE_SCHEMA` —
1454
+ # the bundle is then treated as absent, whatever <key> holds.
1455
+ hr_remote_status_get() {
1456
+ local file="${1-}" key="${2-}" out
1457
+ [ -n "$file" ] && [ -r "$file" ] || return 2
1458
+ hr_remote_names_var
1459
+ # One `jq`, one marker character: `S` a schema it does not recognise, `A` an
1460
+ # absent key, `V` a value — an exit status cannot carry three outcomes.
1461
+ out=$(jq -r --arg k "$key" --arg s "$HR_REMOTE_STATE_SCHEMA" '
1462
+ if type != "object" or .schema != $s then "S"
1463
+ elif (.[$k] | type) == "string" then "V" + .[$k]
1464
+ else "A" end' "$file" 2>/dev/null) || return 2
1465
+ case "$out" in
1466
+ V*) printf '%s\n' "${out#V}" ;;
1467
+ A) return 1 ;;
1468
+ *) return 2 ;;
1469
+ esac
1470
+ }
1471
+
1472
+ # hr_remote_bundle_write <root> <branch> <registry_file> <out_dir>
1473
+ #
1474
+ # Assembles the format above in <out_dir>, which must be absent or empty, from
1475
+ # <root>'s configured state directory. `status.json` is the job's own
1476
+ # `autonomous_logs/remote_status.json` when present; otherwise it is written
1477
+ # from <branch>'s registry record with decision `stop`, because a job that never
1478
+ # wrote its status never decided to continue. 0 written; 1 a missing argument,
1479
+ # a non-empty <out_dir> or a failed copy; 2 <root>'s configuration unresolvable.
1480
+ hr_remote_bundle_write() {
1481
+ local root="${1-}" branch="${2-}" registry="${3-}" out="${4-}" state base clarify
1482
+ [ -n "$root" ] && [ -n "$branch" ] && [ -n "$registry" ] && [ -n "$out" ] || return 1
1483
+ state=$(hr_state_dir "$root") || return 2
1484
+ hr_remote_names_var
1485
+ base="${root%/}/$state"
1486
+ out=${out%/}
1487
+ if [ -e "$out" ]; then
1488
+ [ -d "$out" ] || return 1
1489
+ [ -z "$(ls -A "$out" 2>/dev/null)" ] || return 1
1490
+ fi
1491
+ mkdir -p "$out" 2>/dev/null || return 1
1492
+
1493
+ if [ -f "$base/$HR_REMOTE_STATUS_SOURCE" ]; then
1494
+ cp "$base/$HR_REMOTE_STATUS_SOURCE" "$out/$HR_REMOTE_STATUS_FILE" 2>/dev/null || return 1
1495
+ else
1496
+ hr_remote_status_write "$registry" "$branch" "$out/$HR_REMOTE_STATUS_FILE" stop \
1497
+ "the job wrote no status; derived from the registry record when the bundle was saved" || return 1
1498
+ fi
1499
+ if [ -d "$base/$HR_REMOTE_CLARIFY_DIR/$branch" ]; then
1500
+ clarify="$out/$HR_REMOTE_CLARIFY_DIR/$branch"
1501
+ mkdir -p "${clarify%/*}" 2>/dev/null || return 1
1502
+ # No trailing slash on the source: BSD `cp -R dir/` copies the contents instead.
1503
+ cp -R "$base/$HR_REMOTE_CLARIFY_DIR/$branch" "$clarify" 2>/dev/null || return 1
1504
+ fi
1505
+ if [ -f "$base/$HR_REMOTE_PAUSE_FILE" ]; then
1506
+ cp "$base/$HR_REMOTE_PAUSE_FILE" "$out/$HR_REMOTE_PAUSE_FILE" 2>/dev/null || return 1
1507
+ fi
1508
+ if [ -f "$base/$HR_REMOTE_WALKER_SOURCE" ]; then
1509
+ cp "$base/$HR_REMOTE_WALKER_SOURCE" "$out/$HR_REMOTE_WALKER_FILE" 2>/dev/null || return 1
1510
+ fi
1511
+ if [ -f "$base/$HR_REMOTE_LOGS_DIR/$branch.log" ]; then
1512
+ cp "$base/$HR_REMOTE_LOGS_DIR/$branch.log" "$out/$HR_REMOTE_LOG_FILE" 2>/dev/null || return 1
1513
+ fi
1514
+ return 0
1515
+ }
1516
+
1517
+ # hr_remote_bundle_restore <bundle_dir> <root> <branch> <mode>
1518
+ #
1519
+ # <mode> `job` places the clarification directory, `PAUSE_PROGRESS.md`, the
1520
+ # walker state (back under its dotted name) and `status.json` (as
1521
+ # `autonomous_logs/remote_status.json`); `mirror` places the first two only. The
1522
+ # run log is placed by neither.
1523
+ #
1524
+ # THE CLARIFICATION DIRECTORY IS REPLACED WHOLESALE, AND NOTHING IS DELETED. An
1525
+ # existing target is moved aside with one `mv` into
1526
+ # `autonomous_logs/remote_superseded/<epoch>/clarifications/<branch>` (`<epoch>-<n>`
1527
+ # when an earlier restore took that second) before the
1528
+ # bundle's copy goes in, so a stale local pair cannot survive and no recursive
1529
+ # removal is ever shelled out. A bundle carrying no clarification directory
1530
+ # leaves the target alone: in a mirror it may hold an answer not yet relayed.
1531
+ #
1532
+ # 0 restored; 1 a missing argument, an unknown <mode> or a failed copy; 2 —
1533
+ # touching nothing — when the bundle is unrecognised (no readable `status.json`,
1534
+ # a schema other than `HR_REMOTE_STATE_SCHEMA`, or a `branch` other than
1535
+ # <branch>) or <root>'s configuration is unresolvable.
1536
+ hr_remote_bundle_restore() {
1537
+ local bundle="${1-}" root="${2-}" branch="${3-}" mode="${4-}"
1538
+ local state base named target epoch aside n tmp
1539
+ [ -n "$bundle" ] && [ -n "$root" ] && [ -n "$branch" ] || return 1
1540
+ case "$mode" in
1541
+ job|mirror) ;;
1542
+ *) return 1 ;;
1543
+ esac
1544
+ hr_remote_names_var
1545
+ bundle=${bundle%/}
1546
+ named=$(hr_remote_status_get "$bundle/$HR_REMOTE_STATUS_FILE" branch) || return 2
1547
+ [ "$named" = "$branch" ] || return 2
1548
+ state=$(hr_state_dir "$root") || return 2
1549
+ base="${root%/}/$state"
1550
+
1551
+ if [ -d "$bundle/$HR_REMOTE_CLARIFY_DIR/$branch" ]; then
1552
+ target="$base/$HR_REMOTE_CLARIFY_DIR/$branch"
1553
+ if [ -e "$target" ]; then
1554
+ epoch=$(date +%s)
1555
+ aside="$base/$HR_REMOTE_SUPERSEDED_DIR/$epoch"
1556
+ n=0
1557
+ while [ -e "$aside/$HR_REMOTE_CLARIFY_DIR/$branch" ]; do
1558
+ n=$((n + 1))
1559
+ aside="$base/$HR_REMOTE_SUPERSEDED_DIR/$epoch-$n"
1560
+ done
1561
+ aside="$aside/$HR_REMOTE_CLARIFY_DIR/$branch"
1562
+ mkdir -p "${aside%/*}" 2>/dev/null || return 1
1563
+ mv "$target" "$aside" 2>/dev/null || return 1
1564
+ fi
1565
+ mkdir -p "${target%/*}" 2>/dev/null || return 1
1566
+ cp -R "$bundle/$HR_REMOTE_CLARIFY_DIR/$branch" "$target" 2>/dev/null || return 1
1567
+ fi
1568
+ if [ -f "$bundle/$HR_REMOTE_PAUSE_FILE" ]; then
1569
+ mkdir -p "$base" 2>/dev/null || return 1
1570
+ cp "$bundle/$HR_REMOTE_PAUSE_FILE" "$base/$HR_REMOTE_PAUSE_FILE" 2>/dev/null || return 1
1571
+ fi
1572
+ [ "$mode" = job ] || return 0
1573
+
1574
+ if [ -f "$bundle/$HR_REMOTE_WALKER_FILE" ]; then
1575
+ mkdir -p "$base" 2>/dev/null || return 1
1576
+ cp "$bundle/$HR_REMOTE_WALKER_FILE" "$base/$HR_REMOTE_WALKER_SOURCE" 2>/dev/null || return 1
1577
+ fi
1578
+ mkdir -p "$base/$HR_REMOTE_LOGS_DIR" 2>/dev/null || return 1
1579
+ tmp=$(mktemp "$base/$HR_REMOTE_STATUS_SOURCE.tmp.XXXXXX" 2>/dev/null) || return 1
1580
+ if cp "$bundle/$HR_REMOTE_STATUS_FILE" "$tmp" 2>/dev/null \
1581
+ && mv "$tmp" "$base/$HR_REMOTE_STATUS_SOURCE" 2>/dev/null; then
1582
+ return 0
1583
+ fi
1584
+ rm -f "$tmp"
1585
+ return 1
1586
+ }
1587
+
1011
1588
  # ---------------------------------------------------------------------------
1012
1589
  # THE MACHINE-LEVEL USAGE LANE.
1013
1590
  #