autonomous-sdlc-harness 0.6.0 → 0.6.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.
@@ -9,7 +9,8 @@
9
9
  #
10
10
  # THE INPUT CONTRACT. Composed on the shell side by `remote-run.sh` alone:
11
11
  # action run | pause | warm | stop. Only `run` and `warm` start a
12
- # job; `pause` and `stop` are jobless, titled marker runs
12
+ # job; `pause` and `stop` are jobless, titled marker runs,
13
+ # except a `pause` from the wrong ref (THE REF CHECK)
13
14
  # branch the run's branch (for warm: GitHub's default branch)
14
15
  # engine task | user_review | docs (action=run)
15
16
  # resume none | answer | pause (action=run)
@@ -33,7 +34,8 @@
33
34
  # HARNESS_REMOTE_STOP, CLAUDE_CODE_OAUTH_TOKEN,
34
35
  # ANTHROPIC_API_KEY, HARNESS_PUSH_URL,
35
36
  # HARNESS_GIT_TOKEN, the artifact harness-state,
36
- # HARNESS_CLI_VERSION
37
+ # HARNESS_CLI_VERSION, HARNESS_RUN_ACTORS,
38
+ # HARNESS_TRIGGER_ALLOWED_BOTS
37
39
  # cli/src/generators/githubWorkflows.ts --upgrade-workflows
38
40
  # cli/src/generators/projectSettings.ts the marketplace and plugin key
39
41
  # autonomous-sdlc-harness@autonomous-sdlc-harness
@@ -69,6 +71,38 @@
69
71
  # report step's reason below: scripts older than this file carry no `collect`
70
72
  # verb.
71
73
  #
74
+ # THE REF CHECK. Finding 6 of Gate 12 round 6: GitHub lists a run under the ref
75
+ # it was dispatched from (Use workflow from), so a run or pause dispatched from
76
+ # a ref other than its `branch` input is invisible to every lookup by that
77
+ # branch. When `action` is `run` or `pause` and `github.ref_name` is not
78
+ # `inputs.branch`, the `wrong-ref` job fails, naming the ref to use, and `run`
79
+ # and `collect` are skipped; the jobless `pause` marker gains that failing job
80
+ # only when its ref is wrong. `warm` is exempt because it dispatches on the
81
+ # default branch with that branch as its input, so the two already agree.
82
+ # `stop` is exempt because a deleted branch's stop marker is sent from the
83
+ # default branch, its own ref being gone, and the stop check matches the
84
+ # `harness stop <branch>` title across every branch's runs, under any ref.
85
+ #
86
+ # THE RUN-ACTOR GATE. The first step of `run` and of `collect`, byte-identical
87
+ # in both, refuses a `github.triggering_actor` the repository variable
88
+ # HARNESS_RUN_ACTORS does not admit, so the Run workflow form, `gh workflow run`
89
+ # and a re-run by someone not on the list launch nothing; a refused `run` sets
90
+ # no SCRIPTS_DIR, so its `always()` and `!cancelled()` steps skip too. The list:
91
+ # split on `,`, each entry trimmed, empty entries dropped, matched
92
+ # case-insensitively, `*` admitting every writer; an empty list admits the owner
93
+ # of a user-owned repository alone, and nobody otherwise.
94
+ # * A step, not a job: Re-run failed jobs and Re-run job do not re-run an
95
+ # upstream job that succeeded, so a gate job would never see the re-runner's
96
+ # triggering_actor (docs/team-accounts-research.md, G2).
97
+ # * `github-actions[bot]` passes: every harness dispatch is made with
98
+ # GITHUB_TOKEN and names it. Measured 2026-10-05 for the trigger, the comment
99
+ # commands and `collect`; inferred for the `remote-run.sh continue` chain and
100
+ # the harness-resume.yml poller, which Gate 12 observation (xv) confirms.
101
+ # * Not closed: a writer who edits this file on a branch can remove the gate
102
+ # or dispatch as the bot. That writer can already read the secret (G7).
103
+ # * Resolved before it: the job-level HARNESS_PUSH_URL secret, at job start.
104
+ # The credential secrets and HARNESS_GIT_TOKEN are read only by later steps.
105
+ #
72
106
  # ACTION PINS.
73
107
  # actions/checkout@v5
74
108
  # actions/setup-node@v5
@@ -93,7 +127,8 @@
93
127
  # so your CI runs on it without an approval click), HARNESS_PUSH_URL
94
128
  # (optional notifications).
95
129
  # Variables: HARNESS_RUNNER, HARNESS_REMOTE_STOP (any value stops every job
96
- # before it launches anything), HARNESS_STEP_TIMEOUT_MINUTES,
130
+ # before it launches anything), HARNESS_RUN_ACTORS (THE RUN-ACTOR GATE),
131
+ # HARNESS_STEP_TIMEOUT_MINUTES,
97
132
  # HARNESS_SELF_PAUSE_AFTER_MINUTES, HARNESS_MAX_CHAIN, and the watcher
98
133
  # tunables listed in the `run` job's `env:` under their own names. An unset
99
134
  # variable arrives empty and the script reading it applies its own default.
@@ -234,8 +269,20 @@ defaults:
234
269
  shell: bash
235
270
 
236
271
  jobs:
272
+ wrong-ref:
273
+ if: (inputs.action == 'run' || inputs.action == 'pause') && github.ref_name != inputs.branch
274
+ runs-on: ${{ vars.HARNESS_RUNNER || 'ubuntu-latest' }}
275
+ steps:
276
+ - name: Refuse a dispatch from another ref
277
+ env:
278
+ IN_REF: ${{ github.ref_name }}
279
+ IN_BRANCH: ${{ inputs.branch }}
280
+ run: |
281
+ echo "::error::this run was dispatched from '$IN_REF', but its branch input is '$IN_BRANCH': GitHub lists a run under the ref it was dispatched from, so no lookup of '$IN_BRANCH' would find it. Run the workflow again with Use workflow from set to '$IN_BRANCH'."
282
+ exit 1
283
+
237
284
  run:
238
- if: inputs.action == 'run'
285
+ if: inputs.action == 'run' && github.ref_name == inputs.branch
239
286
  runs-on: ${{ vars.HARNESS_RUNNER || 'ubuntu-latest' }}
240
287
  # The self-hosted job limit, 5 days; a hosted job stops at its own limit.
241
288
  timeout-minutes: 7200
@@ -253,6 +300,7 @@ jobs:
253
300
  HARNESS_INPUT_PARK_LOOP_CLEAR: ${{ inputs.park_loop_clear }}
254
301
  HARNESS_INPUT_CHAIN: ${{ inputs.chain }}
255
302
  HARNESS_REMOTE_STOP: ${{ vars.HARNESS_REMOTE_STOP }}
303
+ HARNESS_RUN_ACTORS: ${{ vars.HARNESS_RUN_ACTORS }}
256
304
  HARNESS_MAX_CHAIN: ${{ vars.HARNESS_MAX_CHAIN }}
257
305
  STALL_WARN_SECS: ${{ vars.STALL_WARN_SECS }}
258
306
  STALL_KILL_SECS: ${{ vars.STALL_KILL_SECS }}
@@ -274,6 +322,51 @@ jobs:
274
322
  HARNESS_STEP_ALLOWANCE_MINUTES: '30'
275
323
  HARNESS_SELF_PAUSE_MARGIN_MINUTES: '90'
276
324
  steps:
325
+ - name: Refuse an actor not on HARNESS_RUN_ACTORS
326
+ env:
327
+ IN_TRIGGERING_ACTOR: ${{ github.triggering_actor }}
328
+ IN_OWNER: ${{ github.repository_owner }}
329
+ IN_OWNER_TYPE: ${{ github.event.repository.owner.type }}
330
+ run: |
331
+ actor="$IN_TRIGGERING_ACTOR"
332
+ if [ "$actor" = 'github-actions[bot]' ]; then
333
+ echo "@$actor passes: every harness dispatch is made with GITHUB_TOKEN and names it."
334
+ exit 0
335
+ fi
336
+ lower() { printf '%s' "$1" | tr '[:upper:]' '[:lower:]'; }
337
+ want=$(lower "$actor")
338
+ rest="${HARNESS_RUN_ACTORS-},"
339
+ listed=""
340
+ while [ -n "$rest" ]; do
341
+ entry="${rest%%,*}"
342
+ rest="${rest#*,}"
343
+ entry="${entry#"${entry%%[![:space:]]*}"}"
344
+ entry="${entry%"${entry##*[![:space:]]}"}"
345
+ [ -n "$entry" ] || continue
346
+ listed=1
347
+ if [ "$entry" = '*' ]; then
348
+ echo "@$actor passes: HARNESS_RUN_ACTORS carries *, which admits every writer."
349
+ exit 0
350
+ fi
351
+ if [ -n "$want" ] && [ "$(lower "$entry")" = "$want" ]; then
352
+ echo "@$actor passes: listed in HARNESS_RUN_ACTORS."
353
+ exit 0
354
+ fi
355
+ done
356
+ if [ -n "$listed" ]; then
357
+ why="@${actor:-(no actor)} is not listed in it."
358
+ elif [ "$IN_OWNER_TYPE" = User ] && [ -n "$IN_OWNER" ]; then
359
+ if [ -n "$want" ] && [ "$(lower "$IN_OWNER")" = "$want" ]; then
360
+ echo "@$actor passes: HARNESS_RUN_ACTORS is unset, which admits the owner of this user-owned repository alone."
361
+ exit 0
362
+ fi
363
+ why="it is unset, which admits the owner of this user-owned repository, @$IN_OWNER, alone, and @${actor:-(no actor)} is not that owner."
364
+ else
365
+ why="it is unset, and this repository has no single owner to admit (its owner type is ${IN_OWNER_TYPE:-unreadable}), so it admits nobody."
366
+ fi
367
+ echo "::error::@${actor:-(no actor)} dispatched or re-ran this run, and the repository variable HARNESS_RUN_ACTORS does not admit them: $why Nothing was launched and no credential was read. Add the login to the comma-separated repository variable HARNESS_RUN_ACTORS, or set it to * for every writer (docs/remote-execution.md, section 9)."
368
+ exit 1
369
+
277
370
  - name: Compute the time budget
278
371
  env:
279
372
  IN_RUNNER_ENVIRONMENT: ${{ runner.environment }}
@@ -486,7 +579,7 @@ jobs:
486
579
 
487
580
  collect:
488
581
  needs: run
489
- if: ${{ inputs.action == 'run' && !cancelled() }}
582
+ if: ${{ inputs.action == 'run' && !cancelled() && github.ref_name == inputs.branch }}
490
583
  runs-on: ${{ vars.HARNESS_RUNNER || 'ubuntu-latest' }}
491
584
  concurrency:
492
585
  group: harness-review-${{ inputs.branch }}
@@ -497,7 +590,53 @@ jobs:
497
590
  HARNESS_INPUT_BRANCH: ${{ inputs.branch }}
498
591
  HARNESS_REMOTE_STOP: ${{ vars.HARNESS_REMOTE_STOP }}
499
592
  HARNESS_TRIGGER_ALLOWED_BOTS: ${{ vars.HARNESS_TRIGGER_ALLOWED_BOTS }}
593
+ HARNESS_RUN_ACTORS: ${{ vars.HARNESS_RUN_ACTORS }}
500
594
  steps:
595
+ - name: Refuse an actor not on HARNESS_RUN_ACTORS before collecting
596
+ env:
597
+ IN_TRIGGERING_ACTOR: ${{ github.triggering_actor }}
598
+ IN_OWNER: ${{ github.repository_owner }}
599
+ IN_OWNER_TYPE: ${{ github.event.repository.owner.type }}
600
+ run: |
601
+ actor="$IN_TRIGGERING_ACTOR"
602
+ if [ "$actor" = 'github-actions[bot]' ]; then
603
+ echo "@$actor passes: every harness dispatch is made with GITHUB_TOKEN and names it."
604
+ exit 0
605
+ fi
606
+ lower() { printf '%s' "$1" | tr '[:upper:]' '[:lower:]'; }
607
+ want=$(lower "$actor")
608
+ rest="${HARNESS_RUN_ACTORS-},"
609
+ listed=""
610
+ while [ -n "$rest" ]; do
611
+ entry="${rest%%,*}"
612
+ rest="${rest#*,}"
613
+ entry="${entry#"${entry%%[![:space:]]*}"}"
614
+ entry="${entry%"${entry##*[![:space:]]}"}"
615
+ [ -n "$entry" ] || continue
616
+ listed=1
617
+ if [ "$entry" = '*' ]; then
618
+ echo "@$actor passes: HARNESS_RUN_ACTORS carries *, which admits every writer."
619
+ exit 0
620
+ fi
621
+ if [ -n "$want" ] && [ "$(lower "$entry")" = "$want" ]; then
622
+ echo "@$actor passes: listed in HARNESS_RUN_ACTORS."
623
+ exit 0
624
+ fi
625
+ done
626
+ if [ -n "$listed" ]; then
627
+ why="@${actor:-(no actor)} is not listed in it."
628
+ elif [ "$IN_OWNER_TYPE" = User ] && [ -n "$IN_OWNER" ]; then
629
+ if [ -n "$want" ] && [ "$(lower "$IN_OWNER")" = "$want" ]; then
630
+ echo "@$actor passes: HARNESS_RUN_ACTORS is unset, which admits the owner of this user-owned repository alone."
631
+ exit 0
632
+ fi
633
+ why="it is unset, which admits the owner of this user-owned repository, @$IN_OWNER, alone, and @${actor:-(no actor)} is not that owner."
634
+ else
635
+ why="it is unset, and this repository has no single owner to admit (its owner type is ${IN_OWNER_TYPE:-unreadable}), so it admits nobody."
636
+ fi
637
+ echo "::error::@${actor:-(no actor)} dispatched or re-ran this run, and the repository variable HARNESS_RUN_ACTORS does not admit them: $why Nothing was launched and no credential was read. Add the login to the comma-separated repository variable HARNESS_RUN_ACTORS, or set it to * for every writer (docs/remote-execution.md, section 9)."
638
+ exit 1
639
+
501
640
  - name: Check out the default branch for collecting
502
641
  uses: actions/checkout@v5
503
642
  with:
@@ -33,7 +33,8 @@
33
33
  # the authority.
34
34
  #
35
35
  # WHO CAN START A RUN. `remote-run.sh`'s `trigger` paragraph owns the check —
36
- # the labeller, bots and HARNESS_TRIGGER_ALLOWED_BOTS, the permission call —
36
+ # the labeller, bots and HARNESS_TRIGGER_ALLOWED_BOTS, the permission call, the
37
+ # allow-list HARNESS_RUN_ACTORS, and the `repository_dispatch` sender check —
37
38
  # and the order its refusals are made in. Nothing here pre-filters on it.
38
39
  #
39
40
  # THE PERMISSIONS, declared because the default token may be read-only
@@ -69,6 +70,7 @@
69
70
  # HARNESS_REMOTE_STOP, HARNESS_TRIGGER_LABEL,
70
71
  # DEFAULT_TRIGGER_LABEL (`sdlc-harness`),
71
72
  # HARNESS_TRIGGER_ALLOWED_BOTS,
73
+ # HARNESS_RUN_ACTORS,
72
74
  # TRIGGER_DISPATCH_EVENT_TYPE (`harness-task`)
73
75
  # cli/src/config/model.ts DEFAULTS.scriptsDir
74
76
  #
@@ -112,6 +114,7 @@ jobs:
112
114
  HARNESS_REMOTE_SLUG: ${{ github.repository }}
113
115
  HARNESS_TRIGGER_LABEL: ${{ vars.HARNESS_TRIGGER_LABEL || 'sdlc-harness' }}
114
116
  HARNESS_TRIGGER_ALLOWED_BOTS: ${{ vars.HARNESS_TRIGGER_ALLOWED_BOTS }}
117
+ HARNESS_RUN_ACTORS: ${{ vars.HARNESS_RUN_ACTORS }}
115
118
  steps:
116
119
  - name: Check out the default branch
117
120
  uses: actions/checkout@v5
@@ -329,6 +329,20 @@
329
329
  # count are seeded from it; the auto-resume count only when
330
330
  # HARNESS_INPUT_CHAIN is above 0, because a chain of 0 is a user's own
331
331
  # dispatch; `chain` never — every write records this job's own input.
332
+ # * THE PAUSE NOTE belongs to the run that wrote it. Before its own first
333
+ # write, the job reads the restored `remote_status.json`'s `status` and
334
+ # `engine`. In job mode, a carried `PAUSE_PROGRESS.md` is kept only when
335
+ # the job's `resume` is `pause`, the restored `remote_status.json` says
336
+ # `status: paused`, and its `engine` equals the job's engine. In every other
337
+ # case — a fresh launch (`resume none`), an answer resume (`resume
338
+ # answer`), a pause resume whose previous job did not pause (a stop or a
339
+ # kill), or a different engine — it is moved aside, never deleted, to
340
+ # `<state_dir>/autonomous_logs/remote_superseded/<epoch>[-<n>]/PAUSE_PROGRESS.md`.
341
+ # The move is lib/harness-run-lib.sh's `hr_remote_move_aside`; this script
342
+ # writes nothing under `remote_superseded/` itself. Separately, a pause
343
+ # resume whose previous job did not pause, or paused for a different
344
+ # engine, is re-launched with a clause saying there is no pause note,
345
+ # whether or not a note was carried (`pause_note_stale` in the registry).
332
346
  # * IT WRITES `remote_status.json` with decision `continue` before the spawn,
333
347
  # so a job killed mid-run leaves a bundle that says continue, again after
334
348
  # every successful control poll, and once more when the run leaves
@@ -1205,17 +1219,27 @@ job_report() {
1205
1219
  # resumed_at when the most recent resume happened — stamped by BOTH
1206
1220
  # resume paths, so it does not say which one
1207
1221
  # resumed_for_index a space-separated list of the clarification indexes one
1208
- # park-resume consumed (`1 2 3`). Set by resume_parked_run
1209
- # and archived then cleared by classify_run_exit, which is
1210
- # the whole of its lifetime — so A NON-EMPTY VALUE ON A
1211
- # `completed` RECORD IS A DEFECT: it means the pairs it
1212
- # names are still sitting unarchived at the top level,
1213
- # where they trigger a resume of a run that already read
1214
- # them. It is NOT a defect on a `paused` record: the pause
1215
- # branch returns before the archival and leaves it set on
1216
- # purpose, because a pause mid park-resume left those
1217
- # answers unconsumed. The pause resume never writes this
1218
- # field — a pause is not an answer.
1222
+ # park-resume consumed (`1 2 3`). Set by begin_park_resume
1223
+ # and archived then cleared by classify_run_exit together
1224
+ # with `launch_answered_set`, which is the whole of its
1225
+ # lifetime — so A NON-EMPTY VALUE ON A `completed` RECORD
1226
+ # IS A DEFECT: it means the pairs it names are still
1227
+ # sitting unarchived at the top level, where they trigger
1228
+ # a resume of a run that already read them. It is NOT a
1229
+ # defect on a `paused` record: the pause branch returns
1230
+ # before the archival and leaves it set on purpose,
1231
+ # because a pause mid park-resume left those answers
1232
+ # unconsumed. The pause resume never writes this field —
1233
+ # a pause is not an answer. It is not the only record of
1234
+ # the consumed set: `launch_answered_set` re-derives it
1235
+ # launch_answered_set every top-level answered pair's index when the current
1236
+ # session launched (`1 2`), which is the set a re-entering
1237
+ # engine consumes. Written by spawn_engine at every launch,
1238
+ # local and job mode alike, from the clarification channel
1239
+ # on disk; archived then cleared by classify_run_exit on a
1240
+ # non-pause exit, and left set by a pause exit. The remote
1241
+ # bundle does not carry it: the next job's launch
1242
+ # re-derives it from the restored channel
1219
1243
  # resume_kind `answer` when the park resume launched the current
1220
1244
  # session, `pause` when the pause resume did, and empty
1221
1245
  # otherwise. Read and cleared by classify_run_exit on every
@@ -1311,7 +1335,14 @@ job_report() {
1311
1335
  # bound of the next control poll, this job's or the next
1312
1336
  # chained one's. Set at start (see JOB MODE) and advanced
1313
1337
  # by every successful poll
1314
- # execution `github-actions` on a remote record — one
1338
+ # pause_note_stale job mode only: `1` when a `pause` job's restored
1339
+ # `status.json` was not `paused` or named another engine,
1340
+ # so spawn_engine's pause-resume prompt says there is no
1341
+ # pause note instead of naming PAUSE_PROGRESS.md. Cleared
1342
+ # with run_job's fresh-launch defaults, and by
1343
+ # classify_run_exit's job-mode pause arm, whose session
1344
+ # just wrote its own note for a later relaunch to read
1345
+ # execution `github-actions` on a remote record — one
1315
1346
  # launch_remote_run wrote through
1316
1347
  # lib/harness-run-lib.sh's `hr_remote_record_init` — and
1317
1348
  # absent on a local one. Fixed for the run's life: a later
@@ -1803,8 +1834,18 @@ files, each paired by index with its question_<i>.md, and resume from the park p
1803
1834
  # committed flow-progress LEDGER (deterministic), with PAUSE_PROGRESS.md as a
1804
1835
  # human-readable hint. Mutually exclusive with the clarification resume above:
1805
1836
  # a run resumes from a park OR from a pause, never both.
1837
+ # JOB MODE ONLY: `pause_note_stale` (see the registry) swaps the note-naming
1838
+ # opening for the stop/kill one; a local record never carries the field.
1839
+ local pause_note_stale=""
1840
+ if [ -n "$pause_resume" ] && [ "$JOB_MODE" = "1" ]; then
1841
+ pause_note_stale="$(registry_get "$branch" pause_note_stale)"
1842
+ fi
1806
1843
  local pause_resume_clause=""
1807
- if [ -n "$pause_resume" ]; then
1844
+ if [ -n "$pause_resume" ] && [ "$pause_note_stale" = "1" ]; then
1845
+ pause_resume_clause="This is a RESUME after the previous job was stopped or ended without pausing: there is no pause \
1846
+ note — resume strictly from the committed flow-progress ledger ${state_rel}/flow_progress/${branch}_progress.md — continue \
1847
+ at the first phase entry still marked [ ] and SKIP every phase already marked [x]; do NOT restart completed phases. "
1848
+ elif [ -n "$pause_resume" ]; then
1808
1849
  pause_resume_clause="This is a RESUME from a PAUSE: read ${state_rel}/PAUSE_PROGRESS.md for the pause note, then \
1809
1850
  resume strictly from the committed flow-progress ledger ${state_rel}/flow_progress/${branch}_progress.md — continue at the \
1810
1851
  first phase entry still marked [ ] and SKIP every phase already marked [x]; do NOT restart completed phases. "
@@ -1852,7 +1893,11 @@ ${GLOBAL_STOP}. End at 'branch ready for review' — never merge, never push to
1852
1893
  # own pause-resume clause pointing at the checklist, and omits the
1853
1894
  # clarification-channel language the other two carry.
1854
1895
  local docs_pause_clause=""
1855
- if [ -n "$pause_resume" ]; then
1896
+ if [ -n "$pause_resume" ] && [ "$pause_note_stale" = "1" ]; then
1897
+ docs_pause_clause="This is a RESUME after the previous job was stopped or ended without pausing: there is no \
1898
+ pause note — resume strictly from the checklist ${state_rel}/docs_catalog/${branch}_docs.md — continue at the first entry \
1899
+ still marked [ ] and SKIP every entry already marked [x]; do NOT rewrite completed docs. "
1900
+ elif [ -n "$pause_resume" ]; then
1856
1901
  docs_pause_clause="This is a RESUME from a PAUSE: read ${state_rel}/PAUSE_PROGRESS.md for the pause note, then \
1857
1902
  resume strictly from the checklist ${state_rel}/docs_catalog/${branch}_docs.md — continue at the first entry still marked [ ] \
1858
1903
  and SKIP every entry already marked [x]; do NOT rewrite completed docs. "
@@ -1929,6 +1974,11 @@ EOF
1929
1974
  formatter="cat"
1930
1975
  fi
1931
1976
 
1977
+ # The answered pairs this session will consume, re-derived from disk at every
1978
+ # launch so no job boundary can lose them; classify_run_exit archives them.
1979
+ registry_set "$branch" launch_answered_set \
1980
+ "$(top_level_answered_pairs "$worktree/$state_rel/clarifications/$branch")"
1981
+
1932
1982
  # Spawn ONE detached subshell that runs the agent IN THE FOREGROUND and then
1933
1983
  # classifies the exit from its REAL exit code. The agent must be a CHILD of
1934
1984
  # this subshell — not a sibling of a separate monitor — or that code is
@@ -2168,11 +2218,13 @@ max_question_index() {
2168
2218
  #
2169
2219
  # Consume-then-archive contract: on a resume the watcher LEAVES the whole
2170
2220
  # answered set at the TOP LEVEL so the re-launched engine can self-detect and
2171
- # consume it. Exactly that set is archived only AFTER that resumed engine exits —
2172
- # here, keyed off the `resumed_for_index` list the resume recorded. That is what
2173
- # stops a pair the engine already read from triggering another resume, without
2174
- # emptying the paths the re-entering engine reads; a pair written mid-session is
2175
- # not in the list and stays.
2221
+ # consume it, and a re-entering engine consumes EVERY top-level answered pair.
2222
+ # So the archived set is every pair answered when the session launched — the
2223
+ # union of `resumed_for_index` and `launch_answered_set` — archived only AFTER
2224
+ # that session exits, and never on a pause exit. That is what stops a pair the
2225
+ # engine already read from triggering another resume, without emptying the
2226
+ # paths the re-entering engine reads; a pair written mid-session is in neither
2227
+ # set and stays.
2176
2228
  classify_run_exit() {
2177
2229
  local branch="$1" worktree="$2" log_path="$3" rc="$4"
2178
2230
 
@@ -2226,7 +2278,9 @@ classify_run_exit() {
2226
2278
  reason=overload
2227
2279
  fi
2228
2280
  fi
2229
- registry_set "$branch" pause_reason "$reason" status paused
2281
+ # pause_note_stale is cleared here, not in a shell global: this runs in the
2282
+ # launch subshell, and only a registry write reaches run_job.
2283
+ registry_set "$branch" pause_reason "$reason" status paused pause_note_stale ""
2230
2284
  log "run '$branch' paused (PAUSE honored, reason $reason) — rc=$rc"
2231
2285
  if [ "$reason" = "user" ]; then
2232
2286
  notify paused "$branch" "$log_path" "paused as you asked — run /autonomous-sdlc-harness:branch-resume $branch to continue; $(hr_github_resume_route "$branch" "$(registry_get "$branch" engine)")"
@@ -2239,23 +2293,25 @@ classify_run_exit() {
2239
2293
  return 0
2240
2294
  fi
2241
2295
 
2242
- # If this exit followed a resume — and was NOT a pause, handled above — every
2243
- # answer in `resumed_for_index` has now been consumed by the re-launched
2244
- # engine. Archive exactly that set before classifying, so none of it is ever
2296
+ # This exit was NOT a pause, handled above, so the session consumed every pair
2297
+ # it launched with: the union of `resumed_for_index` and `launch_answered_set`.
2298
+ # Archive exactly that union before classifying, so none of it is ever
2245
2299
  # reprocessed; nothing else is archived. A legacy single-index value is a
2246
2300
  # one-element list.
2247
- local consumed_set consumed_n
2248
- consumed_set="$(registry_get "$branch" resumed_for_index)"
2249
- if [ -n "$consumed_set" ]; then
2301
+ local consumed_set consumed_n archived=" "
2302
+ consumed_set="$(registry_get "$branch" resumed_for_index) $(registry_get "$branch" launch_answered_set)"
2303
+ if [ -n "${consumed_set// /}" ]; then
2250
2304
  # An empty clar_dir means the state directory was unresolvable above; the
2251
- # field is still cleared, because leaving it set would make the next exit
2305
+ # fields are still cleared, because leaving them set would make the next exit
2252
2306
  # try to archive pairs whose location is no better known than it is now.
2253
2307
  if [ -n "$clar_dir" ]; then
2254
2308
  for consumed_n in $consumed_set; do
2309
+ case "$archived" in *" $consumed_n "*) continue ;; esac
2310
+ archived="${archived}${consumed_n} "
2255
2311
  archive_answered_pair "$clar_dir" "$consumed_n"
2256
2312
  done
2257
2313
  fi
2258
- registry_set "$branch" resumed_for_index ""
2314
+ registry_set "$branch" resumed_for_index "" launch_answered_set ""
2259
2315
  fi
2260
2316
 
2261
2317
  local parked=0
@@ -2399,6 +2455,32 @@ park_answered_set() {
2399
2455
  printf '%s\n' "${answered_set% }"
2400
2456
  }
2401
2457
 
2458
+ # top_level_answered_pairs <clar_dir>
2459
+ #
2460
+ # Every top-level index with both files, space-separated and numerically sorted,
2461
+ # whether or not another top-level question is still unanswered; prints nothing
2462
+ # when there is none. spawn_engine records it at every launch. Each
2463
+ # index is printed as its file name spells it, because archive_answered_pair
2464
+ # rebuilds the file names from it; `sort -n` orders a zero-padded one in base 10.
2465
+ top_level_answered_pairs() {
2466
+ local clar_dir="$1" q n answered_list="" answered_set
2467
+ for q in "$clar_dir"/question_*.md; do
2468
+ [ -e "$q" ] || continue
2469
+ n="${q##*/}"
2470
+ n="${n#question_}"
2471
+ n="${n%.md}"
2472
+ case "$n" in
2473
+ '' | *[!0-9]*) continue ;;
2474
+ esac
2475
+ [ -f "$clar_dir/answer_${n}.md" ] || continue
2476
+ answered_list="${answered_list}${n}
2477
+ "
2478
+ done
2479
+ [ -n "$answered_list" ] || return 0
2480
+ answered_set="$(printf '%s' "$answered_list" | sort -n | tr '\n' ' ')"
2481
+ printf '%s\n' "${answered_set% }"
2482
+ }
2483
+
2402
2484
  # begin_park_resume <branch> <clar_dir> <answered_set>
2403
2485
  #
2404
2486
  # The registry half of a park resume, shared by resume_parked_run and job mode.
@@ -4060,6 +4142,7 @@ run_job() {
4060
4142
  local branch="$1" engine="$2" resume="$3"
4061
4143
  local worktree="$MAIN_REPO" log_path="$LOGS_DIR/$branch.log"
4062
4144
  local state_rel state_abs clar_dir remote_status key value answered_set="" prev_reason=""
4145
+ local prev_status="" prev_engine="" aside_rc
4063
4146
 
4064
4147
  JOB_START_EPOCH="$(job_int "${HARNESS_JOB_STARTED_EPOCH:-}")" || JOB_START_EPOCH="$(date +%s)"
4065
4148
  state_rel="$(run_state_dir "$worktree")" || fatal "job: the state directory in '$worktree' is unresolvable"
@@ -4083,8 +4166,11 @@ run_job() {
4083
4166
  registry_set "$branch" auto_resumes 0
4084
4167
  registry_set "$branch" pause_reason ""
4085
4168
  registry_set "$branch" control_polled_at ""
4169
+ registry_set "$branch" pause_note_stale ""
4086
4170
  if [ -f "$remote_status" ]; then
4087
4171
  prev_reason="$(hr_remote_status_get "$remote_status" pause_reason)" || prev_reason=""
4172
+ prev_status="$(hr_remote_status_get "$remote_status" status)" || prev_status=""
4173
+ prev_engine="$(hr_remote_status_get "$remote_status" engine)" || prev_engine=""
4088
4174
  for key in park_loop_cycles resume_max_question_index stall_restarts; do
4089
4175
  value="$(hr_remote_status_get "$remote_status" "$key")" && registry_set "$branch" "$key" "$value"
4090
4176
  done
@@ -4095,6 +4181,25 @@ run_job() {
4095
4181
  fi
4096
4182
  fi
4097
4183
 
4184
+ # The pause note belongs to the run that wrote it (the header's JOB MODE
4185
+ # block). Two decisions, deliberately independent: the clause reads the
4186
+ # restored status alone, so a run stopped before it ever paused is never
4187
+ # pointed at a note; the move governs only a note that exists.
4188
+ if [ "$resume" = "pause" ] && { [ "$prev_status" != "paused" ] || [ "$prev_engine" != "$engine" ]; }; then
4189
+ registry_set "$branch" pause_note_stale 1
4190
+ log "job: '$branch' resumes after a job that did not pause for engine $engine (prev_status '${prev_status}', prev_engine '${prev_engine}') — no pause note"
4191
+ fi
4192
+ if ! { [ "$resume" = "pause" ] && [ "$prev_status" = "paused" ] && [ "$prev_engine" = "$engine" ]; } \
4193
+ && [ -f "$state_abs/$HR_REMOTE_PAUSE_FILE" ]; then
4194
+ aside_rc=0
4195
+ hr_remote_move_aside "$worktree" "$HR_REMOTE_PAUSE_FILE" || aside_rc=$?
4196
+ case "$aside_rc" in
4197
+ 0) log "job: '$branch' carried a $HR_REMOTE_PAUSE_FILE that is not this run's (resume $resume, prev_status '${prev_status}', prev_engine '${prev_engine}', engine $engine) — moved aside to $HR_REMOTE_ASIDE" ;;
4198
+ 3) ;;
4199
+ *) log "job: WARNING — could not move the carried $HR_REMOTE_PAUSE_FILE of '$branch' aside (hr_remote_move_aside exit $aside_rc); continuing" ;;
4200
+ esac
4201
+ fi
4202
+
4098
4203
  registry_set "$branch" engine "$engine"
4099
4204
  registry_set "$branch" worktree "$worktree"
4100
4205
  registry_set "$branch" log_path "$log_path"
@@ -79,9 +79,12 @@
79
79
  # files under the eight planning paths `hr_remote_planning_paths` assigns;
80
80
  # outside it, only the caller-named `<out_dir>` (its `planning/` included)
81
81
  # of `hr_remote_bundle_write` and the caller-named `<out_json>`
82
- # of `hr_remote_status_write`. Written only by `hr_remote_status_write`,
83
- # `hr_remote_bundle_write` and `hr_remote_bundle_restore`, and nothing
84
- # there but a writer's own failed temp file is ever removed.
82
+ # of `hr_remote_status_write`. The move-aside directory receives the
83
+ # clarification directory and `PAUSE_PROGRESS.md`, both moved there by
84
+ # `hr_remote_move_aside` alone. Written only by `hr_remote_status_write`,
85
+ # `hr_remote_bundle_write`, `hr_remote_bundle_restore` and
86
+ # `hr_remote_move_aside`, and nothing there but a writer's own failed temp
87
+ # file is ever removed.
85
88
  # 4. THE ARTIFACT PLACEMENT writes one artifact into a working copy. Fence:
86
89
  # the caller-named `<worktree>/<rel>`, its parent directories and that
87
90
  # path's index entry, plus whatever the two caller-named wrappers do.
@@ -95,7 +98,7 @@
95
98
  #
96
99
  # A caller that calls no `hr_lane_*`, `hr_registry_init`, `hr_registry_set`,
97
100
  # `hr_remote_record_init`, `hr_registry_lock`, `hr_registry_unlock`, `hr_remote_status_write`,
98
- # `hr_remote_bundle_write`, `hr_remote_bundle_restore`, `hr_place_artifact`,
101
+ # `hr_remote_bundle_write`, `hr_remote_bundle_restore`, `hr_remote_move_aside`, `hr_place_artifact`,
99
102
  # `hr_commit_placed` or `hr_push_landed` function still gets a library that only reads. The
100
103
  # lane's ceilings are the only environment values here that carry policy, because
101
104
  # the lane is machine-scoped and has no configuration key to carry them; each is
@@ -211,7 +214,8 @@
211
214
  # `HR_LANE_OWNER_AT` and `HR_LANE_BROKEN_OWNER`, and the remote state bundle's
212
215
  # names, which `hr_remote_names_var` assigns, with `HR_REMOTE_PLANNING_PATHS`
213
216
  # (`hr_remote_planning_paths`) and `HR_REMOTE_PLANNING_PLACED` /
214
- # `HR_REMOTE_PLANNING_KEPT` (`hr_remote_bundle_restore`). Every one of them is assigned
217
+ # `HR_REMOTE_PLANNING_KEPT` (`hr_remote_bundle_restore`) and `HR_REMOTE_ASIDE`
218
+ # (`hr_remote_move_aside`). Every one of them is assigned
215
219
  # before it is read by the function that owns it, so an inherited value from a
216
220
  # parent process is overwritten rather than believed.
217
221
  #
@@ -2179,6 +2183,45 @@ EOF
2179
2183
  return 0
2180
2184
  }
2181
2185
 
2186
+ # hr_remote_move_aside <root> <rel_path>
2187
+ #
2188
+ # Moves `<root>/<state_dir>/<rel_path>`, a file or a directory, with one `mv`
2189
+ # into `autonomous_logs/remote_superseded/<epoch>/<rel_path>` (`<epoch>-<n>`,
2190
+ # the first `n` = 1, 2, … where that path is free, when an earlier move took
2191
+ # that second), creating the parents and removing nothing. On success
2192
+ # `HR_REMOTE_ASIDE` holds the absolute destination; it is empty at entry.
2193
+ #
2194
+ # 0 moved; 1 a missing argument, a <rel_path> that is absolute or has a `..`
2195
+ # segment, or a failed `mkdir` / `mv`; 2 — touching nothing — <root>'s
2196
+ # configuration is unresolvable; 3 — touching nothing — no source exists.
2197
+ hr_remote_move_aside() {
2198
+ local root="${1-}" rel="${2-}" state base epoch aside n
2199
+ HR_REMOTE_ASIDE=''
2200
+ [ -n "$root" ] && [ -n "$rel" ] || return 1
2201
+ case "$rel" in
2202
+ /*) return 1 ;;
2203
+ esac
2204
+ case "/$rel/" in
2205
+ */../*) return 1 ;;
2206
+ esac
2207
+ state=$(hr_state_dir "$root") || return 2
2208
+ hr_remote_names_var
2209
+ base="${root%/}/$state"
2210
+ [ -e "$base/$rel" ] || [ -L "$base/$rel" ] || return 3
2211
+ epoch=$(date +%s)
2212
+ aside="$base/$HR_REMOTE_SUPERSEDED_DIR/$epoch"
2213
+ n=0
2214
+ while [ -e "$aside/$rel" ] || [ -L "$aside/$rel" ]; do
2215
+ n=$((n + 1))
2216
+ aside="$base/$HR_REMOTE_SUPERSEDED_DIR/$epoch-$n"
2217
+ done
2218
+ aside="$aside/$rel"
2219
+ mkdir -p "${aside%/*}" 2>/dev/null || return 1
2220
+ mv "$base/$rel" "$aside" 2>/dev/null || return 1
2221
+ HR_REMOTE_ASIDE=$aside
2222
+ return 0
2223
+ }
2224
+
2182
2225
  # hr_remote_bundle_restore <bundle_dir> <root> <branch> <mode>
2183
2226
  #
2184
2227
  # <mode> `job` places the clarification directory, `PAUSE_PROGRESS.md`, the
@@ -2195,20 +2238,18 @@ EOF
2195
2238
  # stay `0` in `mirror` mode.
2196
2239
  #
2197
2240
  # THE CLARIFICATION DIRECTORY IS REPLACED WHOLESALE, AND NOTHING IS DELETED. An
2198
- # existing target is moved aside with one `mv` into
2199
- # `autonomous_logs/remote_superseded/<epoch>/clarifications/<branch>` (`<epoch>-<n>`
2200
- # when an earlier restore took that second) before the
2201
- # bundle's copy goes in, so a stale local pair cannot survive and no recursive
2202
- # removal is ever shelled out. A bundle carrying no clarification directory
2203
- # leaves the target alone: in a mirror it may hold an answer not yet relayed.
2204
- #
2205
- # 0 restored; 1 a missing argument, an unknown <mode> or a failed copy; 2 —
2206
- # touching nothing — when the bundle is unrecognised (no readable `status.json`,
2207
- # a schema other than `HR_REMOTE_STATE_SCHEMA`, or a `branch` other than
2208
- # <branch>) or <root>'s configuration is unresolvable.
2241
+ # existing target is moved aside by `hr_remote_move_aside` before the bundle's
2242
+ # copy goes in, so a stale local pair cannot survive and no recursive removal is
2243
+ # ever shelled out. A bundle carrying no clarification directory leaves the
2244
+ # target alone: in a mirror it may hold an answer not yet relayed.
2245
+ #
2246
+ # 0 restored; 1 a missing argument, an unknown <mode>, a failed move aside or a
2247
+ # failed copy; 2 — touching nothing — when the bundle is unrecognised (no
2248
+ # readable `status.json`, a schema other than `HR_REMOTE_STATE_SCHEMA`, or a
2249
+ # `branch` other than <branch>) or <root>'s configuration is unresolvable.
2209
2250
  hr_remote_bundle_restore() {
2210
2251
  local bundle="${1-}" root="${2-}" branch="${3-}" mode="${4-}"
2211
- local state base named target epoch aside n tmp pdir file rel p inset
2252
+ local state base named target tmp pdir file rel p inset
2212
2253
  HR_REMOTE_PLANNING_PLACED=0
2213
2254
  HR_REMOTE_PLANNING_KEPT=0
2214
2255
  [ -n "$bundle" ] && [ -n "$root" ] && [ -n "$branch" ] || return 1
@@ -2226,16 +2267,7 @@ hr_remote_bundle_restore() {
2226
2267
  if [ -d "$bundle/$HR_REMOTE_CLARIFY_DIR/$branch" ]; then
2227
2268
  target="$base/$HR_REMOTE_CLARIFY_DIR/$branch"
2228
2269
  if [ -e "$target" ]; then
2229
- epoch=$(date +%s)
2230
- aside="$base/$HR_REMOTE_SUPERSEDED_DIR/$epoch"
2231
- n=0
2232
- while [ -e "$aside/$HR_REMOTE_CLARIFY_DIR/$branch" ]; do
2233
- n=$((n + 1))
2234
- aside="$base/$HR_REMOTE_SUPERSEDED_DIR/$epoch-$n"
2235
- done
2236
- aside="$aside/$HR_REMOTE_CLARIFY_DIR/$branch"
2237
- mkdir -p "${aside%/*}" 2>/dev/null || return 1
2238
- mv "$target" "$aside" 2>/dev/null || return 1
2270
+ hr_remote_move_aside "$root" "$HR_REMOTE_CLARIFY_DIR/$branch" || return 1
2239
2271
  fi
2240
2272
  mkdir -p "${target%/*}" 2>/dev/null || return 1
2241
2273
  cp -R "$bundle/$HR_REMOTE_CLARIFY_DIR/$branch" "$target" 2>/dev/null || return 1