autonomous-sdlc-harness 0.4.0 → 0.4.2

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.
@@ -58,21 +58,27 @@
58
58
  # and are written only by the `hr_lane_*` functions.
59
59
  # 2. THE RUN REGISTRY writes the registry file its caller names. Fence: that
60
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`.
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.
63
67
  # 3. THE REMOTE STATE BUNDLE writes the files its format lists. Fence: inside
64
68
  # `<root>/<state_dir>/` (resolved through `hr_state_dir`), only
65
69
  # `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>`
70
+ # `PAUSE_PROGRESS.md`, `.flow_walker_state`, the move-aside directory
71
+ # `autonomous_logs/remote_superseded/` and, only where nothing exists yet,
72
+ # files under the eight planning paths `hr_remote_planning_paths` assigns;
73
+ # outside it, only the caller-named `<out_dir>` (its `planning/` included)
74
+ # of `hr_remote_bundle_write` and the caller-named `<out_json>`
69
75
  # of `hr_remote_status_write`. Written only by `hr_remote_status_write`,
70
76
  # `hr_remote_bundle_write` and `hr_remote_bundle_restore`, and nothing
71
77
  # there but a writer's own failed temp file is ever removed.
72
78
  #
73
79
  # 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
80
+ # `hr_registry_lock`, `hr_registry_unlock`, `hr_remote_status_write`,
81
+ # `hr_remote_bundle_write` or `hr_remote_bundle_restore` function still gets a library that only reads. The
76
82
  # lane's ceilings are the only environment values here that carry policy, because
77
83
  # the lane is machine-scoped and has no configuration key to carry them; each is
78
84
  # named where it is used. `XDG_STATE_HOME`, `XDG_CONFIG_HOME`, `XDG_CACHE_HOME`,
@@ -165,7 +171,9 @@
165
171
  # because callers capture stdout. The one pass-through is the registry's two
166
172
  # writers, `hr_registry_init` and `hr_registry_set`, which leave the shell's,
167
173
  # `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
174
+ # watcher's bodies they replaced did — that stream is the watcher's log — and
175
+ # add one line of their own, naming the lock, when `hr_registry_set` cannot
176
+ # take the registry lock. That silence is why the lane reports a lock it BROKE through a
169
177
  # variable instead of a log line — the caller owns the log.
170
178
  #
171
179
  # NAMING. Every function is prefixed `hr_`; every variable this file touches
@@ -177,7 +185,9 @@
177
185
  # `HR_LANE_RANK`, `HR_LANE_STATE`, `HR_LANE_RESUME_AT`, `HR_LANE_OBSERVED_AT`,
178
186
  # `HR_LANE_OBSERVED_REPO`, `HR_LANE_OWNER_SLUG`, `HR_LANE_OWNER_PID`,
179
187
  # `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
188
+ # names, which `hr_remote_names_var` assigns, with `HR_REMOTE_PLANNING_PATHS`
189
+ # (`hr_remote_planning_paths`) and `HR_REMOTE_PLANNING_PLACED` /
190
+ # `HR_REMOTE_PLANNING_KEPT` (`hr_remote_bundle_restore`). Every one of them is assigned
181
191
  # before it is read by the function that owns it, so an inherited value from a
182
192
  # parent process is overwritten rather than believed.
183
193
  #
@@ -1110,31 +1120,187 @@ hr_push_env_files() {
1110
1120
  # Each function takes the registry file as its first argument — the caller
1111
1121
  # resolves it as `hr_state_path <root> autonomous_logs/registry.json` — and is
1112
1122
  # 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.
1123
+ # every write stamps `branch` and `updated_at` on the record. No shell option is
1124
+ # assumed: the caller may set `-e`, `-u` or neither.
1125
+ #
1126
+ # EVERY WRITE IS A LOCKED READ-MODIFY-WRITE. Every script sharing the file
1127
+ # writes it, and so does the watcher's own `( … ) &` engine subshell, so two
1128
+ # unserialized writers each read the same record and the second `mv` silently
1129
+ # drops the first one's key. The lock is a `mkdir` of `<file>.lock` holding an
1130
+ # `owner` file with a per-call token — the `mktemp` name of that call's temp
1131
+ # file, because `$$` inside a subshell is the parent's pid and bash 3.2 has no
1132
+ # per-subshell pid variable — and only a call whose token is still in `owner`
1133
+ # releases it.
1134
+ #
1135
+ # STALENESS IS THE LOCK DIRECTORY'S AGE, NEVER A PID. The stall watchdog kills
1136
+ # the engine subshell mid-write, so a dead holder is expected, and a pid says
1137
+ # nothing about a lock the killed subshell took under its parent's `$$`. A write
1138
+ # is one `jq` over a file of a few records, well under a second, so a lock 10 s
1139
+ # old is a crashed holder; a waiter polls every 0.05 s for up to 12 s — past the
1140
+ # stale age, so a waiter that arrives just after a crash breaks the lock rather
1141
+ # than giving up. Both are constants here, not environment values: the header
1142
+ # allows policy-carrying environment values for the lane only.
1143
+ #
1144
+ # BREAKERS ARE SERIALIZED, AND NONE RENAMES A LIVE LOCK. A waiter that judges
1145
+ # the lock stale takes a second `mkdir` mutex, `<file>.lock.break`, judges the
1146
+ # lock's age again under it, and only then renames the lock aside and empties
1147
+ # it, as `hr_lane_acquire` does. A lock re-taken since the first judgement is
1148
+ # fresh, so the second leaves it alone. Between the second judgement and the
1149
+ # rename, nothing but a breaker or the lock's own holder frees the path:
1150
+ # breakers are serialized, and the holder of a lock that old is dead by the
1151
+ # stale rule. The watchdog can kill a breaker inside the mutex, so a mutex 10 s
1152
+ # old is removed by the next waiter. That removal is the one window left open.
1153
+ # Two waiters that judge one abandoned mutex old together can both remove it,
1154
+ # and the second can remove the fresh mutex the first has just taken. Two
1155
+ # breakers then judge the lock at once, so reaching it takes a breaker killed
1156
+ # inside the mutex, a stale lock and three concurrent writers.
1157
+ #
1158
+ # A write that cannot take the lock prints one stderr line naming it, writes
1159
+ # nothing and returns 1.
1160
+ #
1161
+ # ONE CALL, SEVERAL KEYS. `hr_registry_set <file> <branch> <key> <value> [<key>
1162
+ # <value> …]` writes every pair in one `jq` pass under one lock and one `mv`.
1163
+ # A caller writing two keys that a concurrent reader must never see apart — a
1164
+ # status and the reason for it — MUST pass them in one call.
1165
+ #
1166
+ # READERS TAKE NO LOCK. The temp file sits beside the registry, so the `mv` is a
1167
+ # same-filesystem rename and `hr_registry_get`, `hr_registry_branches` and any
1168
+ # other `jq` over the file see the old record or the new one, never a partial
1169
+ # one. `hr_registry_init` creates the file by `ln`-ing a complete temp file to
1170
+ # the registry name, which fails when the name exists, so a reader's create
1171
+ # never truncates a registry a writer has just created.
1116
1172
  # ---------------------------------------------------------------------------
1117
1173
 
1118
- # Create an empty registry at <file> when none exists.
1174
+ # The directory holding <file> — where its temp files go, so every `mv` over it
1175
+ # is a same-filesystem rename.
1176
+ hr_registry_dir() {
1177
+ local file="${1-}" dir
1178
+ case "$file" in
1179
+ */*)
1180
+ dir="${file%/*}"
1181
+ [ -n "$dir" ] || dir=/
1182
+ ;;
1183
+ *) dir=. ;;
1184
+ esac
1185
+ printf '%s\n' "$dir"
1186
+ }
1187
+
1188
+ # Create an empty registry at <file> when none exists. Atomic: the name is
1189
+ # either absent or a complete `{"runs":{}}` — never truncated, never partial.
1119
1190
  hr_registry_init() {
1120
- local file="${1-}"
1191
+ local file="${1-}" dir tmp
1121
1192
  [ -n "$file" ] || return 1
1122
- [ -f "$file" ] || printf '{"runs":{}}\n' >"$file"
1193
+ [ -f "$file" ] && return 0
1194
+ dir=$(hr_registry_dir "$file")
1195
+ tmp=$(mktemp "$dir/.registry.XXXXXX") || return 1
1196
+ if printf '{"runs":{}}\n' >"$tmp"; then
1197
+ ln "$tmp" "$file" 2>/dev/null || :
1198
+ fi
1199
+ rm -f "$tmp"
1200
+ [ -f "$file" ]
1201
+ }
1202
+
1203
+ # hr_registry_lock <file> <token> — take `<file>.lock` for <token>, breaking a
1204
+ # stale one (see the section comment). 0 when held; 1, with one stderr line,
1205
+ # when the wait ceiling passed.
1206
+ hr_registry_lock() {
1207
+ local lock="${1-}.lock" token="${2-}" polls=0 start="" now m stale bm
1208
+ # 12 — the wait ceiling and 10 — the stale age, both in seconds. The wait is
1209
+ # judged by the clock, because each poll's forks cost more than its sleep;
1210
+ # 240 polls is the ceiling only when `date` gives no epoch.
1211
+ while :; do
1212
+ if mkdir "$lock" 2>/dev/null; then
1213
+ if printf '%s\n' "$token" >"$lock/owner" 2>/dev/null; then
1214
+ return 0
1215
+ fi
1216
+ rm -f "$lock/owner" 2>/dev/null || :
1217
+ rmdir "$lock" 2>/dev/null || :
1218
+ break
1219
+ fi
1220
+ m=$(hr_lane_mtime "$lock")
1221
+ now=$(date +%s 2>/dev/null) || now=0
1222
+ case "$now" in '' | *[!0-9]*) now=0 ;; esac
1223
+ [ -n "$start" ] || start="$now"
1224
+ if [ "$now" -gt 0 ]; then
1225
+ [ $((now - start)) -lt 12 ] || break
1226
+ else
1227
+ [ "$polls" -lt 240 ] || break
1228
+ fi
1229
+ if [ "$m" -gt 0 ] && [ "$now" -gt 0 ] && [ $((now - m)) -ge 10 ]; then
1230
+ if mkdir "$lock.break" 2>/dev/null; then
1231
+ # One breaker at a time: judge the age again under the mutex, so a
1232
+ # lock re-taken since the judgement above is fresh and left alone.
1233
+ m=$(hr_lane_mtime "$lock")
1234
+ now=$(date +%s 2>/dev/null) || now=0
1235
+ case "$now" in '' | *[!0-9]*) now=0 ;; esac
1236
+ if [ "$m" -gt 0 ] && [ "$now" -gt 0 ] && [ $((now - m)) -ge 10 ]; then
1237
+ stale="$lock.stale.${token##*.}"
1238
+ if mv "$lock" "$stale" 2>/dev/null; then
1239
+ rm -f "$stale/owner" 2>/dev/null || :
1240
+ rmdir "$stale" 2>/dev/null || :
1241
+ fi
1242
+ fi
1243
+ rmdir "$lock.break" 2>/dev/null || :
1244
+ polls=$((polls + 1))
1245
+ continue
1246
+ fi
1247
+ # Another breaker holds the mutex. One the watchdog killed inside it
1248
+ # left it behind: a mutex 10 s old is removed here.
1249
+ bm=$(hr_lane_mtime "$lock.break")
1250
+ if [ "$bm" -gt 0 ] && [ $((now - bm)) -ge 10 ]; then
1251
+ rmdir "$lock.break" 2>/dev/null || :
1252
+ fi
1253
+ fi
1254
+ sleep 0.05
1255
+ polls=$((polls + 1))
1256
+ done
1257
+ printf 'hr_registry_set: could not take the registry lock %s\n' "$lock" >&2
1258
+ return 1
1123
1259
  }
1124
1260
 
1125
- # hr_registry_set <file> <branch> <key> <value>
1261
+ # hr_registry_unlock <file> <token> — release `<file>.lock` only while its
1262
+ # `owner` still holds <token>; a lock broken and re-taken is someone else's.
1263
+ hr_registry_unlock() {
1264
+ local lock="${1-}.lock" token="${2-}" owner=""
1265
+ [ -r "$lock/owner" ] && { IFS= read -r owner <"$lock/owner" || :; } 2>/dev/null
1266
+ [ -n "$token" ] && [ "$owner" = "$token" ] || return 0
1267
+ rm -f "$lock/owner" 2>/dev/null || :
1268
+ rmdir "$lock" 2>/dev/null || :
1269
+ }
1270
+
1271
+ # hr_registry_set <file> <branch> <key> <value> [<key> <value> …]
1272
+ # 1, writing nothing, on no key/value pair or an odd count of them. Each pair
1273
+ # reaches `jq` as `--arg kN` / `--arg vN` — jq 1.5 has no `$ARGS` — and only
1274
+ # those generated names enter the program text, never a value.
1126
1275
  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
1276
+ local file="${1-}" branch="${2-}" dir tmp fields="" i=0 status=0
1277
+ local -a args
1278
+ [ -n "$file" ] && [ "$#" -ge 4 ] && [ $(($# % 2)) -eq 0 ] || return 1
1279
+ shift 2
1280
+ args=(--arg b "$branch" --arg now "$(date '+%Y-%m-%dT%H:%M:%S')")
1281
+ while [ "$#" -gt 0 ]; do
1282
+ args+=(--arg "k$i" "$1" --arg "v$i" "$2")
1283
+ fields="$fields(\$k$i): \$v$i, "
1284
+ i=$((i + 1))
1285
+ shift 2
1286
+ done
1287
+ dir=$(hr_registry_dir "$file")
1288
+ tmp=$(mktemp "$dir/.registry.XXXXXX") || return 1
1289
+ if ! hr_registry_lock "$file" "$tmp"; then
1135
1290
  rm -f "$tmp"
1136
1291
  return 1
1137
1292
  fi
1293
+ hr_registry_init "$file" || :
1294
+ if jq "${args[@]}" "
1295
+ .runs[\$b] = ((.runs[\$b] // {}) + {$fields\"branch\": \$b, \"updated_at\": \$now})
1296
+ " "$file" >"$tmp"; then
1297
+ mv "$tmp" "$file" || status=1
1298
+ else
1299
+ status=1
1300
+ fi
1301
+ [ "$status" -eq 0 ] || rm -f "$tmp"
1302
+ hr_registry_unlock "$file" "$tmp"
1303
+ return "$status"
1138
1304
  }
1139
1305
 
1140
1306
  # hr_registry_get <file> <branch> <key> -> the value, or nothing
@@ -1165,18 +1331,43 @@ hr_registry_branches() {
1165
1331
  # <bundle>/PAUSE_PROGRESS.md when present
1166
1332
  # <bundle>/flow_walker_state <state_dir>/.flow_walker_state, WITHOUT its dot
1167
1333
  # <bundle>/run.log <state_dir>/autonomous_logs/<branch>.log; never restored
1334
+ # <bundle>/planning/<path> each of these under <state_dir>, when present:
1335
+ # story_plans/<branch>_story_plan.md
1336
+ # task_plans/<branch>
1337
+ # ui_test_plans/<branch>_ui_test_plan.md
1338
+ # ui_test_plans/<branch>
1339
+ # task_plan_reviews/<branch>
1340
+ # business_parity_reviews/<branch>
1341
+ # architecture_reviews/<branch>
1342
+ # ui_test_plan_reviews/<branch>
1168
1343
  #
1169
1344
  # 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.
1345
+ # files by default. THE BUNDLE ITSELF IS NEVER COMMITTED. Every file outside
1346
+ # `planning/` is gitignored machine-local state, and the remote-status and
1347
+ # move-aside paths sit under `autonomous_logs/`, whose ignore rule already
1348
+ # covers them. The files under `planning/` are untracked drafts that the flow
1349
+ # commits itself at its P1/P3 convergence; the bundle only carries them.
1350
+ #
1351
+ # THE PLANNING PATHS ARE A MIRROR of the contracts that write and stage them:
1352
+ # `plugin/instructions/task_plan_writing_instructions_autonomous.md` →
1353
+ # `## Override 3` and `## Override 4` staging lists, and
1354
+ # `<scripts_dir>/flows/task_plan_writing.graph.json` → each node's `findingsFolder`. A path
1355
+ # added there is an edit to `hr_remote_planning_paths`. Only planning is
1356
+ # carried because the walker's one graph is `task_plan_writing.graph.json`;
1357
+ # implementation-phase per-unit review folders never span a pause, which waits
1358
+ # for a clean tracked tree. `HR_REMOTE_STATE_SCHEMA` stays `'1'`: `planning/`
1359
+ # is additive, an older reader ignores it, and a newer reader of an older
1360
+ # bundle finds none.
1173
1361
  #
1174
1362
  # WHO READS EACH FILE. `status.json`: `remote-run.sh sync` / `status` (into the
1175
1363
  # local registry), `continue` / `poll` (the decision, `chain`, the reset time)
1176
1364
  # and the next job's seed. The clarification directory and `PAUSE_PROGRESS.md`:
1177
1365
  # 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.
1366
+ # only. `planning/`: the next job only, never a mirror — an untracked draft left
1367
+ # in the mirror would make its later fast-forward to `origin/<branch>` refuse,
1368
+ # because the draft's own convergence commit adds the same path. `run.log`: the
1369
+ # user only — `sync` copies it to the main checkout's logs directory itself,
1370
+ # and no restore places it.
1180
1371
  #
1181
1372
  # `status.json` — schema `HR_REMOTE_STATE_SCHEMA`; every value a JSON string:
1182
1373
  # schema a reader that does not recognise it treats the bundle as absent
@@ -1210,6 +1401,27 @@ hr_remote_names_var() {
1210
1401
  HR_REMOTE_LOGS_DIR='autonomous_logs'
1211
1402
  HR_REMOTE_STATUS_SOURCE="$HR_REMOTE_LOGS_DIR/remote_status.json"
1212
1403
  HR_REMOTE_SUPERSEDED_DIR="$HR_REMOTE_LOGS_DIR/remote_superseded"
1404
+ HR_REMOTE_PLANNING_DIR='planning'
1405
+ }
1406
+
1407
+ # hr_remote_planning_paths <branch>
1408
+ #
1409
+ # Assigns `HR_REMOTE_PLANNING_PATHS`: the eight planning paths of the format
1410
+ # above, relative to <state_dir>, newline-separated, no trailing slash. 1 with
1411
+ # an empty value for an empty <branch>.
1412
+ hr_remote_planning_paths() {
1413
+ local branch="${1-}"
1414
+ HR_REMOTE_PLANNING_PATHS=''
1415
+ [ -n "$branch" ] || return 1
1416
+ HR_REMOTE_PLANNING_PATHS="story_plans/${branch}_story_plan.md
1417
+ task_plans/$branch
1418
+ ui_test_plans/${branch}_ui_test_plan.md
1419
+ ui_test_plans/$branch
1420
+ task_plan_reviews/$branch
1421
+ business_parity_reviews/$branch
1422
+ architecture_reviews/$branch
1423
+ ui_test_plan_reviews/$branch"
1424
+ return 0
1213
1425
  }
1214
1426
 
1215
1427
  # hr_remote_status_write <registry_file> <branch> <out_json> <decision> <detail>
@@ -1313,10 +1525,12 @@ hr_remote_status_get() {
1313
1525
  # <root>'s configured state directory. `status.json` is the job's own
1314
1526
  # `autonomous_logs/remote_status.json` when present; otherwise it is written
1315
1527
  # 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,
1528
+ # wrote its status never decided to continue. Each planning path present is
1529
+ # copied under `planning/`, whether or not the branch tracks it: the restore
1530
+ # never overwrites, so a tracked copy is inert. 0 written; 1 a missing argument,
1317
1531
  # a non-empty <out_dir> or a failed copy; 2 <root>'s configuration unresolvable.
1318
1532
  hr_remote_bundle_write() {
1319
- local root="${1-}" branch="${2-}" registry="${3-}" out="${4-}" state base clarify
1533
+ local root="${1-}" branch="${2-}" registry="${3-}" out="${4-}" state base clarify rel dst
1320
1534
  [ -n "$root" ] && [ -n "$branch" ] && [ -n "$registry" ] && [ -n "$out" ] || return 1
1321
1535
  state=$(hr_state_dir "$root") || return 2
1322
1536
  hr_remote_names_var
@@ -1349,15 +1563,36 @@ hr_remote_bundle_write() {
1349
1563
  if [ -f "$base/$HR_REMOTE_LOGS_DIR/$branch.log" ]; then
1350
1564
  cp "$base/$HR_REMOTE_LOGS_DIR/$branch.log" "$out/$HR_REMOTE_LOG_FILE" 2>/dev/null || return 1
1351
1565
  fi
1566
+ hr_remote_planning_paths "$branch" || return 1
1567
+ while IFS= read -r rel; do
1568
+ dst="$out/$HR_REMOTE_PLANNING_DIR/$rel"
1569
+ if [ -f "$base/$rel" ]; then
1570
+ mkdir -p "${dst%/*}" 2>/dev/null || return 1
1571
+ cp "$base/$rel" "$dst" 2>/dev/null || return 1
1572
+ elif [ -d "$base/$rel" ]; then
1573
+ mkdir -p "${dst%/*}" 2>/dev/null || return 1
1574
+ cp -R "$base/$rel" "$dst" 2>/dev/null || return 1
1575
+ fi
1576
+ done <<EOF
1577
+ $HR_REMOTE_PLANNING_PATHS
1578
+ EOF
1352
1579
  return 0
1353
1580
  }
1354
1581
 
1355
1582
  # hr_remote_bundle_restore <bundle_dir> <root> <branch> <mode>
1356
1583
  #
1357
1584
  # <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.
1585
+ # walker state (back under its dotted name), the planning drafts and
1586
+ # `status.json` (as `autonomous_logs/remote_status.json`); `mirror` places the
1587
+ # first two only. The run log is placed by neither.
1588
+ #
1589
+ # A PLANNING DRAFT NEVER OVERWRITES. A regular file under `planning/` whose
1590
+ # path lies in `HR_REMOTE_PLANNING_PATHS` and has no `..` segment is placed only
1591
+ # where nothing exists, counted in `HR_REMOTE_PLANNING_PLACED`; one whose target
1592
+ # exists is left byte-identical — the checkout's copy is the branch's committed
1593
+ # record — and counted in `HR_REMOTE_PLANNING_KEPT`. A symlink, a non-regular
1594
+ # entry or a path outside the set counts in neither. Both are `0` at entry and
1595
+ # stay `0` in `mirror` mode.
1361
1596
  #
1362
1597
  # THE CLARIFICATION DIRECTORY IS REPLACED WHOLESALE, AND NOTHING IS DELETED. An
1363
1598
  # existing target is moved aside with one `mv` into
@@ -1373,7 +1608,9 @@ hr_remote_bundle_write() {
1373
1608
  # <branch>) or <root>'s configuration is unresolvable.
1374
1609
  hr_remote_bundle_restore() {
1375
1610
  local bundle="${1-}" root="${2-}" branch="${3-}" mode="${4-}"
1376
- local state base named target epoch aside n tmp
1611
+ local state base named target epoch aside n tmp pdir file rel p inset
1612
+ HR_REMOTE_PLANNING_PLACED=0
1613
+ HR_REMOTE_PLANNING_KEPT=0
1377
1614
  [ -n "$bundle" ] && [ -n "$root" ] && [ -n "$branch" ] || return 1
1378
1615
  case "$mode" in
1379
1616
  job|mirror) ;;
@@ -1413,6 +1650,37 @@ hr_remote_bundle_restore() {
1413
1650
  mkdir -p "$base" 2>/dev/null || return 1
1414
1651
  cp "$bundle/$HR_REMOTE_WALKER_FILE" "$base/$HR_REMOTE_WALKER_SOURCE" 2>/dev/null || return 1
1415
1652
  fi
1653
+ pdir="$bundle/$HR_REMOTE_PLANNING_DIR"
1654
+ if [ -d "$pdir" ] && [ ! -L "$pdir" ]; then
1655
+ hr_remote_planning_paths "$branch" || return 1
1656
+ # Process substitution, not a pipe: the loop must run in this shell so the
1657
+ # counters and `return 1` reach the caller.
1658
+ while IFS= read -r file; do
1659
+ # Re-tested per line: a name holding a newline arrives split and fails here.
1660
+ [ -f "$file" ] && [ ! -L "$file" ] || continue
1661
+ rel=${file#"$pdir"/}
1662
+ case "/$rel/" in
1663
+ */../*) continue ;;
1664
+ esac
1665
+ inset=0
1666
+ while IFS= read -r p; do
1667
+ case "$rel" in
1668
+ "$p"|"$p"/*) inset=1; break ;;
1669
+ esac
1670
+ done <<EOF
1671
+ $HR_REMOTE_PLANNING_PATHS
1672
+ EOF
1673
+ [ "$inset" = 1 ] || continue
1674
+ if [ -e "$base/$rel" ] || [ -L "$base/$rel" ]; then
1675
+ HR_REMOTE_PLANNING_KEPT=$((HR_REMOTE_PLANNING_KEPT + 1))
1676
+ continue
1677
+ fi
1678
+ target="$base/$rel"
1679
+ mkdir -p "${target%/*}" 2>/dev/null || return 1
1680
+ cp "$file" "$target" 2>/dev/null || return 1
1681
+ HR_REMOTE_PLANNING_PLACED=$((HR_REMOTE_PLANNING_PLACED + 1))
1682
+ done < <(find "$pdir" -type f 2>/dev/null)
1683
+ fi
1416
1684
  mkdir -p "$base/$HR_REMOTE_LOGS_DIR" 2>/dev/null || return 1
1417
1685
  tmp=$(mktemp "$base/$HR_REMOTE_STATUS_SOURCE.tmp.XXXXXX" 2>/dev/null) || return 1
1418
1686
  if cp "$bundle/$HR_REMOTE_STATUS_FILE" "$tmp" 2>/dev/null \
@@ -71,11 +71,15 @@
71
71
  # past a run with none, and stopping at one whose artifact has expired, since
72
72
  # an older copy would be staler state. An expired one restores nothing: under
73
73
  # --resume answer it exits 2; otherwise it prints a `::warning::` line naming
74
- # the run, the expiry and the lost counts and clarification history, and the
75
- # job continues from the committed ledger. An unexpired one it downloads to `<state_dir>/autonomous_logs/remote_download/<branch>/<id>/`
74
+ # the run, the expiry and the lost counts, clarification history and
75
+ # uncommitted planning drafts, and the job continues from the committed ledger. An unexpired one it downloads to `<state_dir>/autonomous_logs/remote_download/<branch>/<id>/`
76
76
  # (skipped when that directory already holds its status.json); and restores it
77
77
  # in `job` mode — on every --resume kind, `none` included, because a reused
78
- # branch keeps its clarification history. Then, under --resume answer, it
78
+ # branch keeps its clarification history. A job-mode restore also places the
79
+ # bundle's `planning/` drafts at their paths under the state directory, never
80
+ # over a file the checkout already has, and when it placed or kept any prints
81
+ # `placed <n> planning file(s) for <branch>; kept <n> the checkout already
82
+ # carries`. Then, under --resume answer, it
79
83
  # writes each `"<n>": "<text>"` entry to `clarifications/<branch>/answer_<n>.md`
80
84
  # with the exact bytes, after checking every entry first; and with
81
85
  # `HARNESS_INPUT_PARK_LOOP_CLEAR` exactly `true` it sets `park_loop_cycles` to
@@ -230,7 +234,8 @@
230
234
  # `mirror` mode into the record's `worktree`, `run.log` copied to the main
231
235
  # checkout's `autonomous_logs/<branch>.remote.log`, and `status`,
232
236
  # `pause_reason`, `usage_resume_at`, `park_loop_cycles`, `remote_run_id`,
233
- # `remote_run_url`, `remote_detail` and `remote_synced_at` written
237
+ # `remote_run_url`, `remote_detail` and `remote_synced_at` written. A
238
+ # `mirror` restore places no planning draft
234
239
  # 4. no artifact, while some bundle exists (`remote_run_id` is set, or an
235
240
  # older finished run carries one): a job that died before its upload.
236
241
  # `paused` / `killed`, `remote_run_id` / `remote_run_url` re-pointed at
@@ -276,7 +281,7 @@
276
281
  # no job to stop and may complete before its cancel lands — trying every one even
277
282
  # after a failure. (3) Only when (1) and (2) all succeeded, and only when a
278
283
  # local registry record exists, it writes `remote_stopped_at` and sets `status`
279
- # to `failed` through `hr_registry_set`; a partial stop leaves the record alone
284
+ # to `failed` in one `hr_registry_set` call; a partial stop leaves the record alone
280
285
  # and exits 3, so running `stop` again is the remedy.
281
286
  #
282
287
  # `warm` dispatches action=warm on GitHub's OWN default branch (`gh repo view
@@ -294,7 +299,8 @@
294
299
  # `<state_dir>/autonomous_logs/remote_download/<branch>/<id>/` and
295
300
  # `<branch>.remote.log` in the main checkout, plus the mirror restore
296
301
  # `hr_remote_bundle_restore` performs in the record's `worktree`; for
297
- # `restore`, that download directory, the job restore, `answer_<n>.md` and the
302
+ # `restore`, that download directory, the job restore (the planning drafts
303
+ # among it), `answer_<n>.md` and the
298
304
  # `park_loop_cycles` rewrite of `remote_status.json`, all in the job's
299
305
  # checkout; for `save`, <out_dir> and the step summary; for `poll`, its
300
306
  # download directories and `<state_dir>/autonomous_logs/poll_state/previous/`
@@ -363,7 +369,8 @@
363
369
  # flow_walker_state) and GITHUB_RUN_ID set to another id:
364
370
  # restore bash scripts/remote-run.sh restore feat_x --resume none -> 0; the
365
371
  # checkout carries clarifications/feat_x/question_1.md,
366
- # .flow_walker_state and autonomous_logs/remote_status.json
372
+ # .flow_walker_state and autonomous_logs/remote_status.json, and
373
+ # places the bundle's planning/ drafts where the checkout has none
367
374
  # answer HARNESS_INPUT_ANSWERS='{"1":"Use B.\n"}' ... --resume answer -> 0;
368
375
  # clarifications/feat_x/answer_1.md holds exactly `Use B.` + newline
369
376
  # no question HARNESS_INPUT_ANSWERS='{"2":"x"}' ... --resume answer
@@ -381,7 +388,7 @@
381
388
  # -> prints the expired line, "$r" byte-identical
382
389
  # save bash scripts/remote-run.sh save feat_x /tmp/b -> 0; /tmp/b holds
383
390
  # status.json, clarifications/feat_x/, flow_walker_state (and
384
- # PAUSE_PROGRESS.md, run.log when present); with
391
+ # PAUSE_PROGRESS.md, run.log, planning/ when present); with
385
392
  # GITHUB_STEP_SUMMARY=/tmp/s, /tmp/s gains the status table
386
393
  # never started no remote_status.json and no registry: save -> 0, /tmp/b
387
394
  # empty
@@ -811,8 +818,7 @@ verb_stop() {
811
818
 
812
819
  registry=$(hr_state_path "$root" autonomous_logs/registry.json) || registry=""
813
820
  if [ -n "$registry" ] && [ -f "$registry" ] && [ -n "$(hr_registry_get "$registry" "$branch" branch)" ]; then
814
- hr_registry_set "$registry" "$branch" remote_stopped_at "$stopped_at" \
815
- && hr_registry_set "$registry" "$branch" status failed \
821
+ hr_registry_set "$registry" "$branch" remote_stopped_at "$stopped_at" status failed \
816
822
  || echo "remote-run.sh: stopped on GitHub, but the local record of $branch could not be updated" >&2
817
823
  fi
818
824
  echo "remote-run.sh: stopped $branch"
@@ -904,6 +910,17 @@ set_or_fail() {
904
910
  }
905
911
  }
906
912
 
913
+ # set_many_or_fail <key> <value> [<key> <value> …] — every pair in one write, so
914
+ # a concurrent reader never sees an outcome half-applied.
915
+ set_many_or_fail() {
916
+ local keys="" i
917
+ hr_registry_set "$registry" "$branch" "$@" || {
918
+ for ((i = 1; i <= $#; i += 2)); do keys="$keys${keys:+, }${!i}"; done
919
+ echo "remote-run.sh: writing $keys of $branch to '$registry' failed" >&2
920
+ exit "$EXIT_USAGE"
921
+ }
922
+ }
923
+
907
924
  verb_status() {
908
925
  local runs finished_id synced_id field
909
926
  list_runs
@@ -935,18 +952,14 @@ verb_status() {
935
952
  sync_expired() {
936
953
  local line
937
954
  line=$(expired_line "$1")
938
- set_or_fail status paused
939
- set_or_fail pause_reason expired
940
- set_or_fail remote_run_id "$1"
941
- set_or_fail remote_run_url "$2"
942
- set_or_fail remote_detail "$line"
943
- set_or_fail remote_synced_at "$3"
955
+ set_many_or_fail status paused pause_reason expired remote_run_id "$1" \
956
+ remote_run_url "$2" remote_detail "$line" remote_synced_at "$3"
944
957
  echo "remote-run.sh: $line"
945
958
  }
946
959
 
947
960
  verb_sync() {
948
961
  local worktree runs newest id state url synced_id now older download status_file
949
- local status reason detail
962
+ local status reason detail resume_at cycles
950
963
  worktree=$(hr_registry_get "$registry" "$branch" worktree)
951
964
  if [ -z "$worktree" ] || [ ! -d "$worktree" ]; then
952
965
  echo "remote-run.sh: refused, nothing written: the mirror working copy '$worktree' of $branch is missing" >&2
@@ -965,8 +978,7 @@ verb_sync() {
965
978
  now=$(date +%s)
966
979
 
967
980
  if [ "$state" != completed ]; then
968
- set_or_fail status running
969
- set_or_fail remote_synced_at "$now"
981
+ set_many_or_fail status running remote_synced_at "$now"
970
982
  echo "remote-run.sh: run $id of $branch is $state; the record is running, nothing downloaded"
971
983
  return 0
972
984
  fi
@@ -1039,14 +1051,11 @@ verb_sync() {
1039
1051
  detail="the job ended mid-run (its bundle still says running): $url"
1040
1052
  fi
1041
1053
  [ -n "$detail" ] || detail="synced from $url"
1042
- set_or_fail status "$status"
1043
- set_or_fail pause_reason "$reason"
1044
- set_or_fail usage_resume_at "$(hr_remote_status_get "$status_file" usage_resume_at || :)"
1045
- set_or_fail park_loop_cycles "$(hr_remote_status_get "$status_file" park_loop_cycles || :)"
1046
- set_or_fail remote_run_id "$id"
1047
- set_or_fail remote_run_url "$url"
1048
- set_or_fail remote_detail "$detail"
1049
- set_or_fail remote_synced_at "$now"
1054
+ resume_at=$(hr_remote_status_get "$status_file" usage_resume_at) || resume_at=""
1055
+ cycles=$(hr_remote_status_get "$status_file" park_loop_cycles) || cycles=""
1056
+ set_many_or_fail status "$status" pause_reason "$reason" usage_resume_at "$resume_at" \
1057
+ park_loop_cycles "$cycles" remote_run_id "$id" remote_run_url "$url" \
1058
+ remote_detail "$detail" remote_synced_at "$now"
1050
1059
  echo "remote-run.sh: synced run $id of $branch: $status${reason:+ ($reason)}"
1051
1060
  return 0
1052
1061
  fi
@@ -1061,20 +1070,17 @@ verb_sync() {
1061
1070
  done
1062
1071
  fi
1063
1072
  if [ "$bundle_exists" -eq 1 ]; then
1064
- set_or_fail status paused
1065
- set_or_fail pause_reason killed
1066
- set_or_fail remote_run_id "$id"
1067
- set_or_fail remote_run_url "$url"
1068
- set_or_fail remote_detail "run $id ended with no state bundle (killed, cancelled or replaced): $url"
1069
- set_or_fail remote_synced_at "$now"
1073
+ set_many_or_fail status paused pause_reason killed remote_run_id "$id" remote_run_url "$url" \
1074
+ remote_detail "run $id ended with no state bundle (killed, cancelled or replaced): $url" \
1075
+ remote_synced_at "$now"
1070
1076
  echo "remote-run.sh: run $id of $branch left no bundle; the record is paused (killed), nothing restored"
1071
1077
  return 0
1072
1078
  fi
1073
1079
 
1074
1080
  # Case 5 — no bundle in any run.
1075
- set_or_fail status failed
1076
- set_or_fail remote_detail "no run of $branch ever uploaded a state bundle; newest: $url"
1077
- set_or_fail remote_synced_at "$now"
1081
+ set_many_or_fail status failed \
1082
+ remote_detail "no run of $branch ever uploaded a state bundle; newest: $url" \
1083
+ remote_synced_at "$now"
1078
1084
  echo "remote-run.sh: no run of $branch carries a state bundle; the record is failed"
1079
1085
  }
1080
1086
 
@@ -1141,7 +1147,7 @@ verb_restore() {
1141
1147
  if [ "$PREV_RUN_STATE" = expired ]; then
1142
1148
  [ "$resume" != answer ] \
1143
1149
  || restore_refuse "the state bundle of run $id expired on $BUNDLE_EXPIRES_AT, so its questions can no longer be answered here: resume from the committed ledger with $RESUME_HINT $branch, or re-drop the task; nothing written"
1144
- echo "::warning::remote-run.sh: the state bundle of run $id expired on $BUNDLE_EXPIRES_AT: the park-loop, auto-resume and stall counts and the clarification history it carried are lost; this job continues from the committed ledger"
1150
+ echo "::warning::remote-run.sh: the state bundle of run $id expired on $BUNDLE_EXPIRES_AT: the park-loop, auto-resume and stall counts, the clarification history and any planning drafts not yet committed that it carried are lost; this job continues from the committed ledger"
1145
1151
  elif [ -z "$id" ]; then
1146
1152
  [ "$resume" != answer ] \
1147
1153
  || restore_refuse "--resume answer, but no finished run of $branch carries a state bundle; nothing written"
@@ -1164,7 +1170,11 @@ verb_restore() {
1164
1170
  fi
1165
1171
  hr_remote_bundle_restore "$download" "$root" "$branch" job
1166
1172
  case $? in
1167
- 0) echo "remote-run.sh: restored the bundle of run $id into $root" ;;
1173
+ 0)
1174
+ echo "remote-run.sh: restored the bundle of run $id into $root"
1175
+ [ $((HR_REMOTE_PLANNING_PLACED + HR_REMOTE_PLANNING_KEPT)) -eq 0 ] \
1176
+ || echo "remote-run.sh: placed $HR_REMOTE_PLANNING_PLACED planning file(s) for $branch; kept $HR_REMOTE_PLANNING_KEPT the checkout already carries"
1177
+ ;;
1168
1178
  2) restore_refuse "the bundle in '$download' is unrecognised for $branch; nothing restored" ;;
1169
1179
  *) restore_fail "restoring '$download' into '$root' failed" ;;
1170
1180
  esac
@@ -1,6 +1,6 @@
1
1
  # autonomous_logs/
2
2
 
3
- One readable transcript `<branch>.log` and one raw event log `<branch>.stream.jsonl` per dispatched run, plus the daemon's own `watcher.log`, the service manager's capture of its standard output and error as `watcher.out.log` and `watcher.err.log`, and `registry.json` — the run registry that indexes every run's status. All of it resolves to `<state_dir>/autonomous_logs/` **in the main checkout**, whichever sibling working copy a run actually executes in, so one repository has one set of these files however many runs it has in flight.
3
+ One readable transcript `<branch>.log` and one raw event log `<branch>.stream.jsonl` per dispatched run, plus the daemon's own `watcher.log`, the service manager's capture of its standard output and error as `watcher.out.log` and `watcher.err.log`, and `registry.json` — the run registry that indexes every run's status. All of it resolves to `<state_dir>/autonomous_logs/` **in the main checkout**, whichever sibling working copy a run actually executes in, so one repository has one set of these files however many runs it has in flight. A `registry.json.lock` directory beside the registry exists only for the width of one registry write; one left over after a crash is broken automatically by the next writer, and is never removed by hand while a watcher runs.
4
4
 
5
5
  Everything here is written by the run daemon and read by the operator watching or diagnosing a run: the transcript is formatted live and tailable while the run is going, and the raw stream beside it is the deep-debug copy and the one thing the usage gate can parse, since a run cannot read the stream it is itself producing. The registry has readers beyond the operator, and that is what makes it different in kind from its neighbours: it is the single record of which runs exist and what state each is in, and the harness's status command, its pause and resume commands, `<scripts_dir>/restart-watcher.sh` and `<scripts_dir>/cleanup-merged-worktrees.sh` all read it before they act, and `<scripts_dir>/remote-run.sh` reads and writes it for a run executing on GitHub Actions.
6
6