autonomous-sdlc-harness 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (171) hide show
  1. package/LICENSE +201 -0
  2. package/NOTICE +7 -0
  3. package/README.md +24 -0
  4. package/dist/cli.js +194 -0
  5. package/dist/cli.js.map +1 -0
  6. package/dist/commands/config.js +561 -0
  7. package/dist/commands/config.js.map +1 -0
  8. package/dist/commands/daemon.js +791 -0
  9. package/dist/commands/daemon.js.map +1 -0
  10. package/dist/commands/doctor.js +336 -0
  11. package/dist/commands/doctor.js.map +1 -0
  12. package/dist/commands/init.js +2023 -0
  13. package/dist/commands/init.js.map +1 -0
  14. package/dist/commands/registry.js +42 -0
  15. package/dist/commands/registry.js.map +1 -0
  16. package/dist/config/check.js +505 -0
  17. package/dist/config/check.js.map +1 -0
  18. package/dist/config/io.js +177 -0
  19. package/dist/config/io.js.map +1 -0
  20. package/dist/config/model.js +406 -0
  21. package/dist/config/model.js.map +1 -0
  22. package/dist/core/errors.js +71 -0
  23. package/dist/core/errors.js.map +1 -0
  24. package/dist/core/git.js +537 -0
  25. package/dist/core/git.js.map +1 -0
  26. package/dist/core/json.js +125 -0
  27. package/dist/core/json.js.map +1 -0
  28. package/dist/core/layerCoverage.js +141 -0
  29. package/dist/core/layerCoverage.js.map +1 -0
  30. package/dist/core/layerGapRemedy.js +62 -0
  31. package/dist/core/layerGapRemedy.js.map +1 -0
  32. package/dist/core/nameList.js +23 -0
  33. package/dist/core/nameList.js.map +1 -0
  34. package/dist/core/paths.js +153 -0
  35. package/dist/core/paths.js.map +1 -0
  36. package/dist/core/prompt.js +206 -0
  37. package/dist/core/prompt.js.map +1 -0
  38. package/dist/core/repoPaths.js +55 -0
  39. package/dist/core/repoPaths.js.map +1 -0
  40. package/dist/core/report.js +150 -0
  41. package/dist/core/report.js.map +1 -0
  42. package/dist/core/templating.js +88 -0
  43. package/dist/core/templating.js.map +1 -0
  44. package/dist/core/writer.js +479 -0
  45. package/dist/core/writer.js.map +1 -0
  46. package/dist/daemon/backend.js +180 -0
  47. package/dist/daemon/backend.js.map +1 -0
  48. package/dist/daemon/units.js +380 -0
  49. package/dist/daemon/units.js.map +1 -0
  50. package/dist/detect/nestedApplication.js +79 -0
  51. package/dist/detect/nestedApplication.js.map +1 -0
  52. package/dist/detect/presets.js +2033 -0
  53. package/dist/detect/presets.js.map +1 -0
  54. package/dist/detect/signals.js +1368 -0
  55. package/dist/detect/signals.js.map +1 -0
  56. package/dist/doctor/checks.js +3530 -0
  57. package/dist/doctor/checks.js.map +1 -0
  58. package/dist/generators/claudeContext.js +588 -0
  59. package/dist/generators/claudeContext.js.map +1 -0
  60. package/dist/generators/githooks.js +446 -0
  61. package/dist/generators/githooks.js.map +1 -0
  62. package/dist/generators/harnessConfig.js +632 -0
  63. package/dist/generators/harnessConfig.js.map +1 -0
  64. package/dist/generators/notifications.js +191 -0
  65. package/dist/generators/notifications.js.map +1 -0
  66. package/dist/generators/outerLoopScripts.js +165 -0
  67. package/dist/generators/outerLoopScripts.js.map +1 -0
  68. package/dist/generators/permissionProfile.js +1172 -0
  69. package/dist/generators/permissionProfile.js.map +1 -0
  70. package/dist/generators/projectSettings.js +322 -0
  71. package/dist/generators/projectSettings.js.map +1 -0
  72. package/dist/generators/repoRoot.js +417 -0
  73. package/dist/generators/repoRoot.js.map +1 -0
  74. package/dist/generators/scripts.js +557 -0
  75. package/dist/generators/scripts.js.map +1 -0
  76. package/dist/generators/stateDir.js +221 -0
  77. package/dist/generators/stateDir.js.map +1 -0
  78. package/dist/machine/paths.js +111 -0
  79. package/dist/machine/paths.js.map +1 -0
  80. package/dist/machine/plugins.js +224 -0
  81. package/dist/machine/plugins.js.map +1 -0
  82. package/dist/machine/registry.js +330 -0
  83. package/dist/machine/registry.js.map +1 -0
  84. package/package.json +23 -0
  85. package/scripts/README.md +13 -0
  86. package/scripts/daemon/launchd.plist.template +59 -0
  87. package/scripts/daemon/systemd.service.template +58 -0
  88. package/templates/README.md +15 -0
  89. package/templates/claude/CLAUDE.md +54 -0
  90. package/templates/claude/README.md +5 -0
  91. package/templates/claude/context/api.md +29 -0
  92. package/templates/claude/context/conventions.md +23 -0
  93. package/templates/claude/context/data-layer.md +28 -0
  94. package/templates/claude/context/data-storage.md +29 -0
  95. package/templates/claude/context/docs-catalog.md +29 -0
  96. package/templates/claude/context/domain.md +28 -0
  97. package/templates/claude/context/layer.md +20 -0
  98. package/templates/claude/context/module.md +30 -0
  99. package/templates/claude/context/package.md +29 -0
  100. package/templates/claude/context/presentation.md +32 -0
  101. package/templates/claude/context/state-slices.md +28 -0
  102. package/templates/claude/context/tests.md +28 -0
  103. package/templates/claude/harness-task-offer.md +58 -0
  104. package/templates/claude/push-notify.env.example +21 -0
  105. package/templates/claude/qa-accounts.env.example +38 -0
  106. package/templates/claude/qa_test_scenarios.md +110 -0
  107. package/templates/claude/settings.autonomous.json +93 -0
  108. package/templates/claude/settings.autonomous.qa.json +36 -0
  109. package/templates/githooks/README.md +3 -0
  110. package/templates/githooks/pre-push +72 -0
  111. package/templates/repo/README.md +3 -0
  112. package/templates/repo/gitattributes +16 -0
  113. package/templates/repo/gitignore +61 -0
  114. package/templates/repo/gitignore.qa +25 -0
  115. package/templates/repo/mcp.json +17 -0
  116. package/templates/scripts/README.md +5 -0
  117. package/templates/scripts/autonomous-format-stream.sh +95 -0
  118. package/templates/scripts/autonomous-notify.sh +337 -0
  119. package/templates/scripts/autonomous-watcher.sh +3087 -0
  120. package/templates/scripts/cleanup-merged-worktrees.sh +327 -0
  121. package/templates/scripts/commit-on-branch.sh +288 -0
  122. package/templates/scripts/create-worktree.sh +360 -0
  123. package/templates/scripts/deploy.sh +47 -0
  124. package/templates/scripts/lib/harness-run-lib.sh +1481 -0
  125. package/templates/scripts/push-branch.sh +140 -0
  126. package/templates/scripts/refresh-branch.sh +244 -0
  127. package/templates/scripts/restart-watcher.sh +401 -0
  128. package/templates/scripts/scratch-run.sh +302 -0
  129. package/templates/scripts/setup-worktree.sh +262 -0
  130. package/templates/scripts/start-dev-server.sh +99 -0
  131. package/templates/scripts/test.sh +50 -0
  132. package/templates/scripts/typecheck.sh +50 -0
  133. package/templates/state-dir/README-root.md +13 -0
  134. package/templates/state-dir/README.md +9 -0
  135. package/templates/state-dir/architecture_branch_review_point_reviews/README.md +9 -0
  136. package/templates/state-dir/architecture_branch_reviews/README.md +9 -0
  137. package/templates/state-dir/architecture_reviews/README.md +9 -0
  138. package/templates/state-dir/architecture_user_review_reviews/README.md +9 -0
  139. package/templates/state-dir/autonomous_inbox/README.md +9 -0
  140. package/templates/state-dir/autonomous_logs/README.md +9 -0
  141. package/templates/state-dir/branch_statistics/README.md +9 -0
  142. package/templates/state-dir/business_parity_branch_review_point_reviews/README.md +9 -0
  143. package/templates/state-dir/business_parity_branch_reviews/README.md +9 -0
  144. package/templates/state-dir/business_parity_reviews/README.md +9 -0
  145. package/templates/state-dir/business_parity_user_review_reviews/README.md +9 -0
  146. package/templates/state-dir/clarification_digests/README.md +9 -0
  147. package/templates/state-dir/clarifications/README.md +9 -0
  148. package/templates/state-dir/code_reviews/README.md +9 -0
  149. package/templates/state-dir/dispatch_additions/README.md +19 -0
  150. package/templates/state-dir/docs_catalog/README.md +9 -0
  151. package/templates/state-dir/flow_progress/README.md +9 -0
  152. package/templates/state-dir/improvement_observations/README.md +19 -0
  153. package/templates/state-dir/improvement_suggestions.md +29 -0
  154. package/templates/state-dir/lessons.md +23 -0
  155. package/templates/state-dir/qa_review_point_reviews/README.md +9 -0
  156. package/templates/state-dir/qa_reviews/README.md +9 -0
  157. package/templates/state-dir/review_plan_point_reviews/README.md +9 -0
  158. package/templates/state-dir/review_plan_reviews/README.md +9 -0
  159. package/templates/state-dir/scratch/README.md +11 -0
  160. package/templates/state-dir/skeptic_review_plan_reviews/README.md +9 -0
  161. package/templates/state-dir/skeptic_review_point_reviews/README.md +9 -0
  162. package/templates/state-dir/skeptic_reviews/README.md +9 -0
  163. package/templates/state-dir/story_plans/README.md +9 -0
  164. package/templates/state-dir/task_plan_point_reviews/README.md +9 -0
  165. package/templates/state-dir/task_plan_reviews/README.md +9 -0
  166. package/templates/state-dir/task_plans/README.md +9 -0
  167. package/templates/state-dir/task_prompts/README.md +9 -0
  168. package/templates/state-dir/ui_test_plan_reviews/README.md +9 -0
  169. package/templates/state-dir/ui_test_plans/README.md +9 -0
  170. package/templates/state-dir/user_review_fix_plan_point_reviews/README.md +9 -0
  171. package/templates/state-dir/user_reviews/README.md +9 -0
@@ -0,0 +1,327 @@
1
+ #!/usr/bin/env bash
2
+ # cleanup-merged-worktrees.sh — housekeeping: remove the sibling working copy and
3
+ # the local branch of a run whose pull request was merged and whose remote branch
4
+ # was then deleted.
5
+ #
6
+ # THE TRIGGER IS AN UPSTREAM THAT IS GONE. After `git fetch --prune`, git marks a
7
+ # local branch whose remote counterpart no longer exists as `[gone]` — visible as
8
+ # `git for-each-ref --format='%(refname:short) %(upstream:track)' refs/heads/`.
9
+ # That is exactly the merge-the-pull-request-then-delete-the-remote-branch flow,
10
+ # so it is the signal this sweep acts on, and it is the only one. For each such
11
+ # branch it
12
+ #
13
+ # 1. removes the working copy checked out on it (`git worktree remove --force`;
14
+ # a failure is reported and the sweep continues, because a directory left
15
+ # behind is a smaller problem than a sweep that stops half-way through the
16
+ # set), then
17
+ # 2. force-deletes the local branch (`git branch -D`),
18
+ #
19
+ # and then runs `git worktree prune` to drop the administrative records of
20
+ # working copies whose directories are already gone.
21
+ #
22
+ # WHERE IT RUNS. In the MAIN checkout, always — that is where every working copy
23
+ # and every branch of this repository is administered, and where the run registry
24
+ # and the configuration live. The script derives that anchor rather than assuming
25
+ # it, so it behaves identically whether it is started from the main checkout or
26
+ # from a sibling working copy that is about to be swept away.
27
+ #
28
+ # THE THREE REFUSALS. It never touches
29
+ #
30
+ # * a PROTECTED branch — the set resolved at run time from this repository's
31
+ # `harness.config.json` (`protectedBranches`, or that key's schema default,
32
+ # unioned with `defaultBranch`), through lib/harness-run-lib.sh. There is no
33
+ # list of branch names in this file: one written here would delete branches
34
+ # an adopter had protected, the moment their configuration named anything
35
+ # else;
36
+ # * the branch the MAIN checkout itself has checked out, nor the main
37
+ # checkout's own directory;
38
+ # * a branch with an ACTIVE RUN — one whose record in the run registry
39
+ # (`<state_dir>/autonomous_logs/registry.json`, shaped
40
+ # `{"runs": {"<branch>": {"status": …}}}`) says `running`, `parked` or
41
+ # `paused`. All three own a working copy the watcher will come back to: a
42
+ # parked run is waiting for a clarification answer and a paused one for a
43
+ # RESUME sentinel, and `worktree remove --force` would discard the pause
44
+ # note the resumed engine is pointed at.
45
+ #
46
+ # CAVEAT: `: gone` + force-delete also catches an ABANDONED branch whose remote
47
+ # was deleted WITHOUT merging — its unmerged local commits would be lost. For a
48
+ # strict merge-then-delete-remote flow this is a non-issue. Use --dry-run to
49
+ # preview.
50
+ #
51
+ # `--dry-run` IS THE PREVIEW, AND IT DELETES NOTHING. It reports the branch (and
52
+ # the working copy) each decision would remove; no branch is deleted, no working
53
+ # copy removed, and it does not even `worktree prune`. It is NOT read-only: the
54
+ # `fetch --prune` that establishes which upstreams are gone runs first either
55
+ # way, so remote-tracking refs for deleted upstreams are removed and FETCH_HEAD
56
+ # is written. Preview the decisions, not the repository's ref state.
57
+ #
58
+ # FAIL CLOSED, AND HERE THAT MEANS DELETING NOTHING. This is the most destructive
59
+ # script in the set — `git branch -D` and `git worktree remove --force` are not
60
+ # recoverable from this side — so every input it cannot resolve ends the run
61
+ # instead of being worked around:
62
+ #
63
+ # * the shared library, or `<repo_root>/harness.config.json`, cannot be read
64
+ # (absent, unreadable, invalid JSON, more than one document, no
65
+ # `defaultBranch`, or `jq` missing or older than 1.5) — it cannot prove any
66
+ # branch is unprotected;
67
+ # * the registry file is present and cannot be read as a registry (unreadable,
68
+ # invalid JSON, or no `.runs` wrapper) — it cannot prove any branch is idle.
69
+ # A registry that is simply ABSENT is a different answer and not an error: a
70
+ # repository the watcher has never run in has no registry, and no active run
71
+ # either. But a registry that is there in a shape this cannot enumerate is
72
+ # refused rather than read as "nothing is running", because that misreading
73
+ # is a deleted branch with a run still writing to it;
74
+ # * the protected-branch answer for a specific branch comes back unresolvable
75
+ # mid-sweep.
76
+ #
77
+ # Each of those prints one line and exits 0 having deleted nothing. Exit 0
78
+ # because this sweep is housekeeping invoked from a loop that must keep running,
79
+ # and a refusal is a successful refusal.
80
+ #
81
+ # WHO RUNS IT. The watcher, on its own schedule, and a person by hand. NEVER a
82
+ # dispatched agent: the script-allowlist guard withholds the permit rather than
83
+ # granting one (its basename is on that guard's deny list, and being on it means
84
+ # the guard stays silent), and the generated permission profile emits no rule for
85
+ # this script either — so an agent that tries to run it gets a prompt it cannot
86
+ # answer, never a permit. Nothing about that is negotiable — an agent asked to
87
+ # change one file has no business deleting branches.
88
+ #
89
+ # Usage: cleanup-merged-worktrees.sh [--dry-run]
90
+ # --dry-run report what would be removed; remove nothing
91
+ #
92
+ # Exit map a caller can switch on:
93
+ #
94
+ # 0 the sweep ran, or refused and deleted nothing (the message says which)
95
+ # 1 usage error — an unrecognized or extra argument. Nothing was read and
96
+ # nothing was deleted; in particular a misspelled `--dry-run` is refused
97
+ # here rather than silently becoming a real sweep
98
+ #
99
+ # REPRO — reproduce any decision by hand, against a throwaway fixture:
100
+ #
101
+ # w=$(mktemp -d); b="$w/origin.git"; git init -q --bare "$b"
102
+ # d="$w/demo"; git init -q -b trunk "$d"; git -C "$d" remote add origin "$b"
103
+ # printf '%s' '{"version":1,"projectName":"demo","defaultBranch":"trunk","protectedBranches":["trunk","release/*"],"stateDir":"sdlc-harness/","layers":[],"commands":{}}' > "$d/harness.config.json"
104
+ # mkdir -p "$d/scripts/lib" # copy this script + lib/ there
105
+ # git -C "$d" add -A && git -C "$d" commit -qm seed
106
+ # git -C "$d" push -q -u origin trunk
107
+ # for r in feat_gone feat_live release/1.0; do git -C "$d" branch "$r"; \
108
+ # git -C "$d" push -q -u origin "$r"; done
109
+ # git -C "$d" worktree add -q "$w/demo-feat_gone" feat_gone
110
+ # git -C "$b" branch -q -D feat_gone; git -C "$b" branch -q -D release/1.0
111
+ #
112
+ # preview bash "$d/scripts/cleanup-merged-worktrees.sh" --dry-run
113
+ # -> names feat_gone and its working copy; `git -C "$d" branch`
114
+ # is unchanged and "$w/demo-feat_gone" is still there
115
+ # sweep bash "$d/scripts/cleanup-merged-worktrees.sh"
116
+ # -> "$w/demo-feat_gone" removed and feat_gone deleted;
117
+ # feat_live untouched (its upstream is not gone) and
118
+ # release/1.0 untouched (protected, though it IS gone)
119
+ # active run mkdir -p "$d/sdlc-harness/autonomous_logs"
120
+ # printf '%s' '{"runs":{"feat_gone":{"status":"running"}}}' \
121
+ # > "$d/sdlc-harness/autonomous_logs/registry.json"
122
+ # -> the skip line for feat_gone; nothing deleted (same with
123
+ # "parked" and "paused")
124
+ # unresolvable printf 'x' > "$d/harness.config.json"
125
+ # -> one line, exit 0, nothing deleted
126
+ # offline git -C "$d" remote set-url origin /nonexistent.git
127
+ # -> the fetch message, exit 0, nothing deleted
128
+
129
+ set -uo pipefail
130
+
131
+ # The library is reached by a path computed from this script's own location — no
132
+ # session root and no runtime-substituted token is assumed. Not finding it is a
133
+ # refusal: without it there is no protected set to check a branch against.
134
+ hr_lib="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/lib/harness-run-lib.sh"
135
+ if [ ! -r "$hr_lib" ]; then
136
+ echo "cleanup-merged-worktrees.sh: cannot read '$hr_lib' — refusing to delete anything"
137
+ exit 0
138
+ fi
139
+ # shellcheck source=lib/harness-run-lib.sh
140
+ . "$hr_lib"
141
+
142
+ dry_run=0
143
+ case "${1:-}" in
144
+ --dry-run)
145
+ dry_run=1
146
+ shift
147
+ ;;
148
+ '') ;;
149
+ *)
150
+ echo "cleanup-merged-worktrees.sh: unrecognized argument '$1'" >&2
151
+ echo " usage: cleanup-merged-worktrees.sh [--dry-run]" >&2
152
+ exit 1
153
+ ;;
154
+ esac
155
+ if [ "$#" -gt 0 ]; then
156
+ echo "cleanup-merged-worktrees.sh: unexpected extra arguments" >&2
157
+ echo " usage: cleanup-merged-worktrees.sh [--dry-run]" >&2
158
+ exit 1
159
+ fi
160
+
161
+ # --- Anchors. Derived, never remembered: this copy of the script may be sitting
162
+ # in the main checkout or in a sibling working copy of the same repository.
163
+ script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
164
+ top="$(hr_repo_root "$script_dir")" || top=""
165
+ if [ -z "$top" ]; then
166
+ echo "cleanup-merged-worktrees.sh: '$script_dir' is not inside a git repository — refusing to delete anything"
167
+ exit 0
168
+ fi
169
+
170
+ # The sweep administers working copies and branches, so it runs in the MAIN
171
+ # checkout. Unlike its siblings this one does NOT fall back to the checkout it
172
+ # was started from: if the main-checkout probe does not answer, the working-copy
173
+ # enumeration below cannot be trusted either, and the closed outcome is to stop.
174
+ main_repo="$(hr_main_repo "$top")" || main_repo=""
175
+ if [ -z "$main_repo" ]; then
176
+ echo "cleanup-merged-worktrees.sh: could not determine the main checkout of '$top' — refusing to delete anything"
177
+ exit 0
178
+ fi
179
+
180
+ # Warm the library's per-process cache once, unsubstituted, and make the
181
+ # fail-closed decision here rather than letting each reader fail separately.
182
+ cfg_status=0
183
+ hr_config_load "$main_repo" || cfg_status=$?
184
+ if [ "$cfg_status" -ne 0 ]; then
185
+ echo "cleanup-merged-worktrees.sh: cannot resolve '$main_repo/harness.config.json' — refusing to delete anything"
186
+ echo " (no configuration there, invalid JSON, more than one document, no defaultBranch, or jq missing/older than 1.5)"
187
+ exit 0
188
+ fi
189
+
190
+ # --- The active-run set. A HARD PRECONDITION, not a best-effort read: the
191
+ # question it answers is whether a branch about to be force-deleted has a run
192
+ # writing to it.
193
+ registry="$(hr_state_path "$main_repo" autonomous_logs/registry.json)" || registry=""
194
+ if [ -z "$registry" ]; then
195
+ echo "cleanup-merged-worktrees.sh: could not derive the run registry path — refusing to delete anything"
196
+ exit 0
197
+ fi
198
+
199
+ active=""
200
+ if [ -e "$registry" ]; then
201
+ if [ ! -r "$registry" ]; then
202
+ echo "cleanup-merged-worktrees.sh: '$registry' is not readable — refusing to delete anything"
203
+ exit 0
204
+ fi
205
+ # Enumerated through the `.runs` wrapper the whole flow writes and reads. A
206
+ # document without it is a registry this cannot enumerate, so `jq` fails and
207
+ # the refusal below fires — which is the point: reading such a file as "no
208
+ # active runs" would delete a branch a run is still working on.
209
+ if ! active="$(jq -r '.runs | to_entries[] | select(.value.status == "running" or .value.status == "parked" or .value.status == "paused") | .key' "$registry" 2>/dev/null)"; then
210
+ echo "cleanup-merged-worktrees.sh: '$registry' could not be read as a run registry — refusing to delete anything"
211
+ exit 0
212
+ fi
213
+ fi
214
+
215
+ # Refresh remote-tracking refs so a deleted upstream becomes `[gone]`. A failed
216
+ # fetch (offline, or an unreachable remote) skips this round rather than sweeping
217
+ # on stale tracking information, which would report every branch as gone.
218
+ if ! git -C "$main_repo" fetch --prune --quiet 2>/dev/null; then
219
+ echo "cleanup-merged-worktrees.sh: fetch --prune failed (offline?); skipping this round"
220
+ exit 0
221
+ fi
222
+
223
+ current_branch="$(hr_current_branch "$main_repo")"
224
+
225
+ # The working copy checked out on <branch>, from the porcelain listing. Parsed
226
+ # line-wise rather than by whitespace fields, because a working copy's path may
227
+ # contain spaces. Prints nothing when there is none.
228
+ worktree_for_branch() {
229
+ local branch="${1-}" list line current=""
230
+ [ -n "$branch" ] || return 0
231
+ list="$(git -C "$main_repo" worktree list --porcelain 2>/dev/null)" || return 0
232
+ while IFS= read -r line; do
233
+ case "$line" in
234
+ "worktree "*) current="${line#worktree }" ;;
235
+ "branch refs/heads/$branch")
236
+ printf '%s\n' "$current"
237
+ return 0
238
+ ;;
239
+ esac
240
+ done <<EOF
241
+ $list
242
+ EOF
243
+ return 0
244
+ }
245
+
246
+ # One record per local branch as `<name><TAB><upstream track>`; the tab keeps the
247
+ # two apart without splitting on whitespace.
248
+ gone_list="$(git -C "$main_repo" for-each-ref --format='%(refname:short)%09%(upstream:track)' refs/heads/ 2>/dev/null)" || gone_list=""
249
+
250
+ nl="
251
+ "
252
+ tab=$'\t'
253
+ cleaned=0
254
+
255
+ # Fed by a here-document rather than a pipeline, so `cleaned` is counted in THIS
256
+ # shell — a `|` would run the loop in a subshell and the total would always be 0.
257
+ while IFS= read -r record; do
258
+ [ -n "$record" ] || continue
259
+ branch="${record%%"$tab"*}"
260
+ track="${record#*"$tab"}"
261
+ [ -n "$branch" ] || continue
262
+ [ "$track" = "[gone]" ] || continue
263
+
264
+ hr_branch_is_protected "$main_repo" "$branch"
265
+ protected=$?
266
+ case "$protected" in
267
+ 0) continue ;;
268
+ 2)
269
+ # The configuration resolved a moment ago, so this is it changing under a
270
+ # running sweep. Stop; do not fall through to a delete.
271
+ echo "cleanup-merged-worktrees.sh: cannot tell whether '$branch' is protected — stopping; nothing further deleted"
272
+ exit 0
273
+ ;;
274
+ esac
275
+
276
+ if [ "$branch" = "$current_branch" ]; then
277
+ continue
278
+ fi
279
+
280
+ # Newline-delimited membership: the registry enumeration returns one branch per
281
+ # line, so both the haystack and the needle are wrapped in newlines. Matching
282
+ # on spaces instead would only ever recognize the first and last entry.
283
+ case "$nl$active$nl" in
284
+ *"$nl$branch$nl"*)
285
+ echo "cleanup-merged-worktrees.sh: skip '$branch' (active run)"
286
+ continue
287
+ ;;
288
+ esac
289
+
290
+ wt="$(worktree_for_branch "$branch")"
291
+
292
+ if [ "$dry_run" -eq 1 ]; then
293
+ if [ -n "$wt" ]; then
294
+ echo "cleanup-merged-worktrees.sh: [dry-run] would delete branch '$branch' and remove worktree $wt"
295
+ else
296
+ echo "cleanup-merged-worktrees.sh: [dry-run] would delete branch '$branch'"
297
+ fi
298
+ continue
299
+ fi
300
+
301
+ if [ -n "$wt" ] && [ "$wt" != "$main_repo" ]; then
302
+ # git's own message is captured and reported rather than printed raw, so a
303
+ # failure names the directory a person now has to remove by hand.
304
+ if err="$(git -C "$main_repo" worktree remove --force "$wt" 2>&1 >/dev/null)"; then
305
+ echo "cleanup-merged-worktrees.sh: removed worktree $wt"
306
+ else
307
+ echo "cleanup-merged-worktrees.sh: FAILED to remove worktree $wt: ${err:-no error output} — the directory may be left behind; remove it by hand"
308
+ fi
309
+ fi
310
+
311
+ if git -C "$main_repo" branch -D "$branch" >/dev/null 2>&1; then
312
+ echo "cleanup-merged-worktrees.sh: deleted merged branch '$branch'"
313
+ cleaned=$((cleaned + 1))
314
+ else
315
+ echo "cleanup-merged-worktrees.sh: could not delete branch '$branch' — left in place"
316
+ fi
317
+ done <<EOF
318
+ $gone_list
319
+ EOF
320
+
321
+ if [ "$dry_run" -eq 0 ]; then
322
+ git -C "$main_repo" worktree prune 2>/dev/null || true
323
+ if [ "$cleaned" -gt 0 ]; then
324
+ echo "cleanup-merged-worktrees.sh: $cleaned merged branch(es) removed"
325
+ fi
326
+ fi
327
+ exit 0
@@ -0,0 +1,288 @@
1
+ #!/usr/bin/env bash
2
+ # commit-on-branch.sh — the deterministic commit wrapper every unattended commit
3
+ # point calls: stage exactly the paths it was given, refuse a branch this
4
+ # repository's own configuration protects, and commit with the message supplied
5
+ # as ordinary positional arguments.
6
+ #
7
+ # WHY A WRAPPER EXISTS AT ALL. An unattended run cannot answer a permission
8
+ # prompt, so a command that draws one is a silent stall rather than a refusal.
9
+ # `git commit -m "$(…)"` draws one twice over: a command carrying a shell
10
+ # expansion (`$VAR` / `${…}` / `$(…)`) cannot be matched statically, so it
11
+ # reaches the commit branch-guard (`plugin/hooks/git-commit-branch-guard.sh`),
12
+ # which is fail-closed and answers `ask` for anything it could not read — and a
13
+ # `$(…)` in the message defeats a literal allow entry as well. An invocation of
14
+ # a SCRIPT PATH has neither problem: `bash <scripts_dir>/commit-on-branch.sh …`
15
+ # is allow-listed in three literal forms by the generated permission profile,
16
+ # is auto-allowed by the script-allowlist guard (a script resolving under the
17
+ # configured scripts directory whose basename is not on that guard's deny list,
18
+ # in a command carrying none of `$(…)`, a backtick, `|`, `<`, a braced form
19
+ # other than a bare `${IDENT}`, or a `>` that is neither a descriptor
20
+ # duplication (`2>&1`, `>&2`, `2>&-`) nor a redirection to the literal
21
+ # `/dev/null` — which is what the rule below enforces),
22
+ # and is never matched by the commit guard's parser — even when the path or the
23
+ # arguments carry expansions. So the caller's command line carries NO heredoc
24
+ # and NO `$(…)`: the subject and every body paragraph are ordinary positional
25
+ # arguments and this script builds the commit from repeated `-m` flags itself.
26
+ #
27
+ # SAFETY-CRITICAL CONSEQUENCE. Not reaching the commit branch-guard also means
28
+ # not reaching its protected-branch refusal, so this script MUST make that
29
+ # decision itself — and it makes it from `<repo_root>/harness.config.json` at
30
+ # run time, through `lib/harness-run-lib.sh`, never from a list of branch names
31
+ # written into this file. A remembered list enforces the wrong set the moment
32
+ # `protectedBranches` changes, and enforces nothing at all in a repository whose
33
+ # integration branch is named something else. The library answers three states —
34
+ # protected, not protected, unresolvable — and all three are closed here.
35
+ #
36
+ # LOUD-FAILURE CONTRACT (the KEY difference from push-branch.sh). push-branch.sh
37
+ # makes every failure non-fatal because a stale remote is tolerable. A commit
38
+ # caller cannot tolerate that ambiguity: if a commit did NOT land, the caller
39
+ # must NOT proceed as if it did. A non-git directory, a detached HEAD, a
40
+ # configuration that cannot be resolved, a protected-branch refusal and a
41
+ # git-level hook failure are therefore all non-zero exits. Exit-code map a
42
+ # caller can switch on:
43
+ #
44
+ # 0 commit landed (the new short SHA is printed to stdout)
45
+ # 1 usage error / non-git directory / detached HEAD / the configuration could
46
+ # not be resolved / `git commit` failed (a hook, or any commit-time check)
47
+ # 2 protected-branch refusal (no staging, no commit)
48
+ # 3 nothing to commit (the paths staged cleanly and produced no staged
49
+ # change) — a DISTINCT, non-crashing status so a caller can tell "nothing
50
+ # staged" apart from a protected-branch refusal or a hook failure
51
+ #
52
+ # WHAT IT NEVER DOES. It never pushes (that is push-branch.sh, called as a
53
+ # SEPARATE statement — an `if …; then push; fi` block is a compound the guards
54
+ # do not cover and stalls an unattended run). It never flips a plan checkbox:
55
+ # callers own that. It never stages anything it was not given — no `git add -A`,
56
+ # no `git add .`. It never passes `--no-verify` or any other hook-skip flag, and
57
+ # it never retries a failed commit.
58
+ #
59
+ # STAGING POLICY, AND THE ONE TRAP IN IT. Only the explicit paths passed before
60
+ # `--` are staged, one `git add -- <path>` each. A path that does not exist is
61
+ # skip-with-warning rather than a hard error, and that tolerance is what lets
62
+ # callers pass their paths UNCONDITIONALLY: an optional-but-absent one (an empty
63
+ # per-finding folder, say) must not fail the commit, and a caller-side
64
+ # `if [ -d … ]; then` existence check would reintroduce the stall described
65
+ # under WHY A WRAPPER EXISTS AT ALL — a shell conditional cannot be matched
66
+ # statically, so it reaches the fail-closed guards and an unattended run cannot
67
+ # answer the prompt it draws. Callers were deliberately stripped of those
68
+ # checks; do not restore one. THE TRAP: a path that was DELETED does not
69
+ # exist either, so passing the exact deleted path stages nothing and the
70
+ # deletion is silently left out of the commit. Pass the PARENT DIRECTORY of a
71
+ # deletion instead — `git add -- <dir>` stages the removal. This is source
72
+ # behaviour carried across deliberately rather than fixed: the existence check
73
+ # is what keeps an absent optional path from failing a commit, and it is checked
74
+ # against `<repo_top>/<path>` — the same base `git -C <top> add` resolves
75
+ # against — so a path is staged if and only if it is the one git would resolve.
76
+ #
77
+ # MESSAGE ARGUMENTS, AND THE BYTES ONE MAY NOT CARRY. The subject and every body
78
+ # line arrive as ordinary shell arguments, so none of them may carry `$(…)`, a
79
+ # backtick, `|`, `>`, `<`, or a braced expansion other than a bare `${IDENT}`.
80
+ # Two independent reasons, either one fatal on its own. THE SHELL: inside the
81
+ # caller's double quotes `$(…)` and a backtick are substituted before this
82
+ # script is reached, and any `$` spelling is expanded — so a `$`, a backtick or
83
+ # a placeholder meant as TEXT is not text by the time it lands. THE GUARD: the
84
+ # script-allowlist guard (`autonomous-script-allowlist-guard.sh`, shipped with the
85
+ # plugin)
86
+ # scans the WHOLE command string for `$(…)`, a backtick, `|`, `<`, a braced form
87
+ # other than a bare `${IDENT}`, and a `>` that is neither a descriptor
88
+ # duplication (`2>&1`, `>&2`, `2>&-`) nor a redirection to the literal
89
+ # `/dev/null`, and silences the invocation on a match, which is the permission
90
+ # prompt described under WHY A WRAPPER EXISTS AT ALL and, unattended, a stall.
91
+ # That scanned set is the set above minus the two `>` shapes a MESSAGE cannot
92
+ # be, which is why the rule here stays whole-byte for `>`. The guard reads the
93
+ # raw string, so quoting and escaping rescue nothing: `|`, `<` and a prose `>`
94
+ # are inert to the shell inside quotes and still silence the command, and an
95
+ # escaped backtick matches exactly as a bare one does. The ONE admitted
96
+ # expansion is a bare `${IDENT}`,
97
+ # bought back by the guard's narrowed `${` arm because the corpus's canonical
98
+ # subject spells its branch token that way — it is allowed AND expanded, so pass
99
+ # it only when expansion is what you want. THIS SCRIPT ENFORCES NONE OF THIS: it
100
+ # validates no message, rewrites no message, and builds the commit from repeated
101
+ # `-m` flags exactly as given. The constraint binds the CALLER, and the caller's
102
+ # own contract states what to do with a title that cannot satisfy it.
103
+ #
104
+ # Usage: commit-on-branch.sh [--repo <repo-dir>] <path>... -- <subject> [<body-line>...]
105
+ # --repo <repo-dir> worktree/repository to operate in (default: $PWD)
106
+ # <path>... explicit paths to stage (before `--`); missing paths warn+skip
107
+ # -- separates the stage-path list from the message lines
108
+ # <subject> first token after `--` — the commit subject (`-m`); see
109
+ # MESSAGE ARGUMENTS for the bytes it may not carry
110
+ # <body-line>... each remaining token — one additional `-m` body paragraph,
111
+ # under that same constraint
112
+ #
113
+ # REPRO — reproduce any decision by hand, against a throwaway fixture:
114
+ #
115
+ # d=$(mktemp -d); git -C "$d" init -q -b feat_x
116
+ # printf '%s' '{"version":1,"defaultBranch":"trunk","protectedBranches":["trunk","release/*"],"stateDir":"sdlc-harness/","layers":[],"commands":{}}' > "$d/harness.config.json"
117
+ # printf 'x\n' > "$d/f.txt"
118
+ #
119
+ # ordinary branch commit-on-branch.sh --repo "$d" f.txt -- "feat: x" "body"
120
+ # -> exit 0, prints the new short SHA
121
+ # re-run with nothing changed -> exit 3
122
+ # protected branch git -C "$d" checkout -q -b trunk; same call -> exit 2,
123
+ # and `git -C "$d" status --porcelain` is unchanged
124
+ # not in the set git -C "$d" checkout -q -b main; same call -> exit 0
125
+ # (the set is configured, not remembered)
126
+ # unresolvable printf 'x' > "$d/harness.config.json"; same call -> exit 1,
127
+ # no commit
128
+ # deleted path git -C "$d" rm -q f.txt; passing `f.txt` -> the deletion is
129
+ # NOT staged; pass its parent directory instead
130
+
131
+ set -uo pipefail
132
+
133
+ # The library is reached by a path computed from this script's own location, so
134
+ # a copy that was moved with its `lib/` still finds it, and no session root or
135
+ # runtime-substituted token is assumed.
136
+ hr_lib="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/lib/harness-run-lib.sh"
137
+ if [ ! -r "$hr_lib" ]; then
138
+ echo "commit-on-branch.sh: cannot read '$hr_lib' — refusing to commit" >&2
139
+ exit 1
140
+ fi
141
+ # shellcheck source=lib/harness-run-lib.sh
142
+ . "$hr_lib"
143
+
144
+ repo_dir="$PWD"
145
+
146
+ # --- Parse args: optional --repo, then <path>... up to `--`, then message lines.
147
+ paths=()
148
+ msgs=()
149
+ saw_sep=0
150
+
151
+ while [ "$#" -gt 0 ]; do
152
+ case "$1" in
153
+ --repo)
154
+ if [ "$#" -lt 2 ]; then
155
+ echo "commit-on-branch.sh: --repo requires a directory argument" >&2
156
+ exit 1
157
+ fi
158
+ repo_dir="$2"
159
+ shift 2
160
+ ;;
161
+ --)
162
+ saw_sep=1
163
+ shift
164
+ break
165
+ ;;
166
+ *)
167
+ paths+=("$1")
168
+ shift
169
+ ;;
170
+ esac
171
+ done
172
+
173
+ if [ "$saw_sep" -ne 1 ]; then
174
+ echo "commit-on-branch.sh: missing '--' separator before the commit message" >&2
175
+ echo " usage: commit-on-branch.sh [--repo <dir>] <path>... -- <subject> [<body-line>...]" >&2
176
+ exit 1
177
+ fi
178
+
179
+ # Everything after `--` is the message: the first token is the subject, the rest
180
+ # are additional body paragraphs.
181
+ while [ "$#" -gt 0 ]; do
182
+ msgs+=("$1")
183
+ shift
184
+ done
185
+
186
+ if [ "${#msgs[@]}" -eq 0 ]; then
187
+ echo "commit-on-branch.sh: no commit subject after '--'" >&2
188
+ exit 1
189
+ fi
190
+
191
+ # Resolve the repository top. A non-git directory is a loud non-zero error (a
192
+ # commit caller cannot tolerate "not a repository" the way a best-effort push
193
+ # can).
194
+ top="$(hr_repo_root "$repo_dir")"
195
+ if [ -z "$top" ]; then
196
+ echo "commit-on-branch.sh: '$repo_dir' is not a git repository — refusing to commit" >&2
197
+ exit 1
198
+ fi
199
+
200
+ # Current branch. Empty => detached HEAD => loud refusal, no staging, no commit.
201
+ branch="$(hr_current_branch "$top")"
202
+ if [ -z "$branch" ]; then
203
+ echo "commit-on-branch.sh: detached HEAD — refusing to commit" >&2
204
+ exit 1
205
+ fi
206
+
207
+ # The protected-branch decision, from this repository's configuration. Called
208
+ # unsubstituted so the library's per-process cache is warmed here and the
209
+ # `$(hr_protected_patterns …)` in the refusal message below re-reads nothing.
210
+ hr_branch_is_protected "$top" "$branch"
211
+ protected=$?
212
+ case "$protected" in
213
+ 0)
214
+ # Name the resolved set as well as the branch: the adopter configured it, so
215
+ # a refusal that only names the branch cannot be acted on.
216
+ patterns="$(hr_protected_patterns "$top")" || patterns=""
217
+ protected_set=""
218
+ while IFS= read -r pattern; do
219
+ [ -n "$pattern" ] || continue
220
+ if [ -z "$protected_set" ]; then
221
+ protected_set="$pattern"
222
+ else
223
+ protected_set="$protected_set $pattern"
224
+ fi
225
+ done <<EOF
226
+ $patterns
227
+ EOF
228
+ echo "commit-on-branch.sh: branch '$branch' is protected — refusing to commit" >&2
229
+ echo " protected set (from harness.config.json): $protected_set" >&2
230
+ exit 2
231
+ ;;
232
+ 2)
233
+ # Fail closed. The configuration is what says which branches are protected,
234
+ # so a configuration that cannot be read is a branch that cannot be judged —
235
+ # and committing on it would be exactly the mutation this wrapper exists to
236
+ # gate.
237
+ echo "commit-on-branch.sh: cannot resolve '$top/harness.config.json' — refusing to commit" >&2
238
+ echo " (no configuration there, invalid JSON, more than one document, no defaultBranch, or jq missing/older than 1.5)" >&2
239
+ exit 1
240
+ ;;
241
+ esac
242
+
243
+ # Stage ONLY the explicit paths — never `git add -A` / `git add .`. A missing
244
+ # path is skip-with-warning, not a hard error. The count guard is required: on
245
+ # bash 3.2 (the floor) `"${paths[@]}"` on an EMPTY array under `set -u` raises
246
+ # `unbound variable` instead of expanding to nothing, which would abort a legal
247
+ # zero-path call before it could reach the nothing-to-commit exit 3.
248
+ if [ "${#paths[@]}" -gt 0 ]; then
249
+ for p in "${paths[@]}"; do
250
+ # The existence check MUST use the same base as the staging command below
251
+ # (`git -C "$top" add` resolves $p relative to $top, not the caller's cwd).
252
+ # Checking `$top/$p` only — never a cwd-relative $p — guarantees a path is
253
+ # staged if and only if it is the same path `git add` will resolve.
254
+ if [ ! -e "$top/$p" ]; then
255
+ echo "commit-on-branch.sh: path '$p' does not exist under '$top' — skipping" >&2
256
+ continue
257
+ fi
258
+ if ! git -C "$top" add -- "$p"; then
259
+ echo "commit-on-branch.sh: 'git add -- $p' failed — no commit made" >&2
260
+ exit 1
261
+ fi
262
+ done
263
+ fi
264
+
265
+ # Nothing staged => a distinct, dedicated non-zero status, so a caller can tell
266
+ # this apart from a protected-branch refusal or a hook failure.
267
+ if git -C "$top" diff --cached --quiet; then
268
+ echo "commit-on-branch.sh: nothing to commit" >&2
269
+ exit 3
270
+ fi
271
+
272
+ # Build the commit from repeated -m flags, WITHOUT `--no-verify` or any other
273
+ # hook-skip flag, so the repository's own commit hooks still run.
274
+ commit_args=()
275
+ for m in "${msgs[@]}"; do
276
+ commit_args+=(-m "$m")
277
+ done
278
+
279
+ if ! git -C "$top" commit "${commit_args[@]}"; then
280
+ # A hook (or any other commit-time check) failed. Surface it loudly; do NOT
281
+ # retry, do NOT add --no-verify, do NOT leave a partial or bypassing commit.
282
+ echo "commit-on-branch.sh: 'git commit' failed (see output above) — no commit made" >&2
283
+ exit 1
284
+ fi
285
+
286
+ sha="$(git -C "$top" rev-parse --short HEAD)"
287
+ echo "commit-on-branch.sh: committed $sha on $branch"
288
+ exit 0