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,337 @@
1
+ #!/usr/bin/env bash
2
+ # autonomous-notify.sh — the one place a run's lifecycle event becomes a message
3
+ # an operator actually sees: a desktop banner on the machine the watcher runs on,
4
+ # plus a best-effort push to wherever that operator is instead.
5
+ #
6
+ # WHY IT EXISTS AT ALL. An unattended run is a background process nobody is
7
+ # looking at. Whatever "your chat finished" mechanism an interactive session has
8
+ # does not reach a detached one reliably, so the watcher's own notification on a
9
+ # lifecycle event is the PRIMARY mechanism rather than a nicety — it is how a
10
+ # completed branch, a parked question or a failed run is noticed at all.
11
+ #
12
+ # THE EVENT VOCABULARY IS A CONTRACT. The watcher's exit classifier and its
13
+ # launch, resume, park and stall paths all call this script with one of these six
14
+ # words, and nothing else may be added here without adding it there:
15
+ #
16
+ # completed the run reached "branch ready for review"
17
+ # parked the run wrote a clarification question and yielded — idle until
18
+ # the answer file lands
19
+ # paused the run honored a PAUSE request and yielded — idle until RESUME
20
+ # failed the run exited non-zero, or its process vanished
21
+ # launched a fresh inbox run was just started
22
+ # resumed the same run is being re-launched — a parked run's answer landed,
23
+ # or a paused run's RESUME landed (the detail argument says which)
24
+ #
25
+ # An unrecognized word is still delivered, under a generic title. A notification
26
+ # helper that refused an event it did not know would silently drop exactly the
27
+ # unusual event an operator most needs to hear about.
28
+ #
29
+ # IT NEVER FAILS ITS CALLER. Every delivery arm is best-effort and the script
30
+ # exits 0 whatever happened — a missing notifier, an unreachable endpoint, a
31
+ # push command that returns non-zero. The only non-zero exit is a usage error,
32
+ # which is a caller defect rather than a delivery failure. Each arm that fails
33
+ # prints ONE line to stderr, which lands in the run log next to the event.
34
+ #
35
+ # EVERY MESSAGE CARRIES THE REPOSITORY SLUG. The title is
36
+ # `[<slug>] <event title> — <branch>`, because one machine watches more than one
37
+ # repository and two of them may well have a branch of the same name. The slug is
38
+ # `HARNESS_REPO_SLUG` when the caller exported it (the watcher does, once, rather
39
+ # than re-deriving it per event), else the shared library's `hr_repo_slug` for
40
+ # this checkout — so a hand invocation from a shell still resolves one. It is the
41
+ # same slug the daemon label and the machine-level usage lane are keyed on.
42
+ #
43
+ # CREDENTIAL RESOLUTION ORDER, STATED ONCE AND IMPLEMENTED ONCE:
44
+ #
45
+ # 1. the machine-local file `${XDG_CONFIG_HOME:-$HOME/.config}/autonomous-sdlc-harness/push.env`
46
+ # 2. the repository's configured `pushEnvPath`, when it is set and the file is
47
+ # there
48
+ #
49
+ # The first that exists wins and the other is not read. Neither present is a
50
+ # GRACEFUL DEGRADE, not an error: desktop delivery still happens and the script
51
+ # still exits 0, with one line saying which paths were looked at. Machine-local
52
+ # comes first because a push token belongs to a person and a machine rather than
53
+ # to a repository — an operator keeps it in one place instead of once per
54
+ # checkout, and it cannot be committed by accident from there. The file is
55
+ # sourced under `set -a` so a value reaches a child process; NO VALUE IS EVER
56
+ # PRINTED, by this script or in a failure line — only paths and key names. A key
57
+ # ALREADY IN THE ENVIRONMENT WINS over the file's value for that key: an operator
58
+ # checking delivery with `HARNESS_PUSH_CMD=… autonomous-notify.sh …` means that
59
+ # command, and a file quietly overriding it would send the check somewhere else.
60
+ #
61
+ # DELIVERY IS TWO INDEPENDENT ARMS, NOT A PRECEDENCE CHAIN.
62
+ #
63
+ # HARNESS_PUSH_CMD a local command. It receives the MESSAGE ON STANDARD INPUT
64
+ # and the title in the environment as `HARNESS_PUSH_TITLE`.
65
+ # It is not a `{title}`/`{message}` template — nothing is
66
+ # substituted into it, which is also what keeps a message
67
+ # containing shell metacharacters from being re-parsed.
68
+ # HARNESS_PUSH_URL an endpoint that accepts a POST. The message is the body,
69
+ # the title rides in a `Title:` header, and a failure retries
70
+ # once with a JSON body so either shape of endpoint works.
71
+ #
72
+ # Both set means BOTH run, in that order, and a failing command must not suppress
73
+ # the POST. This is deliberately not the "command instead of URL" precedence the
74
+ # script was ported from: `cli/templates/claude/push-notify.env.example` is the
75
+ # contract — it is committed, it is what an adopter reads before this file, and
76
+ # it describes the command as running *in addition to* the POST.
77
+ #
78
+ # DESKTOP DELIVERY IS PLATFORM-GATED AND DEGRADES TO NOTHING. The two macOS
79
+ # mechanisms are tried only when `command -v` finds them, which is the platform
80
+ # test: on a machine without them (a Linux host running the systemd backend) both
81
+ # are skipped, no error is printed, and the push arms carry the notification
82
+ # alone. The richer notifier and the built-in one are both fired when both are
83
+ # present — the second is what guarantees a banner when the first is installed
84
+ # but not permitted to post one. Notifications are grouped per repository slug,
85
+ # so one repository's stream of events replaces its own and not another's.
86
+ #
87
+ # WHO RUNS IT. The watcher, on every lifecycle event, and a person by hand for a
88
+ # delivery check. Not a dispatched agent: it has no entry in the generated
89
+ # permission profile. That is a CALLING CONVENTION, NOT A GATE — its basename is
90
+ # not on the script-allowlist guard's `DENY_SCRIPT_BASENAMES`, and that guard
91
+ # grants independently of the profile, so an agent invoking this path under
92
+ # `scriptsDir` is auto-allowed today — but only in a PLAIN invocation: that
93
+ # guard's construct scan withholds the allow, silently, from any command
94
+ # carrying `$(…)`, a backtick, `|`, `<`, a braced form other than a bare
95
+ # `${IDENT}`, or a `>` that is neither a descriptor duplication (`2>&1`, `>&2`,
96
+ # `2>&-`) nor a redirection to the literal `/dev/null` — which includes a
97
+ # `<placeholder>` written into this script's own `detail` argument.
98
+ #
99
+ # Usage:
100
+ # autonomous-notify.sh <event> <branch> [log_path] [detail]
101
+ # event completed | parked | paused | failed | launched | resumed
102
+ # branch the run's branch name
103
+ # log_path optional path to the central run log, shown in the message
104
+ # detail optional one-line extra, e.g. the clarification question's file
105
+ #
106
+ # Exit map a caller can switch on:
107
+ #
108
+ # 0 the event was processed — delivered, partly delivered, or degraded to
109
+ # desktop-only. The stderr lines say which; the status deliberately does not
110
+ # 2 usage error: no event, or no branch. Nothing was delivered
111
+ #
112
+ # REPRO — reproduce any decision by hand, with a recorder standing in for a real
113
+ # push target and nothing real configured:
114
+ #
115
+ # w=$(mktemp -d); printf '#!/bin/sh\ncat > "%s/body"; printf %%s "$HARNESS_PUSH_TITLE" > "%s/title"\n' "$w" "$w" > "$w/rec"
116
+ # chmod +x "$w/rec"
117
+ #
118
+ # both arms HARNESS_PUSH_CMD="$w/rec" HARNESS_PUSH_URL=http://127.0.0.1:9/x \
119
+ # bash <repo>/<scripts_dir>/autonomous-notify.sh completed feat/x; echo $?
120
+ # -> "$w/title" begins `[<slug>] ` and names feat/x, "$w/body"
121
+ # holds the message, ONE stderr line for the failed POST, and 0
122
+ # url only HARNESS_PUSH_URL=http://127.0.0.1:9/x bash …/autonomous-notify.sh failed feat/x
123
+ # -> one stderr line, exit 0
124
+ # neither env -u HARNESS_PUSH_CMD -u HARNESS_PUSH_URL bash …/autonomous-notify.sh paused feat/x
125
+ # -> the degrade line naming the paths looked at, exit 0
126
+ # file order put `HARNESS_PUSH_CMD=<recorder A>` in
127
+ # "${XDG_CONFIG_HOME:-$HOME/.config}/autonomous-sdlc-harness/push.env"
128
+ # and `HARNESS_PUSH_CMD=<recorder B>` in the repository's `pushEnvPath`
129
+ # -> A records; remove that file and B records
130
+ # unknown bash …/autonomous-notify.sh sneezed feat/x; echo $? -> generic title, 0
131
+ # usage bash …/autonomous-notify.sh completed; echo $? -> usage on stderr, 2
132
+
133
+ set -u
134
+
135
+ self="autonomous-notify.sh"
136
+
137
+ EVENT="${1:-}"
138
+ BRANCH="${2:-}"
139
+ LOG_PATH="${3:-}"
140
+ DETAIL="${4:-}"
141
+
142
+ if [ -z "$EVENT" ] || [ -z "$BRANCH" ]; then
143
+ echo "usage: $self <completed|parked|paused|failed|launched|resumed> <branch> [log_path] [detail]" >&2
144
+ exit 2
145
+ fi
146
+
147
+ # --- The shared library, reached by a path computed from this script's own
148
+ # location. Unlike every other script in this set, NOT finding it is not a
149
+ # refusal: delivering a notification mutates nothing, and the run whose
150
+ # configuration just became unreadable is exactly the run an operator needs to
151
+ # hear about. Without it the slug falls back to the caller's export, no
152
+ # credential file is looked for, and the pause message loses its state-directory
153
+ # name — the message still goes out.
154
+ script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
155
+ hr_lib="$script_dir/lib/harness-run-lib.sh"
156
+ have_lib=0
157
+ if [ -r "$hr_lib" ]; then
158
+ # shellcheck source=lib/harness-run-lib.sh
159
+ . "$hr_lib"
160
+ have_lib=1
161
+ fi
162
+
163
+ # Anchored on the script's own directory rather than on the caller's working
164
+ # directory: the watcher invokes this from wherever a run happens to be, and an
165
+ # anchor that moved with `$PWD` would key a notification on another repository.
166
+ root=""
167
+ if [ "$have_lib" -eq 1 ]; then
168
+ root="$(hr_repo_root "$script_dir")" || root=""
169
+ fi
170
+
171
+ slug="${HARNESS_REPO_SLUG:-}"
172
+ if [ -z "$slug" ] && [ -n "$root" ]; then
173
+ slug="$(hr_repo_slug "$root")" || slug=""
174
+ fi
175
+ [ -n "$slug" ] || slug="unknown-repo"
176
+
177
+ # The resume sentinel lives at `<state_dir>/RESUME` inside the run's own working
178
+ # copy, and `<state_dir>` is configured — so the message names the resolved
179
+ # directory, never a remembered one. Unresolvable configuration drops to the
180
+ # generic wording rather than to a guess an operator would then look for in vain.
181
+ state_dir=""
182
+ if [ -n "$root" ]; then
183
+ state_dir="$(hr_state_dir "$root")" || state_dir=""
184
+ fi
185
+ if [ -n "$state_dir" ]; then
186
+ resume_hint="Drop '$state_dir/RESUME' in its working copy to continue."
187
+ else
188
+ resume_hint="Drop a RESUME file in its state directory to continue."
189
+ fi
190
+
191
+ case "$EVENT" in
192
+ completed)
193
+ TITLE="[$slug] Run complete — $BRANCH"
194
+ MESSAGE="Branch '$BRANCH' is ready for review."
195
+ ;;
196
+ parked)
197
+ TITLE="[$slug] Run parked — $BRANCH"
198
+ MESSAGE="Run '$BRANCH' is waiting for a clarification answer."
199
+ ;;
200
+ paused)
201
+ TITLE="[$slug] Run paused — $BRANCH"
202
+ MESSAGE="Run '$BRANCH' honored a PAUSE request and yielded. $resume_hint"
203
+ ;;
204
+ resumed)
205
+ TITLE="[$slug] Run resumed — $BRANCH"
206
+ MESSAGE="Run '$BRANCH' resumed."
207
+ ;;
208
+ launched)
209
+ TITLE="[$slug] Run launched — $BRANCH"
210
+ MESSAGE="Run '$BRANCH' started."
211
+ ;;
212
+ failed)
213
+ TITLE="[$slug] Run failed — $BRANCH"
214
+ MESSAGE="Run '$BRANCH' exited non-zero. Check the log."
215
+ ;;
216
+ *)
217
+ TITLE="[$slug] Run ($EVENT) — $BRANCH"
218
+ MESSAGE="Run '$BRANCH': $EVENT."
219
+ ;;
220
+ esac
221
+
222
+ [ -n "$DETAIL" ] && MESSAGE="$MESSAGE $DETAIL"
223
+ [ -n "$LOG_PATH" ] && MESSAGE="$MESSAGE Log: $LOG_PATH"
224
+
225
+ # --- Credentials. First candidate that exists wins; the rest are not read. The
226
+ # candidate list itself, and its order, belong to the library — this script must
227
+ # not re-derive either, or the two would disagree about where an operator's
228
+ # settings live.
229
+ push_env=""
230
+ checked=""
231
+ # Held across the source so an explicitly-invoked value survives it — see the
232
+ # header: the environment wins per key, and a file supplies only what the
233
+ # environment did not.
234
+ env_cmd="${HARNESS_PUSH_CMD:-}"
235
+ env_url="${HARNESS_PUSH_URL:-}"
236
+ if [ "$have_lib" -eq 1 ]; then
237
+ while IFS= read -r candidate; do
238
+ [ -n "$candidate" ] || continue
239
+ if [ -n "$checked" ]; then checked="$checked, $candidate"; else checked="$candidate"; fi
240
+ [ -n "$push_env" ] && continue
241
+ [ -f "$candidate" ] && [ -r "$candidate" ] || continue
242
+ # `set -a` so the keys reach the push command's child process; restored
243
+ # immediately, because exporting everything this script defines afterwards
244
+ # would leak its internals into that child too.
245
+ set -a
246
+ # shellcheck disable=SC1090
247
+ . "$candidate"
248
+ set +a
249
+ push_env="$candidate"
250
+ done <<EOF
251
+ $(hr_push_env_files "$root")
252
+ EOF
253
+ fi
254
+ [ -n "$env_cmd" ] && export HARNESS_PUSH_CMD="$env_cmd"
255
+ [ -n "$env_url" ] && export HARNESS_PUSH_URL="$env_url"
256
+
257
+ # --- Desktop notification. Platform-gated by `command -v` (see the header): both
258
+ # mechanisms are macOS-only, both are skipped silently elsewhere, and each is
259
+ # `|| true` so a notifier that is present but refused permission cannot end the
260
+ # script before the push arms run.
261
+ if command -v terminal-notifier >/dev/null 2>&1; then
262
+ terminal-notifier -title "$TITLE" -message "$MESSAGE" -group "autonomous-sdlc-harness.$slug" >/dev/null 2>&1 || true
263
+ fi
264
+
265
+ if command -v osascript >/dev/null 2>&1; then
266
+ # Escape backslash and double quote for the AppleScript string literals, in
267
+ # that order: an AppleScript literal treats `\` as an escape too, so escaping
268
+ # the quotes first would re-escape the backslashes this step then adds.
269
+ esc_title="$(printf '%s' "$TITLE" | sed -e 's/\\/\\\\/g' -e 's/"/\\"/g')"
270
+ esc_message="$(printf '%s' "$MESSAGE" | sed -e 's/\\/\\\\/g' -e 's/"/\\"/g')"
271
+ osascript -e "display notification \"$esc_message\" with title \"$esc_title\"" >/dev/null 2>&1 || true
272
+ fi
273
+
274
+ # Minimal JSON string escaping for the fallback body below. Two steps, in this
275
+ # order and no other:
276
+ #
277
+ # 1. translate the control characters JSON forbids unescaped inside a string
278
+ # (tab, CR, LF) to spaces. Translated rather than encoded, because a
279
+ # one-line notification body has no use for any of them and `\t`/`\r`/`\n`
280
+ # escapes would be a second thing to get right for no gain — and to a
281
+ # SPACE rather than deleted, because LF is the reachable one in practice
282
+ # (the detail argument interpolates command output) and deleting it would
283
+ # run the last word of one line into the first of the next.
284
+ # 2. BACKSLASH FIRST, THEN QUOTE. Doing it the other way round re-escapes the
285
+ # backslashes step 2 just added, and a value ending in a backslash would
286
+ # escape the closing quote and corrupt the document.
287
+ #
288
+ # This exists because the body is built with printf rather than jq: jq is not a
289
+ # dependency of this script, which must keep working when the library and
290
+ # everything under it is unavailable.
291
+ json_escape() {
292
+ printf '%s' "${1-}" | tr '\t\r\n' ' ' | sed -e 's/\\/\\\\/g' -e 's/"/\\"/g'
293
+ }
294
+
295
+ # --- Push. Two independent arms; neither is conditional on the other's outcome.
296
+ pushed=0
297
+
298
+ if [ -n "${HARNESS_PUSH_CMD:-}" ]; then
299
+ pushed=1
300
+ # The message goes in on STDIN and the title in the environment — the command
301
+ # string is never rewritten, so a message carrying quotes, backticks or a `$`
302
+ # cannot be re-parsed as shell.
303
+ printf '%s\n' "$MESSAGE" | HARNESS_PUSH_TITLE="$TITLE" bash -c "$HARNESS_PUSH_CMD" >/dev/null 2>&1 ||
304
+ echo "$self: HARNESS_PUSH_CMD exited non-zero (the other delivery arms were unaffected)" >&2
305
+ fi
306
+
307
+ if [ -n "${HARNESS_PUSH_URL:-}" ]; then
308
+ pushed=1
309
+ if command -v curl >/dev/null 2>&1; then
310
+ # A plain body first, a JSON body second: the two shapes cover both the
311
+ # "post the text" endpoints and the "post an object" ones without asking the
312
+ # adopter to declare which they have.
313
+ curl -sf -m 10 \
314
+ -H "Title: $TITLE" \
315
+ -d "$MESSAGE" \
316
+ "$HARNESS_PUSH_URL" >/dev/null 2>&1 ||
317
+ curl -sf -m 10 \
318
+ -H "Content-Type: application/json" \
319
+ -d "{\"title\":\"$(json_escape "$TITLE")\",\"message\":\"$(json_escape "$MESSAGE")\"}" \
320
+ "$HARNESS_PUSH_URL" >/dev/null 2>&1 ||
321
+ echo "$self: HARNESS_PUSH_URL POST failed (the other delivery arms were unaffected)" >&2
322
+ else
323
+ echo "$self: curl is not on PATH, so HARNESS_PUSH_URL could not be posted to" >&2
324
+ fi
325
+ fi
326
+
327
+ if [ "$pushed" -eq 0 ]; then
328
+ # Not an error — see the header. The paths are named so an operator can see
329
+ # where the settings were expected; no value is printed.
330
+ if [ -n "$checked" ]; then
331
+ echo "$self: no HARNESS_PUSH_CMD/HARNESS_PUSH_URL set; push skipped (looked in: $checked)" >&2
332
+ else
333
+ echo "$self: no HARNESS_PUSH_CMD/HARNESS_PUSH_URL set and no credential file could be looked for; push skipped" >&2
334
+ fi
335
+ fi
336
+
337
+ exit 0