autonomous-sdlc-harness 0.6.4 → 0.6.6

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.
@@ -113,20 +113,23 @@
113
113
  # The credential secrets and HARNESS_GIT_TOKEN are read only by later steps.
114
114
  #
115
115
  # ACTION PINS.
116
- # actions/checkout@v5
117
- # actions/setup-node@v5
118
- # actions/cache/restore@v5
119
- # actions/cache@v5
120
- # actions/upload-artifact@v6
121
- # Each is the lowest major whose own action.yml declares `runs.using: node24`,
122
- # per a maintainer's lookups of each action's action.yml and release notes on
123
- # 2026-09-29T08:21Z (UTC). Each of those majors' release notes requires Actions
124
- # Runner 2.327.1 or newer; a GitHub-hosted runner already has it, and a
125
- # self-hosted one must run it (docs/remote-execution.md, section 8). A major
126
- # tag, not a commit sha: every action here is GitHub's own `actions/`
127
- # organisation, the owner of the runner and GITHUB_TOKEN this job already
128
- # trusts, and a major tag takes the action's own patch and security releases
129
- # where a sha would freeze your copy. Pin shas in your copy if you want them.
116
+ # actions/checkout v5.1.0
117
+ # actions/setup-node v5.0.0
118
+ # actions/cache/restore v5.1.0
119
+ # actions/cache v5.1.0
120
+ # actions/upload-artifact v6.0.0
121
+ # Each `uses:` names the full commit sha of the release on the comment line
122
+ # above it. Each release is in the lowest major whose own action.yml declares
123
+ # `runs.using: node24`, per a maintainer's lookups of each action's action.yml
124
+ # and release notes on 2026-09-29T08:21Z (UTC). Each of those majors' release
125
+ # notes requires Actions Runner 2.327.1 or newer; a GitHub-hosted runner already
126
+ # has it, and a self-hosted one must run it (docs/remote-execution.md, section
127
+ # 8). A sha, not a tag: a tag is mutable, and this job holds the Claude
128
+ # credential and write tokens. A pin moves when
129
+ # `npx autonomous-sdlc-harness@<version> init --upgrade-workflows` re-renders
130
+ # this file at a newer release's pins; a hand bump replaces the sha and its
131
+ # comment line together. The version stays on its own line, never trailing the
132
+ # `uses:` value: a value carrying ` #` is never a plain scalar.
130
133
  #
131
134
  # WHAT IT READS.
132
135
  # Secrets: CLAUDE_CODE_OAUTH_TOKEN and/or ANTHROPIC_API_KEY (one is required;
@@ -145,13 +148,52 @@
145
148
  # harness.config.json, at run time: `scriptsDir` and whether docs retrieval
146
149
  # applies. No configured value is frozen into this file.
147
150
  #
148
- # THE PERMISSIONS. A workflow-level `permissions:` block sets every permission
149
- # it does not list to `none`, so each one the job uses is listed:
150
- # contents to push the branch
151
- # actions to dispatch, poll and enable the poller
152
- # issues to comment and set labels on the issue and the pull request,
153
- # which both use the issues API
154
- # pull-requests to open the run's pull request, update it and label it
151
+ # THE PERMISSIONS. `permissions: {}` at workflow level grants nothing, and each
152
+ # job's own block sets every permission it does not list to `none`. Each grant
153
+ # is what that job's own steps run:
154
+ # wrong-ref none it fails the dispatch from its step's `env:` alone
155
+ # run contents push-branch.sh pushes the branch
156
+ # actions remote-run.sh `restore` downloads the previous
157
+ # bundle, `continue` dispatches and enables the poller
158
+ # issues lifecycle comments and state labels, on the issue
159
+ # and the pull request, which both use the issues API
160
+ # pull-requests remote-run.sh `open` and `deliver` open, update and
161
+ # label the run's pull request
162
+ # collect contents `collect` runs `review`, whose hr_push_landed
163
+ # pushes the round through push-branch.sh
164
+ # actions the settledness read lists runs and downloads the
165
+ # bundle; `review` dispatches the round (verb_dispatch)
166
+ # issues forge_report's `round` and `not_started` comments
167
+ # and labels, collect_notify's comment, and the label
168
+ # create forge_set_state retries with
169
+ # pull-requests forge_pr_var and round_collect read the pull
170
+ # request's reviews and comments, and `round` runs
171
+ # `gh pr ready --undo` on a ready pull request
172
+ # warm contents read the checkout. Its actions/cache restore and save
173
+ # use the runner's cache token, not GITHUB_TOKEN, and
174
+ # its `init` calls no GitHub API
175
+ # A grant narrower than a job's need fails only on GitHub, never locally.
176
+ #
177
+ # THE CHECKOUT CREDENTIAL. The `warm` checkout sets `persist-credentials: false`:
178
+ # that job pushes nothing. The `run` and `collect` checkouts keep the credential
179
+ # persisted, because both push through push-branch.sh with it (`run` with
180
+ # HARNESS_GIT_TOKEN or the job token, `collect` with the job token), so
181
+ # zizmor's `artipacked` finding stands on those two. The one upload, `Upload
182
+ # the state bundle`, takes its path from the runner's temp directory, outside
183
+ # GITHUB_WORKSPACE, so no artifact carries the checkout's .git/config. An edit
184
+ # keeps it so: never point an upload-artifact `path:` inside the workspace while
185
+ # a checkout persists its credential.
186
+ #
187
+ # THE CLAUDE CLI. `Install the claude CLI when absent` installs
188
+ # @anthropic-ai/claude-code unpinned, and only when the runner has no `claude`.
189
+ # The latest is wanted: the agent runner tracks the model API, and
190
+ # harness-control.yml installs it the same way but is never re-rendered by
191
+ # `--upgrade-workflows` (cli/src/generators/githubWorkflows.ts, choice 5), so a
192
+ # version frozen into the templates would stay frozen there. zizmor's
193
+ # `adhoc-packages` finding stands on it, and pinning does not clear it: it flags
194
+ # `npm install -g @anthropic-ai/claude-code@2.1.284` too, measured 2026-10-09.
195
+ # The audit-silent alternatives, `npx --yes` and `curl | bash`, are the same
196
+ # install with less integrity checking.
155
197
  #
156
198
  # WHY THE REPORT STEP MAY FAIL. `Open the pull request and report` runs
157
199
  # `remote-run.sh deliver` after the push, so the pull request's head carries the
@@ -279,11 +321,7 @@ on:
279
321
  type: number
280
322
  default: 0
281
323
 
282
- permissions:
283
- contents: write
284
- actions: write
285
- issues: write
286
- pull-requests: write
324
+ permissions: {}
287
325
 
288
326
  defaults:
289
327
  run:
@@ -293,6 +331,7 @@ jobs:
293
331
  wrong-ref:
294
332
  if: (inputs.action == 'run' || inputs.action == 'pause') && github.ref_name != inputs.branch
295
333
  runs-on: ${{ vars.HARNESS_RUNNER || 'ubuntu-latest' }}
334
+ permissions: {}
296
335
  steps:
297
336
  - name: Refuse a dispatch from another ref
298
337
  env:
@@ -305,6 +344,11 @@ jobs:
305
344
  run:
306
345
  if: inputs.action == 'run' && github.ref_name == inputs.branch
307
346
  runs-on: ${{ vars.HARNESS_RUNNER || 'ubuntu-latest' }}
347
+ permissions:
348
+ contents: write
349
+ actions: write
350
+ issues: write
351
+ pull-requests: write
308
352
  # The self-hosted job limit, 5 days; a hosted job stops at its own limit.
309
353
  timeout-minutes: 7200
310
354
  concurrency:
@@ -434,7 +478,8 @@ jobs:
434
478
 
435
479
  - name: Check out the run's branch
436
480
  if: env.HARNESS_STOPPED != '1'
437
- uses: actions/checkout@v5
481
+ # actions/checkout v5.1.0
482
+ uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09
438
483
  with:
439
484
  ref: ${{ inputs.branch }}
440
485
  fetch-depth: 0
@@ -466,7 +511,8 @@ jobs:
466
511
 
467
512
  - name: Set up Node
468
513
  if: env.HARNESS_STOPPED != '1'
469
- uses: actions/setup-node@v5
514
+ # actions/setup-node v5.0.0
515
+ uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444
470
516
  with:
471
517
  node-version: '22'
472
518
  # v5 caches by package.json's packageManager; the job installs nothing through npm's cache, and a lockfile-less repository must not fail this step.
@@ -522,7 +568,8 @@ jobs:
522
568
 
523
569
  - name: Restore the docs-retrieval cache
524
570
  if: env.HARNESS_STOPPED != '1' && steps.config.outputs.retrieval == 'true'
525
- uses: actions/cache/restore@v5
571
+ # actions/cache/restore v5.1.0
572
+ uses: actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25
526
573
  with:
527
574
  path: ${{ env.HARNESS_RETRIEVAL_CACHE }}
528
575
  key: harness-retrieval-${{ runner.os }}-${{ env.HARNESS_CLI_VERSION }}
@@ -582,7 +629,8 @@ jobs:
582
629
 
583
630
  - name: Upload the state bundle
584
631
  if: always() && env.SCRIPTS_DIR != ''
585
- uses: actions/upload-artifact@v6
632
+ # actions/upload-artifact v6.0.0
633
+ uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f
586
634
  with:
587
635
  name: harness-state
588
636
  path: ${{ runner.temp }}/harness-state
@@ -612,6 +660,11 @@ jobs:
612
660
  needs: run
613
661
  if: ${{ inputs.action == 'run' && !cancelled() && github.ref_name == inputs.branch }}
614
662
  runs-on: ${{ vars.HARNESS_RUNNER || 'ubuntu-latest' }}
663
+ permissions:
664
+ contents: write
665
+ actions: write
666
+ issues: write
667
+ pull-requests: write
615
668
  concurrency:
616
669
  group: harness-review-${{ inputs.branch }}
617
670
  cancel-in-progress: false
@@ -669,7 +722,8 @@ jobs:
669
722
  exit 1
670
723
 
671
724
  - name: Check out the default branch for collecting
672
- uses: actions/checkout@v5
725
+ # actions/checkout v5.1.0
726
+ uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09
673
727
  with:
674
728
  ref: ${{ github.event.repository.default_branch }}
675
729
  token: ${{ github.token }}
@@ -703,6 +757,8 @@ jobs:
703
757
  warm:
704
758
  if: inputs.action == 'warm'
705
759
  runs-on: ${{ vars.HARNESS_RUNNER || 'ubuntu-latest' }}
760
+ permissions:
761
+ contents: read
706
762
  env:
707
763
  HARNESS_CLI_VERSION: '{{cliVersion}}'
708
764
  HARNESS_REMOTE_STOP: ${{ vars.HARNESS_REMOTE_STOP }}
@@ -716,7 +772,10 @@ jobs:
716
772
 
717
773
  - name: Check out the dispatched ref
718
774
  if: env.HARNESS_STOPPED != '1'
719
- uses: actions/checkout@v5
775
+ # actions/checkout v5.1.0
776
+ uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09
777
+ with:
778
+ persist-credentials: false
720
779
 
721
780
  - name: Read the configuration
722
781
  id: config
@@ -735,7 +794,8 @@ jobs:
735
794
 
736
795
  - name: Set up Node
737
796
  if: env.HARNESS_STOPPED != '1' && steps.config.outputs.retrieval == 'true'
738
- uses: actions/setup-node@v5
797
+ # actions/setup-node v5.0.0
798
+ uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444
739
799
  with:
740
800
  node-version: '22'
741
801
  # As in the run job: no npm cache is used, and a lockfile-less repository must not fail this step.
@@ -743,7 +803,8 @@ jobs:
743
803
 
744
804
  - name: Restore and save the docs-retrieval cache
745
805
  if: env.HARNESS_STOPPED != '1' && steps.config.outputs.retrieval == 'true'
746
- uses: actions/cache@v5
806
+ # actions/cache v5.1.0
807
+ uses: actions/cache@caa296126883cff596d87d8935842f9db880ef25
747
808
  with:
748
809
  path: ${{ env.HARNESS_RETRIEVAL_CACHE }}
749
810
  key: harness-retrieval-${{ runner.os }}-${{ env.HARNESS_CLI_VERSION }}
@@ -5,7 +5,7 @@
5
5
  # `forge` is `github` and its `execution.target` is `github-actions`. Written
6
6
  # create-if-absent: from then on it is yours to tune, and a re-run of `init`
7
7
  # keeps your copy. `init --upgrade-workflows` does NOT re-render it: it carries
8
- # no version pin and calls the scripts on the default branch, so it shares
8
+ # no CLI version pin and calls the scripts on the default branch, so it shares
9
9
  # their re-run contract — `init --force` replaces it after a `.bak`.
10
10
  #
11
11
  # WHAT IT DOES. One job runs `remote-run.sh trigger`, which does everything
@@ -38,7 +38,9 @@
38
38
  # and the order its refusals are made in. Nothing here pre-filters on it.
39
39
  #
40
40
  # THE PERMISSIONS, declared because the default token may be read-only
41
- # (docs/github-integration-research.md, T2):
41
+ # (docs/github-integration-research.md, T2). `permissions: {}` at workflow
42
+ # level grants nothing, and the `trigger` job's own block sets every permission
43
+ # it does not list to `none`, so a job added later inherits nothing:
42
44
  # contents: write push the new branch carrying the task prompt
43
45
  # actions: write dispatch harness-run.yml and look up the run it created
44
46
  # issues: write comment on the issue and remove the trigger label
@@ -49,7 +51,13 @@
49
51
  # HARNESS_GIT_TOKEN: a branch pushed with GITHUB_TOKEN starts no workflow
50
52
  # (docs/remote-execution.md, `### Verified in Gate 12 round 3`), so the prompt
51
53
  # commit does not run your CI. A full fetch, because pushing a new branch from
52
- # a shallow clone has not been measured anywhere.
54
+ # a shallow clone has not been measured anywhere. The checkout keeps the
55
+ # credential persisted, so zizmor's `artipacked` finding stands on it:
56
+ # `remote-run.sh trigger` runs `start`, whose `hr_push_landed` pushes the new
57
+ # branch through push-branch.sh with that credential. The job uploads no
58
+ # artifact, so nothing carries the checkout's .git/config off the runner; an
59
+ # edit adding an upload never points its `path:` at the workspace root or at
60
+ # `.git/`.
53
61
  #
54
62
  # NO CONCURRENCY GROUP. A group keeps at most one pending run and cancels an
55
63
  # earlier pending one, which would drop a trigger. Two starts that race for one
@@ -75,16 +83,19 @@
75
83
  # cli/src/config/model.ts DEFAULTS.scriptsDir
76
84
  #
77
85
  # ACTION PINS.
78
- # actions/checkout@v5
79
- # The lowest major whose own action.yml declares `runs.using: node24`, per a
80
- # maintainer's lookups of its action.yml and release notes on
81
- # 2026-09-29T08:21Z (UTC). That major's release notes require Actions Runner
82
- # 2.327.1 or newer; a GitHub-hosted runner already has it, and a self-hosted
83
- # one must run it (docs/remote-execution.md, section 8). A major tag, not a
84
- # commit sha: it is GitHub's own `actions/` organisation, the owner of the
85
- # runner and GITHUB_TOKEN this job already trusts, and a major tag takes the
86
- # action's own patch and security releases where a sha would freeze your copy.
87
- # Pin a sha in your copy if you want one.
86
+ # actions/checkout v5.1.0
87
+ # The `uses:` names the full commit sha of the release on the comment line
88
+ # above it. The release is in the lowest major whose own action.yml declares
89
+ # `runs.using: node24`, per a maintainer's lookups of its action.yml and release
90
+ # notes on 2026-09-29T08:21Z (UTC). That major's release notes require Actions
91
+ # Runner 2.327.1 or newer; a GitHub-hosted runner already has it, and a
92
+ # self-hosted one must run it (docs/remote-execution.md, section 8). A sha, not
93
+ # a tag: a tag is mutable, and this job holds a write token while it reads
94
+ # untrusted issue text. `init --upgrade-workflows` does not re-render this file
95
+ # (see WHO WRITES IT), so its pin moves only when `init --force` replaces it
96
+ # after a `.bak`, or by a hand bump, which replaces the sha and its comment line
97
+ # together. The version stays on its own line, never trailing the `uses:` value:
98
+ # a value carrying ` #` is never a plain scalar.
88
99
 
89
100
  name: harness-trigger
90
101
  run-name: harness trigger ${{ github.event.issue.number || github.event.action }}
@@ -95,10 +106,7 @@ on:
95
106
  repository_dispatch:
96
107
  types: [harness-task]
97
108
 
98
- permissions:
99
- contents: write
100
- actions: write
101
- issues: write
109
+ permissions: {}
102
110
 
103
111
  defaults:
104
112
  run:
@@ -108,6 +116,10 @@ jobs:
108
116
  trigger:
109
117
  if: github.event_name == 'repository_dispatch' || github.event.label.name == (vars.HARNESS_TRIGGER_LABEL || 'sdlc-harness')
110
118
  runs-on: ${{ vars.HARNESS_RUNNER || 'ubuntu-latest' }}
119
+ permissions:
120
+ contents: write
121
+ actions: write
122
+ issues: write
111
123
  env:
112
124
  GH_TOKEN: ${{ github.token }}
113
125
  HARNESS_REMOTE_STOP: ${{ vars.HARNESS_REMOTE_STOP }}
@@ -117,7 +129,8 @@ jobs:
117
129
  HARNESS_RUN_ACTORS: ${{ vars.HARNESS_RUN_ACTORS }}
118
130
  steps:
119
131
  - name: Check out the default branch
120
- uses: actions/checkout@v5
132
+ # actions/checkout v5.1.0
133
+ uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09
121
134
  with:
122
135
  token: ${{ github.token }}
123
136
  fetch-depth: 0
@@ -249,7 +249,7 @@
249
249
  # would watch a directory nobody drops files into, log where nobody tails, and
250
250
  # honor a kill switch nobody can reach — silently, for as long as it runs. So an
251
251
  # unreadable library, a location outside a repository, or a `harness.config.json`
252
- # that is absent, unparseable, multi-document, missing `defaultBranch` or beyond
252
+ # that is absent, unparsable, multi-document, missing `defaultBranch` or beyond
253
253
  # the `jq` floor ends the process with ONE line on stderr and a non-zero status,
254
254
  # before anything is created. Those lines go to stderr rather than to the watcher
255
255
  # log, because the log's location is exactly what could not be resolved; the
@@ -1574,7 +1574,7 @@ EOF
1574
1574
 
1575
1575
  # The report itself: every registered repository with its state, model, effort,
1576
1576
  # live-run count and per-repository cap, then one summary. Reading fails open in
1577
- # §7's sense — absent, unreadable, unparseable, non-object or unrecognised-schema
1577
+ # §7's sense — absent, unreadable, unparsable, non-object or unrecognised-schema
1578
1578
  # reads as "no repositories are registered", and a malformed entry is dropped
1579
1579
  # rather than hiding the rest. Never a shell error, never a non-zero status,
1580
1580
  # never a write.
@@ -2001,7 +2001,7 @@ ${GLOBAL_STOP}. End at 'branch ready for review' — never merge, never push to
2001
2001
  # an --add-dir, because a job session was refused reads under a root the profile
2002
2002
  # file already granted. `init` stays the one producer of the list. File order;
2003
2003
  # an empty entry and one equal to the two directories already passed are
2004
- # skipped. An absent, unparseable or keyless profile adds nothing and never
2004
+ # skipped. An absent, unparsable or keyless profile adds nothing and never
2005
2005
  # blocks the launch — `doctor --remote-job` refuses an unusable profile earlier.
2006
2006
  local extra_dir_args extra_dirs_logged
2007
2007
  extra_dir_args=()
@@ -3106,7 +3106,7 @@ remote_commit_and_push() {
3106
3106
  # ^(.+)_docs\.md$ -> docs engine, a fresh working copy
3107
3107
  #
3108
3108
  # The library owns these patterns and their order: `hr_inbox_route_var` in
3109
- # lib/harness-run-lib.sh, whose comment states why they cannot mis-route.
3109
+ # lib/harness-run-lib.sh, whose comment states why they cannot misroute.
3110
3110
  #
3111
3111
  # A filename matching none of the three is logged and ARCHIVED rather than left
3112
3112
  # where it is, so it is not re-logged on every pass for as long as the watcher
@@ -3583,14 +3583,14 @@ usage_read_run() {
3583
3583
  sf="${lp%.log}.stream.jsonl"
3584
3584
  [ -f "$sf" ] || return 0
3585
3585
  tail -n 8000 "$sf" 2>/dev/null | grep '"type":"rate_limit_event"' |
3586
- jq -rs --argjson thr "$USAGE_SEVEN_DAY_PAUSE_PCT" '
3586
+ jq -rs --argjson threshold "$USAGE_SEVEN_DAY_PAUSE_PCT" '
3587
3587
  [ .[] | select(.rate_limit_info) | .rate_limit_info ] as $ev
3588
3588
  | [ ($ev | map(select(.rateLimitType == "five_hour")) | last),
3589
3589
  ($ev | map(select(.rateLimitType == "seven_day")) | last),
3590
3590
  ($ev | map(select(.rateLimitType != "five_hour" and .rateLimitType != "seven_day")) | last) ]
3591
3591
  | map(select(. != null))[]
3592
3592
  | (.status // "unknown") as $st0
3593
- | (if (.rateLimitType == "seven_day") and ($st0 == "allowed_warning") and (((.utilization // 0)) < $thr)
3593
+ | (if (.rateLimitType == "seven_day") and ($st0 == "allowed_warning") and (((.utilization // 0)) < $threshold)
3594
3594
  then "allowed" else $st0 end) as $st
3595
3595
  | "\($st) \(.isUsingOverage // false) \(.resetsAt // 0) \(.overageResetsAt // 0)"' 2>/dev/null
3596
3596
  }
@@ -325,7 +325,7 @@ run_bounded() {
325
325
  # string `0`, and `[ 0 -ge 00 ]` is true on the first iteration, so every sweep
326
326
  # kills its own fetch before it starts, blames the network, and never cleans up
327
327
  # another branch. Comparing the VALUE closes both: `[ ]` returns non-zero for an
328
- # unparseable or out-of-range string as readily as for a number that fails the
328
+ # unparsable or out-of-range string as readily as for a number that fails the
329
329
  # test, and the default is what survives either way. The upper clamp is what keeps
330
330
  # a fat-fingered value from expressing "wait forever" in seconds.
331
331
  fetch_timeout="${HARNESS_FETCH_TIMEOUT:-60}"
@@ -1100,7 +1100,7 @@ hr_docs_retrieval_backend() {
1100
1100
  # anchored SUFFIX regexes are mutually exclusive by construction: a filename
1101
1101
  # cannot end in more than one of `_task_prompt.md` / `_review[_<n>].md` /
1102
1102
  # `_docs.md`, so a branch whose own name contains `review` or `task_prompt`
1103
- # cannot be mis-routed — `foo_review_task_prompt.md` is the task engine on branch
1103
+ # cannot be misrouted — `foo_review_task_prompt.md` is the task engine on branch
1104
1104
  # `foo_review`, and `foo_task_prompt_review.md` is the review engine on branch
1105
1105
  # `foo_task_prompt`. POSIX leftmost-longest matching of the greedy `(.+)` derives
1106
1106
  # the right branch from a round-suffixed name: `foo_review_2.md` -> branch `foo`
@@ -2501,7 +2501,7 @@ EOF
2501
2501
  # reset would pin the file for the life of the machine.
2502
2502
  #
2503
2503
  # FAIL OPEN ON THE STATE, CLOSED ON THE LANE. An absent, unreadable or
2504
- # unparseable `usage-state.json` reads as `unknown 0`, which defers nobody:
2504
+ # unparsable `usage-state.json` reads as `unknown 0`, which defers nobody:
2505
2505
  # pausing on an unreadable file would put a machine-level fault in charge of run
2506
2506
  # state, and each repository's own gate is what pauses its runs. A lock directory
2507
2507
  # that cannot be read or created is NOT assumed free: `hr_lane_acquire` returns
@@ -38,7 +38,8 @@
38
38
  # (always 0, 1 only on a usage error: its paragraph)
39
39
  # remote-run.sh collect <branch> [--pr <n>] [--repo <root>]
40
40
  # (always 0, 1 only on a usage error: its paragraph)
41
- # remote-run.sh control [--repo <root>] (its own exit map: its paragraph)
41
+ # remote-run.sh control [--needs-agent] [--repo <root>]
42
+ # (its own exit map: its paragraph)
42
43
  # 0 sent (for stop: the action=stop marker was dispatched, and every
43
44
  # queued, waiting or in-progress `harness run` run of that branch was
44
45
  # asked to cancel, or there was none); for status and fetch: printed
@@ -534,6 +535,18 @@
534
535
  # no such word exists today): the reply lists every command and names
535
536
  # `docs/github-run-control.md`. Skipped for a mention.
536
537
  # Then THE BRANCH, and the exact form's arm (`control_run_verb`) or MENTION.
538
+ # `--needs-agent` answers only whether MENTION would start a session: the
539
+ # intake above, then refusals 1 to 3 (`control_gates`, shared with `control`),
540
+ # run alone, posting, dispatching and creating nothing; its one `gh` call is
541
+ # `authorise_actor`'s permission call, and the branch is never resolved. One
542
+ # stdout line each: exit 0 `needs-agent: yes, a mention by @<login> on #<n>`;
543
+ # exit 2 `needs-agent: no, <reason>` for an event other than `issue_comment`,
544
+ # an ignored comment (after its own line), the exact form (before any gate,
545
+ # no `gh` call), or a refusal, whose <reason> is that refusal's reply text;
546
+ # exit 3 `needs-agent: undecided, <AUTH_WHY>` when the permission call failed
547
+ # (`control` still refuses that with exit 2); exit 1 as `control`'s.
548
+ # `WORKFLOW_CONTROL_FILE` runs it before installing the agent; the act step,
549
+ # plain `control`, still re-checks everything.
537
550
  # MENTION. `verb_control`'s first statements, for every event, copy `IN_OAUTH`
538
551
  # / `IN_API` into the non-exported `MENTION_OAUTH` / `MENTION_API` and unset
539
552
  # them, with `CLAUDE_CODE_OAUTH_TOKEN` / `ANTHROPIC_API_KEY`, so no `gh`, `git`
@@ -611,8 +624,8 @@
611
624
  # is `branch-resume`'s confirmation. Exits: 0 answered or nothing to do; 2
612
625
  # refused; 3 a state read, plugin, binary or agent failure, or a credential
613
626
  # value in `text` or `answer`; a carried-out verb, its arm's. The workflow's
614
- # interface: `IN_OAUTH` / `IN_API` and
615
- # `HARNESS_MENTION_PLUGIN_DIR`.
627
+ # interface: `IN_OAUTH` / `IN_API`, `HARNESS_MENTION_PLUGIN_DIR`, and
628
+ # `control --needs-agent`'s exit 2, on which it skips installing the agent.
616
629
  # THE BRANCH. On a pull request (`.issue.pull_request.url` set), its head, by
617
630
  # `pr view`: a fork's pull request is refused, because this event carries the
618
631
  # repository's secrets, and nothing from its head is checked out or run; one
@@ -1540,6 +1553,13 @@
1540
1553
  # harness-run.yml --ref feat_x -f action=pause -f branch=feat_x`,
1541
1554
  # then one comment on 12 opening `Read from your mention as
1542
1555
  # `@sdlc-harness pause`.`
1556
+ # needs-agent the mention's c.json, `control --needs-agent` -> 0, one line
1557
+ # `needs-agent: yes, a mention by @alice on #12`; "$s.log" holds
1558
+ # only the permission call
1559
+ # needs-agent c.json's body `@sdlc-harness pause` -> 2, one `needs-agent: no`
1560
+ # exact line naming the exact form; "$s.log" unchanged
1561
+ # needs-agent the mention with HARNESS_RUN_ACTORS=bob -> 2, one `needs-agent:
1562
+ # unlisted no` line naming HARNESS_RUN_ACTORS; nothing posted
1543
1563
 
1544
1564
  set -u
1545
1565
 
@@ -1664,7 +1684,7 @@ usage() {
1664
1684
  echo " remote-run.sh open <branch> [--repo <root>]" >&2
1665
1685
  echo " remote-run.sh deliver <branch> <bundle_dir> [--repo <root>]" >&2
1666
1686
  echo " remote-run.sh collect <branch> [--pr <n>] [--repo <root>]" >&2
1667
- echo " remote-run.sh control [--repo <root>]" >&2
1687
+ echo " remote-run.sh control [--needs-agent] [--repo <root>]" >&2
1668
1688
  [ "${verb-}" != save ] || exit "$EXIT_OK"
1669
1689
  exit "$EXIT_USAGE"
1670
1690
  }
@@ -1764,6 +1784,7 @@ reviewers_arg=""
1764
1784
  allow_no_run=0
1765
1785
  pr_arg=""
1766
1786
  branch_gone=0
1787
+ needs_agent=0
1767
1788
 
1768
1789
  while [ "$#" -gt 0 ]; do
1769
1790
  case "$1" in
@@ -1824,6 +1845,9 @@ while [ "$#" -gt 0 ]; do
1824
1845
  --branch-gone)
1825
1846
  [ "$verb" = stop ] || usage "$1 is a stop option"
1826
1847
  branch_gone=1; shift ;;
1848
+ --needs-agent)
1849
+ [ "$verb" = control ] || usage "$1 is a control option"
1850
+ needs_agent=1; shift ;;
1827
1851
  -*)
1828
1852
  usage "unknown option '$1'" ;;
1829
1853
  *)
@@ -3541,7 +3565,7 @@ verb_poll() {
3541
3565
  verb_pause_requested() {
3542
3566
  local found
3543
3567
  list_runs
3544
- # An unparseable createdAt is no match rather than a failed read.
3568
+ # An unparsable createdAt is no match rather than a failed read.
3545
3569
  found=$(printf '%s' "$GH_OUT" | jq -r --arg t "harness pause $branch" --argjson since "$since_arg" '
3546
3570
  [.[] | select(.displayTitle == $t)
3547
3571
  | ((.createdAt // "") | try fromdateiso8601 catch null)
@@ -7423,9 +7447,75 @@ control_mention() {
7423
7447
  exit "$EXIT_GH"
7424
7448
  }
7425
7449
 
7450
+ # control_gates — refusals 1 to 3 of `control`, in its order, for both
7451
+ # `control` and `--needs-agent`. 0 when all pass; otherwise 1 with CG_EXIT,
7452
+ # CG_WHY and CG_WAY set as `control_refuse` takes them, and CG_AUTH the
7453
+ # `authorise_actor` status (0 when an earlier gate refused). Posts nothing.
7454
+ CG_EXIT=0
7455
+ CG_WHY=""
7456
+ CG_WAY=""
7457
+ CG_AUTH=0
7458
+ control_gates() {
7459
+ local forge target
7460
+ CG_EXIT="$EXIT_REFUSED"
7461
+ CG_WHY=""
7462
+ CG_WAY=""
7463
+ CG_AUTH=0
7464
+ if [ -n "${HARNESS_REMOTE_STOP-}" ]; then
7465
+ CG_WHY="the repository variable \`HARNESS_REMOTE_STOP\` is set, which stops every command"
7466
+ CG_WAY="Clear it under **Settings → Secrets and variables → Actions → Variables**, then comment again."
7467
+ return 1
7468
+ fi
7469
+
7470
+ forge=$(hr_forge "$root") || forge=""
7471
+ target=$(hr_execution_target "$root") || target=""
7472
+ if [ "$forge" != github ] || [ "$target" != github-actions ]; then
7473
+ CG_WHY="the default branch's \`harness.config.json\` does not turn run control on: it needs \`forge\` set to \`github\` (it is ${forge:-not set or unreadable}) and \`execution.target\` set to \`github-actions\` (it is ${target:-unreadable})"
7474
+ CG_WAY="Set both keys on the default branch, then comment again."
7475
+ return 1
7476
+ fi
7477
+
7478
+ if ! rerun_actor_listed; then
7479
+ CG_WHY="${RUN_ACTORS_WHY%.}"
7480
+ CG_WAY="Only a person the repository variable \`HARNESS_RUN_ACTORS\` admits may re-run this job; one of them can comment again."
7481
+ return 1
7482
+ fi
7483
+
7484
+ authorise_actor "$CONTROL_ACTOR" "$CONTROL_SENDER_TYPE" || CG_AUTH=$?
7485
+ if [ "$CG_AUTH" -ne 0 ]; then
7486
+ CG_WHY="${AUTH_WHY%.}"
7487
+ CG_WAY="Only a collaborator with write, maintain or admin access whom the repository variable \`HARNESS_RUN_ACTORS\` admits (when unset, the owner alone of a repository a personal account owns, and nobody in an organisation-owned one), or a bot listed in \`HARNESS_TRIGGER_ALLOWED_BOTS\`, commands a run."
7488
+ return 1
7489
+ fi
7490
+ return 0
7491
+ }
7492
+
7493
+ # control_needs_agent_no <reason> — the `--needs-agent` answer that no session
7494
+ # starts: one line, exit 2.
7495
+ control_needs_agent_no() {
7496
+ echo "remote-run.sh: control: needs-agent: no, $1"
7497
+ exit "$EXIT_REFUSED"
7498
+ }
7499
+
7500
+ # control_needs_agent — `--needs-agent` after the comment intake: the exact
7501
+ # form, then `control_gates`, answered as one line and an exit. Never returns.
7502
+ control_needs_agent() {
7503
+ [ -z "$CONTROL_VERB" ] \
7504
+ || control_needs_agent_no "the comment is the exact form \`$COMMAND_HANDLE $CONTROL_VERB\`"
7505
+ if ! control_gates; then
7506
+ if [ "$CG_AUTH" -eq 4 ]; then
7507
+ echo "remote-run.sh: control: needs-agent: undecided, $AUTH_WHY"
7508
+ exit "$EXIT_GH"
7509
+ fi
7510
+ control_needs_agent_no "$CG_WHY"
7511
+ fi
7512
+ echo "remote-run.sh: control: needs-agent: yes, a mention by @$CONTROL_ACTOR on #$CONTROL_NUMBER"
7513
+ exit "$EXIT_OK"
7514
+ }
7515
+
7426
7516
  verb_control() {
7427
7517
  local LC_ALL=C
7428
- local review=0 close=0 forge="" target="" status
7518
+ local review=0 close=0
7429
7519
  # Unset first so an inherited export of either name cannot keep it exported.
7430
7520
  unset MENTION_OAUTH MENTION_API MENTION_JOB_TOKEN_B64
7431
7521
  MENTION_OAUTH="${IN_OAUTH-}"
@@ -7447,9 +7537,17 @@ verb_control() {
7447
7537
  fi
7448
7538
  hr_have_jq || { echo "remote-run.sh: control needs jq" >&2; exit "$EXIT_USAGE"; }
7449
7539
 
7540
+ if [ "$needs_agent" -eq 1 ] && [ "$GITHUB_EVENT_NAME" != issue_comment ]; then
7541
+ control_needs_agent_no "a \`$GITHUB_EVENT_NAME\` event is not a comment"
7542
+ fi
7543
+
7450
7544
  case "$GITHUB_EVENT_NAME" in
7451
7545
  pull_request_review) control_review_intake || return 0 ;;
7452
- issue_comment) control_comment_intake || return 0 ;;
7546
+ issue_comment)
7547
+ if ! control_comment_intake; then
7548
+ [ "$needs_agent" -eq 0 ] || control_needs_agent_no "the comment is ignored"
7549
+ return 0
7550
+ fi ;;
7453
7551
  issues) control_issues_intake || return 0 ;;
7454
7552
  pull_request) control_pull_request_intake || return 0 ;;
7455
7553
  delete) control_delete_intake || return 0 ;;
@@ -7463,6 +7561,8 @@ verb_control() {
7463
7561
  esac
7464
7562
  fi
7465
7563
 
7564
+ [ "$needs_agent" -eq 0 ] || control_needs_agent
7565
+
7466
7566
  control_tmp="${RUNNER_TEMP-}"
7467
7567
  if [ -z "$control_tmp" ] || [ ! -d "$control_tmp" ]; then
7468
7568
  control_tmp=$(mktemp -d) || { echo "remote-run.sh: control: mktemp failed" >&2; exit "$EXIT_USAGE"; }
@@ -7473,29 +7573,7 @@ verb_control() {
7473
7573
  # Decided by the event name, never CONTROL_VERB: a comment can name `close`.
7474
7574
  [ "$close" -eq 0 ] || control_close
7475
7575
 
7476
- if [ -n "${HARNESS_REMOTE_STOP-}" ]; then
7477
- control_refuse "$EXIT_REFUSED" "the repository variable \`HARNESS_REMOTE_STOP\` is set, which stops every command" \
7478
- "Clear it under **Settings → Secrets and variables → Actions → Variables**, then comment again."
7479
- fi
7480
-
7481
- forge=$(hr_forge "$root") || forge=""
7482
- target=$(hr_execution_target "$root") || target=""
7483
- if [ "$forge" != github ] || [ "$target" != github-actions ]; then
7484
- control_refuse "$EXIT_REFUSED" "the default branch's \`harness.config.json\` does not turn run control on: it needs \`forge\` set to \`github\` (it is ${forge:-not set or unreadable}) and \`execution.target\` set to \`github-actions\` (it is ${target:-unreadable})" \
7485
- "Set both keys on the default branch, then comment again."
7486
- fi
7487
-
7488
- if ! rerun_actor_listed; then
7489
- control_refuse "$EXIT_REFUSED" "${RUN_ACTORS_WHY%.}" \
7490
- "Only a person the repository variable \`HARNESS_RUN_ACTORS\` admits may re-run this job; one of them can comment again."
7491
- fi
7492
-
7493
- status=0
7494
- authorise_actor "$CONTROL_ACTOR" "$CONTROL_SENDER_TYPE" || status=$?
7495
- if [ "$status" -ne 0 ]; then
7496
- control_refuse "$EXIT_REFUSED" "${AUTH_WHY%.}" \
7497
- "Only a collaborator with write, maintain or admin access whom the repository variable \`HARNESS_RUN_ACTORS\` admits (when unset, the owner alone of a repository a personal account owns, and nobody in an organisation-owned one), or a bot listed in \`HARNESS_TRIGGER_ALLOWED_BOTS\`, commands a run."
7498
- fi
7576
+ control_gates || control_refuse "$CG_EXIT" "$CG_WHY" "$CG_WAY"
7499
7577
 
7500
7578
  if [ "$review" -eq 0 ] && [ "$CONTROL_MENTION" != 1 ] \
7501
7579
  && { [ -z "$CONTROL_VERB" ] || ! control_verb_handled "$CONTROL_VERB"; }; then
@@ -1,6 +1,6 @@
1
1
  # autonomous_inbox/
2
2
 
3
- The unattended run loop's drop point: one file per run request, and its **name** is the whole request — it selects the engine command, the working-copy strategy and where the dropped file lands, all three at once. `<branch>_task_prompt.md` starts a delivery run in a fresh working copy and lands the prompt in `<state_dir>/task_prompts/`; `<branch>_review[_<n>].md` starts a fix round for a hands-on review in that branch's existing working copy and lands the review in `<state_dir>/user_reviews/` with its round suffix intact; `<branch>_docs.md` starts a documentation run and lands the checklist in `<state_dir>/docs_catalog/`. The three patterns are anchored suffixes and mutually exclusive, so a branch whose own name contains `review` or `task_prompt` is not mis-routed, and only the branch is read out of the name — never the round, which the engine resolves inside the working copy.
3
+ The unattended run loop's drop point: one file per run request, and its **name** is the whole request — it selects the engine command, the working-copy strategy and where the dropped file lands, all three at once. `<branch>_task_prompt.md` starts a delivery run in a fresh working copy and lands the prompt in `<state_dir>/task_prompts/`; `<branch>_review[_<n>].md` starts a fix round for a hands-on review in that branch's existing working copy and lands the review in `<state_dir>/user_reviews/` with its round suffix intact; `<branch>_docs.md` starts a documentation run and lands the checklist in `<state_dir>/docs_catalog/`. The three patterns are anchored suffixes and mutually exclusive, so a branch whose own name contains `review` or `task_prompt` is not misrouted, and only the branch is read out of the name — never the round, which the engine resolves inside the working copy.
4
4
 
5
5
  A drop is written by the harness's prompt command, by its hands-on-review command, or by hand for a documentation checklist, which no shipped command writes. The only reader is the run daemon, on its next poll pass: there is no queue server and no webhook in between. Once it has consumed a file the daemon moves it aside into `.processed/` under a timestamp; a file matching **none** of the three patterns is archived into the same place under a `rejected_` prefix, with one log line and no registry record. A drop the daemon defers instead — the kill switch is up, the run cap is full, the usage hold is on — is left exactly where it is, so a later pass picks it up with nothing to re-drop by hand.
6
6