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,1481 @@
1
+ #!/usr/bin/env bash
2
+ # harness-run-lib.sh — the one place every generated outer-loop script resolves
3
+ # the repository it is operating on, reads that repository's
4
+ # `harness.config.json` at run time, answers "is this branch protected?", and
5
+ # derives the anchors (main checkout, work root, worktree directory, repo slug,
6
+ # state-dir paths) the scripts would otherwise each re-derive slightly
7
+ # differently.
8
+ #
9
+ # WHO SOURCES THIS, AND HOW. Every script in the configured `scriptsDir` that
10
+ # needs this library sources it by a path computed from `${BASH_SOURCE[0]}` —
11
+ # the sourcing script's own location — i.e.
12
+ #
13
+ # . "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/lib/harness-run-lib.sh"
14
+ #
15
+ # and never by a hardcoded path, an assumed session root, or the plugin-root
16
+ # token the runtime substitutes into hook `command` strings and agent bodies:
17
+ # that token is not exported into a script's environment, so a script body that
18
+ # reaches for it gets an empty string under `set -u` or an unset variable
19
+ # without it. (It is deliberately not spelled out anywhere in this file; its
20
+ # literal presence here is exactly the defect this paragraph prevents.) The
21
+ # project-command wrappers and `autonomous-format-stream.sh` need nothing from
22
+ # this library and do not source it.
23
+ #
24
+ # ONE SCRIPT MUST NOT USE THAT SPELLING, AND THE REASON GENERALISES.
25
+ # `autonomous-watcher.sh` sources this file BEFORE it settles `PATH`, because its
26
+ # bootstrap calls `hr_path_with_fallbacks` below; `dirname` is not a builtin, so
27
+ # on a service-manager unit that renders no `PATH` key the fork may not resolve
28
+ # and the start fails at the one step that cannot afford a lookup. It resolves
29
+ # its own directory from `${BASH_SOURCE[0]}` with a `case` strip plus the `cd`
30
+ # and `pwd` builtins instead. ANY script that sources this library ahead of its
31
+ # own `PATH` settle owes the same builtin-only form; every other script settles
32
+ # `PATH` first (or is never started by a service manager) and keeps the `dirname`
33
+ # idiom above.
34
+ #
35
+ # IT IS DELIBERATELY NOT THE PLUGIN'S LIBRARY. The guard hooks have their own
36
+ # copy of this shape. The plugin and this package install to unrelated roots, so
37
+ # neither can source the other's, and a script that tried would work on the
38
+ # machine that wrote it and nowhere else. The two files are kept in step by
39
+ # their shared contract — the three-state answer, the protected set and the
40
+ # floors below — not by sharing code.
41
+ #
42
+ # JURISDICTION. Configuration is read at `<repo_root>/harness.config.json` and
43
+ # nowhere else, where `<repo_root>` is resolved from a bare
44
+ # `git rev-parse --show-toplevel` at the directory the caller names. Nothing
45
+ # here reads an environment variable in place of a configured value, and nothing
46
+ # here writes anything inside a repository.
47
+ #
48
+ # THE ONE EXCEPTION TO "WRITES NOTHING", AND ITS FENCE: the machine-level usage
49
+ # lane at the bottom of this file publishes a state record and takes an advisory
50
+ # lock. Both live under `hr_lane_dir` — a machine-local path outside every
51
+ # repository — and nothing else here writes at all, so a caller that never calls
52
+ # an `hr_lane_*` function still gets a library that only reads. The lane's
53
+ # ceilings are the only environment values here that carry policy, because the
54
+ # lane is machine-scoped and has no configuration key to carry them; each is
55
+ # named where it is used. `XDG_STATE_HOME`, `XDG_CONFIG_HOME`, `HOME` and `PWD`
56
+ # are also read, as location anchors only, and `PATH` is read by
57
+ # `hr_path_with_fallbacks` alone — as that function's input, which it prints back
58
+ # transformed and never assigns.
59
+ #
60
+ # CONFIGURATION IS READ AT RUN TIME, NOT FROZEN AT GENERATION TIME. That is the
61
+ # whole reason the shipped scripts carry no `{{token}}`: a guard whose protected
62
+ # set was substituted into a file when `init` ran enforces the wrong set the
63
+ # moment `protectedBranches` changes, and the failure is silent.
64
+ #
65
+ # THE THREE-STATE ANSWER — the reason this library exists.
66
+ # `hr_branch_is_protected` returns:
67
+ #
68
+ # 0 protected — the branch matched a pattern in the resolved set
69
+ # 1 not protected — the set was resolved and the branch is not in it
70
+ # 2 unresolvable — the configuration could not be resolved at all
71
+ # (no `harness.config.json`, an unreadable one, absent
72
+ # or pre-1.5 `jq`, invalid JSON, more than one JSON
73
+ # document, or the required `defaultBranch` missing)
74
+ #
75
+ # A CONSUMER THAT TREATS 2 AS 1 TAKES A MUTATING ACTION ON A CONFIGURATION IT
76
+ # COULD NOT READ. Every caller therefore has a closed outcome for 2 and states
77
+ # it in its own header: the commit wrapper refuses loudly (non-zero), the push
78
+ # wrapper refuses visibly but non-fatally (exit 0 with a message, because its
79
+ # never-abort-the-caller contract is load-bearing), the watcher logs and defers.
80
+ #
81
+ # THE SAME THREE STATES REACH EVERY TYPED READER: `hr_state_dir`,
82
+ # `hr_command`, `hr_project_name` and the rest print their value and return 0,
83
+ # return 1 when the key is simply not set (and has no schema default to apply),
84
+ # and return 2 when the configuration could not be read. A caller must be able
85
+ # to tell "the key is empty" from "the file could not be read", because the
86
+ # first is an ordinary project and the second is a repository this script has no
87
+ # business acting in. A reader that wants the finer distinction between "no
88
+ # `harness.config.json` at all" and "one that would not parse" calls
89
+ # `hr_config_load` directly, which returns 1 for the first and 2 for the second.
90
+ #
91
+ # THE PROTECTED SET. It is *(`protectedBranches` if present, else that key's
92
+ # schema default)* ∪ *{`defaultBranch`}*, matched as `case` globs so one
93
+ # `release/*` entry covers its namespace. `defaultBranch` is a required key, so
94
+ # its absence is an unresolvable configuration rather than a defaultable one; a
95
+ # `protectedBranches` present but EMPTY is a configured set, so it yields
96
+ # `{defaultBranch}` and not the schema default. There is no built-in matcher: a
97
+ # repository whose default branch is `trunk` and whose list is
98
+ # `["trunk","release/*"]` has every other name as an ordinary branch, and this
99
+ # library says so.
100
+ #
101
+ # THE ONE BRANCH NAME IN THIS FILE IS `protectedBranches`'s SCHEMA DEFAULT, and
102
+ # it is not the hardcoding the rule above is about. It is spelled exactly once,
103
+ # in `hr_protected_default_var`, mirroring `schemas/harness.config.schema.json`
104
+ # and the same default the CLI applies when it generates the committed pre-push
105
+ # hook — so for one configuration the hook and this library RESOLVE the same
106
+ # set. They can still ENFORCE different sets, because they differ in when they
107
+ # resolve it: this library re-reads `harness.config.json` on every call, while
108
+ # the hook is written create-if-absent and carries the set substituted into its
109
+ # `case` label when `init` wrote it. An edit to `protectedBranches` therefore
110
+ # binds a wrapper at once and binds the hook only after `init --force`, or
111
+ # after deleting the hook and re-running `init`. A third run re-renders the
112
+ # hook as a CONSEQUENCE rather than as a way to change the set: `init
113
+ # --reset-config` rebuilds `harness.config.json` itself, and re-renders the
114
+ # hook from the rebuilt file when the `case` label the hook carried no longer
115
+ # matches the set that file resolves — after a `.bak`, and never when the label
116
+ # cannot be read. It is not a route to reach for deliberately: the rebuild
117
+ # takes the whole config from detection and the flags, so every value that was
118
+ # set by hand in the file it replaces goes with it. Which resolution is
119
+ # authoritative follows the caller: a push is judged by the `case` label in the
120
+ # hook file, because git runs that file, and a wrapper is judged by what this
121
+ # library returns. The default is only ever UNIONED with the configured set,
122
+ # never a replacement for it, so it can widen the refusal and can never narrow
123
+ # one. The defect it must never become is a `case` list of remembered names
124
+ # that replaces what the adopter configured: that leaves a repository whose
125
+ # integration branch is named anything else with no protection at all, which is
126
+ # a wrapper committing or pushing where it should have refused. `init` writes
127
+ # `protectedBranches` on every adoption, so this default is reached only by a
128
+ # configuration somebody hand-edited the key out of.
129
+ #
130
+ # FILE DISCIPLINE. Sourced, never executed (mode 0644; the shebang above is a
131
+ # dialect marker for editors and linters). No `set -e` and no `set -u` — a
132
+ # sourced library must not change its caller's shell — but every parameter
133
+ # expansion here is defaulted, so it is safe to source into a caller that sets
134
+ # both. No top-level side effects, no exiting, no writes outside the lane
135
+ # directory named above, and no diagnostics on stdout OR stderr: every reader is
136
+ # silent on failure and signals through its return status, because callers
137
+ # capture stdout. That silence is why the lane reports a lock it BROKE through a
138
+ # variable instead of a log line — the caller owns the log.
139
+ #
140
+ # NAMING. Every function is prefixed `hr_`; every variable this file touches
141
+ # outside a `local` is prefixed `HR_`. The `HR_`-prefixed ones exist to return a
142
+ # value WITHOUT a command substitution — a `$(…)` forks a subshell, and the
143
+ # watcher calls these on every tick: `HR_CFG_PID`, `HR_CFG_ROOT`,
144
+ # `HR_CFG_STATE`, `HR_CFG_FILE`, `HR_CFG_SCALARS`, `HR_CFG_LISTS`,
145
+ # `HR_CFG_VALUE`, `HR_CFG_COMMAND_KEYS`, `HR_PROTECTED_DEFAULT`, and the lane's
146
+ # `HR_LANE_RANK`, `HR_LANE_STATE`, `HR_LANE_RESUME_AT`, `HR_LANE_OBSERVED_AT`,
147
+ # `HR_LANE_OBSERVED_REPO`, `HR_LANE_OWNER_SLUG`, `HR_LANE_OWNER_PID`,
148
+ # `HR_LANE_OWNER_AT` and `HR_LANE_BROKEN_OWNER`. Every one of them is assigned
149
+ # before it is read by the function that owns it, so an inherited value from a
150
+ # parent process is overwritten rather than believed.
151
+ #
152
+ # BASH 3.2 IS THE FLOOR. macOS ships `/bin/bash` 3.2, so nothing here uses an
153
+ # associative array, `${var^^}` / `${var,,}`, `mapfile` or `local -n`. The cache
154
+ # below is a string-record store for exactly that reason, and the one
155
+ # case-folding step forks `tr` rather than reaching for a 4.x expansion.
156
+ #
157
+ # JQ 1.5 IS THE FLOOR, AND AN OLDER `jq` IS UNRESOLVABLE, NOT ABSENT.
158
+ # `hr_config_load`'s program uses five constructs that are all jq 1.5 additions:
159
+ # `input` / `inputs` (the multi-document refusal), `@tsv` (the record format),
160
+ # `try … catch`, `error("…")` and the `def s($k; $v)` value-parameter form. On an
161
+ # older `jq` the program is a COMPILE error, so the load fails and every reader
162
+ # returns 2 — the closed path — while `command -v jq` still succeeds and says
163
+ # nothing. That is why `doctor` checks the version rather than the binary. If
164
+ # you add a construct here, check it against 1.5 or raise this floor in both
165
+ # places.
166
+ #
167
+ # THE CACHE IS PER PROCESS, SO A LONG-LIVED CALLER MUST RESET IT PER TICK.
168
+ # `hr_config_load` runs ONE `jq` and holds the result in shell variables, so a
169
+ # script that reads a dozen keys forks `jq` once instead of a dozen times. The
170
+ # entry is keyed by root and stamped with `$$`, never by the file's contents:
171
+ # a caller that outlives an edit to `harness.config.json` — the watcher, which
172
+ # runs for days — is answered from the document it replaced until it calls
173
+ # `hr_config_reset`. THE WATCHER CALLS IT ONCE AT THE TOP OF EVERY TICK; a
174
+ # one-shot wrapper never needs to. A `$(…)` runs in a SUBSHELL, which inherits
175
+ # the cache but cannot write one back, so a caller that reads only through
176
+ # command substitutions loads the document once per read: call
177
+ # `hr_config_load "$root" || :` once, unsubstituted, to warm it. Forgetting that
178
+ # line is slower and still correct.
179
+ #
180
+ # REPRO — reproduce any answer by hand, against a throwaway fixture:
181
+ #
182
+ # . <repo>/<scripts_dir>/lib/harness-run-lib.sh
183
+ # root=$(git -C <dir> rev-parse --show-toplevel)
184
+ #
185
+ # adopted + valid hr_protected_patterns "$root"
186
+ # hr_branch_is_protected "$root" "$(hr_current_branch "$root")"; echo $?
187
+ # -> the resolved set, then 0 or 1
188
+ # hr_state_dir "$root"; hr_command "$root" test
189
+ # adopted + broken printf 'x' > "$root/harness.config.json"
190
+ # hr_branch_is_protected "$root" <branch>; echo $? -> 2
191
+ # hr_state_dir "$root"; echo $? -> 2, prints nothing
192
+ # not adopted mv "$root/harness.config.json" "$root/../saved.json"
193
+ # hr_config_file "$root"; echo $? -> 1, prints nothing
194
+ # hr_config_load "$root"; echo $? -> 1
195
+ # not a repository hr_repo_root /tmp; echo $? -> 1, prints nothing
196
+ # anchors hr_main_repo "$root"; hr_work_root "$root"
197
+ # hr_worktree_dir "$root" feat/x; hr_repo_slug "$root"
198
+ # hr_state_path "$root" autonomous_logs/registry.json
199
+ # the PATH policy run each in a SUBSHELL, so your own PATH is untouched:
200
+ # ( PATH="$HOME/.rbenv/shims:/usr/bin:/bin"
201
+ # hr_path_with_fallbacks )
202
+ # -> the shims entry STILL FIRST and `/usr/bin:/bin` still
203
+ # after it, then the five absent fallbacks appended in
204
+ # list order:
205
+ # /opt/homebrew/bin:/usr/local/bin:/usr/sbin:/sbin:$HOME/.local/bin
206
+ # ( PATH=/usr/bin:/bin:/usr/sbin:/sbin
207
+ # hr_path_with_fallbacks )
208
+ # -> that value, then the three absent ones —
209
+ # /opt/homebrew/bin:/usr/local/bin:$HOME/.local/bin —
210
+ # which is how `jq`, a Homebrew toolchain and an agent CLI
211
+ # under `~/.local/bin` stay resolvable from a bare
212
+ # service-manager PATH, none of them being in `/usr/bin`.
213
+ # A Homebrew copy of a name `/usr/bin` DOES hold (`ruby`,
214
+ # `python3`, `curl`, `git`) is reached but no longer
215
+ # preferred, which is the price of never demoting the
216
+ # caller's order.
217
+ # the lane point it somewhere disposable first, so a live daemon's
218
+ # lane is not what you experiment on:
219
+ # export XDG_STATE_HOME=$(mktemp -d)
220
+ # hr_lane_dir; hr_lane_read -> the dir, `unknown 0`
221
+ # hr_lane_publish repo-a feat/x warning $(( $(date +%s) + 600 ))
222
+ # hr_lane_read -> `warning <epoch>`
223
+ # hr_lane_publish repo-b feat/y allowed 0; hr_lane_read
224
+ # -> STILL `warning <epoch>`: worst-wins, and the stored
225
+ # record is not spent yet
226
+ # hr_lane_acquire repo-a; echo $? -> 0
227
+ # hr_lane_owner -> `repo-a <pid> <epoch>`
228
+ # hr_lane_acquire repo-b; echo $? -> 1 (a LIVE foreign owner)
229
+ # hr_lane_release repo-b; echo $? -> 1 (never ours to release)
230
+ # hr_lane_release repo-a; hr_lane_owner; echo $? -> 1, free
231
+ # a stale lane hr_lane_acquire repo-a
232
+ # printf 'repo-a 999999 1\n' > \
233
+ # "$(hr_lane_dir)/run-lane.lock/owner"
234
+ # hr_lane_acquire repo-b; echo $? -> 0, and
235
+ # echo "$HR_LANE_BROKEN_OWNER" -> names repo-a 999999
236
+
237
+ # ---------------------------------------------------------------------------
238
+ # The PATH policy — the one function here that runs BEFORE anything else works.
239
+ # ---------------------------------------------------------------------------
240
+
241
+ # Print — never export, never assign — a `PATH` that keeps everything the caller
242
+ # inherited and adds the usual locations it is missing. Takes no arguments,
243
+ # reads `PATH` and `HOME` from the environment, always returns 0.
244
+ #
245
+ # IT APPENDS; IT NEVER PROMOTES AND NEVER DEMOTES. Each fallback directory is
246
+ # added to a SUFFIX only when it is absent from the inherited `PATH`, and the
247
+ # suffix goes AFTER the inherited value. A directory already there keeps its
248
+ # inherited position and is never re-added, and the relative order of everything
249
+ # the caller inherited is unchanged — whatever the caller put first stays first.
250
+ # Prepending, even prepending only what is absent, inserts a directory ahead of a
251
+ # version-manager shims directory the caller deliberately put first, and the tool
252
+ # that then resolves is the system one.
253
+ #
254
+ # IT NAMES NO TOOLCHAIN. The list is locations, not languages: which toolchain a
255
+ # repository needs is its own configuration's business.
256
+ #
257
+ # BUILTIN-ONLY, AND THAT IS THE CONTRACT, NOT AN OPTIMIZATION. The caller runs
258
+ # this to MAKE `PATH` usable, so every step is a `case`, a parameter expansion or
259
+ # `printf` — an external command here would be the failure this function exists
260
+ # to prevent.
261
+ #
262
+ # NO EMPTY ENTRY SURVIVES. An empty `PATH` element means the current directory,
263
+ # and callers of this are daemons, so a leading, trailing or doubled colon in the
264
+ # inherited value is dropped (order and duplicates otherwise untouched). With an
265
+ # unset or empty `PATH` the result is the fallback list alone; with an unset or
266
+ # empty `HOME` the `$HOME/.local/bin` entry is skipped rather than rendered as a
267
+ # bare `/.local/bin`.
268
+ hr_path_with_fallbacks() {
269
+ local inherited="${PATH-}" home="${HOME-}" kept="" suffix="" rest entry dir
270
+ rest="$inherited"
271
+ while [ -n "$rest" ]; do
272
+ entry=${rest%%:*}
273
+ if [ "$entry" = "$rest" ]; then rest=""; else rest=${rest#*:}; fi
274
+ [ -n "$entry" ] || continue
275
+ if [ -n "$kept" ]; then kept="$kept:$entry"; else kept="$entry"; fi
276
+ done
277
+
278
+ # Unquoted on purpose: these six are fixed literals with no space among them.
279
+ # `$HOME/.local/bin` is NOT in this list — a home directory can contain a
280
+ # space, and the entry is conditional — so it is handled after the loop, which
281
+ # is also where the policy's order puts it.
282
+ for dir in /opt/homebrew/bin /usr/local/bin /usr/bin /bin /usr/sbin /sbin; do
283
+ case ":$kept:" in *":$dir:"*) continue ;; esac
284
+ case ":$suffix:" in *":$dir:"*) continue ;; esac
285
+ if [ -n "$suffix" ]; then suffix="$suffix:$dir"; else suffix="$dir"; fi
286
+ done
287
+
288
+ if [ -n "$home" ]; then
289
+ dir="${home%/}/.local/bin"
290
+ case ":$kept:" in
291
+ *":$dir:"*) ;;
292
+ *)
293
+ if [ -n "$suffix" ]; then suffix="$suffix:$dir"; else suffix="$dir"; fi
294
+ ;;
295
+ esac
296
+ fi
297
+
298
+ if [ -n "$kept" ] && [ -n "$suffix" ]; then
299
+ printf '%s\n' "$kept:$suffix"
300
+ elif [ -n "$kept" ]; then
301
+ printf '%s\n' "$kept"
302
+ else
303
+ printf '%s\n' "$suffix"
304
+ fi
305
+ return 0
306
+ }
307
+
308
+ # ---------------------------------------------------------------------------
309
+ # Resolution — the repository, its main checkout, and the directory holding
310
+ # both. Anchors are DERIVED, never remembered: the script this library is
311
+ # sourced into may be running in the main checkout or in any sibling worktree,
312
+ # and a fixed `dirname $0` walk only works for one repository layout.
313
+ # ---------------------------------------------------------------------------
314
+
315
+ # 0 when `jq` is on PATH. Its VERSION is not tested here — see the jq floor in
316
+ # the header: a pre-1.5 `jq` fails the load instead, which is the closed path.
317
+ hr_have_jq() {
318
+ command -v jq >/dev/null 2>&1
319
+ }
320
+
321
+ # Print the work-tree root of the repository containing <dir> (default `$PWD`);
322
+ # return 1, printing nothing, when <dir> is not inside a repository or `git` is
323
+ # not on PATH.
324
+ #
325
+ # The probe is issued BARE — the literal `rev-parse --show-toplevel`, with
326
+ # nothing added — because an unattended run's permission profile allow-lists it
327
+ # in that exact spelling, and a flag appended to it is a different string that
328
+ # stalls on a prompt.
329
+ hr_repo_root() {
330
+ local dir="${1-}" top
331
+ [ -n "$dir" ] || dir="${PWD-}"
332
+ [ -n "$dir" ] || dir="."
333
+ top=$(git -C "$dir" rev-parse --show-toplevel 2>/dev/null) || return 1
334
+ [ -n "$top" ] || return 1
335
+ printf '%s\n' "$top"
336
+ }
337
+
338
+ # Print the MAIN checkout of the repository containing <dir> (default `$PWD`) —
339
+ # the first entry of `git worktree list --porcelain`, which git documents as the
340
+ # main working tree. Return 1, printing nothing, when the probe does not answer.
341
+ #
342
+ # WHY THE MAIN CHECKOUT IS A SEPARATE ANCHOR: the inbox, the logs, the registry
343
+ # and the kill switch live in ONE place so every run is tailable and stoppable
344
+ # from it, while a run itself executes in a sibling worktree. A script that used
345
+ # its own checkout for both would write a second, invisible inbox per worktree.
346
+ hr_main_repo() {
347
+ local dir="${1-}" out line
348
+ [ -n "$dir" ] || dir="${PWD-}"
349
+ [ -n "$dir" ] || dir="."
350
+ out=$(git -C "$dir" worktree list --porcelain 2>/dev/null) || return 1
351
+ [ -n "$out" ] || return 1
352
+ while IFS= read -r line; do
353
+ case "$line" in
354
+ "worktree "*)
355
+ line=${line#worktree }
356
+ [ -n "$line" ] || return 1
357
+ printf '%s\n' "$line"
358
+ return 0
359
+ ;;
360
+ esac
361
+ done <<EOF
362
+ $out
363
+ EOF
364
+ return 1
365
+ }
366
+
367
+ # Print the directory above <repo_root> — where sibling worktrees live. Matches
368
+ # the CLI's `workRoot()` (the repository root's parent) byte for byte, because
369
+ # the generated permission profile is materialized from that one.
370
+ hr_work_root() {
371
+ local root="${1-}" parent
372
+ [ -n "$root" ] || return 1
373
+ while [ "$root" != "/" ] && [ "${root%/}" != "$root" ]; do root=${root%/}; done
374
+ parent=${root%/*}
375
+ [ -n "$parent" ] || parent="/"
376
+ printf '%s\n' "$parent"
377
+ }
378
+
379
+ # Print the repository directory's own name — the value `projectName` defaults
380
+ # to, and the CLI's `defaultProjectName()`.
381
+ hr_default_project_name() {
382
+ local root="${1-}"
383
+ [ -n "$root" ] || return 1
384
+ while [ "$root" != "/" ] && [ "${root%/}" != "$root" ]; do root=${root%/}; done
385
+ root=${root##*/}
386
+ [ -n "$root" ] || return 1
387
+ printf '%s\n' "$root"
388
+ }
389
+
390
+ # ---------------------------------------------------------------------------
391
+ # Configuration — one `jq` per process (see the cache note in the header), read
392
+ # at the resolved repository root and nowhere else.
393
+ # ---------------------------------------------------------------------------
394
+
395
+ # Set `HR_CFG_FILE` to the configuration path and return 0 when the repository
396
+ # has adopted the harness; return 1 (clearing it) when it has not. The variable
397
+ # form exists so the readers can run the jurisdiction test without a `$(…)`.
398
+ hr_config_file_var() {
399
+ local root="${1-}" f
400
+ HR_CFG_FILE=""
401
+ [ -n "$root" ] || return 1
402
+ f="${root%/}/harness.config.json"
403
+ [ -f "$f" ] && [ -r "$f" ] || return 1
404
+ HR_CFG_FILE="$f"
405
+ return 0
406
+ }
407
+
408
+ # Print the configuration path when the repository has adopted the harness;
409
+ # return 1 (printing nothing) when it has not. This is the jurisdiction test —
410
+ # "no such file" and "a file this account may not read" are one answer here,
411
+ # and `hr_config_load` is what separates them.
412
+ hr_config_file() {
413
+ hr_config_file_var "${1-}" || return 1
414
+ printf '%s\n' "$HR_CFG_FILE"
415
+ }
416
+
417
+ # Drop the cache. A one-shot wrapper never needs this; the watcher calls it at
418
+ # the top of every tick, so an edit to `harness.config.json` is picked up
419
+ # without restarting the daemon.
420
+ hr_config_reset() {
421
+ HR_CFG_PID=""
422
+ HR_CFG_ROOT=""
423
+ HR_CFG_STATE=""
424
+ HR_CFG_FILE=""
425
+ HR_CFG_SCALARS=""
426
+ HR_CFG_LISTS=""
427
+ HR_CFG_VALUE=""
428
+ }
429
+
430
+ # Reverse `@tsv`'s escaping into `HR_CFG_VALUE`. Left-to-right, one backslash at
431
+ # a time, so `\\t` decodes to a literal backslash followed by `t` rather than to
432
+ # a tab. Returns immediately on the overwhelmingly common backslash-free value.
433
+ hr_tsv_unescape_var() {
434
+ local s="${1-}" out="" head c
435
+ HR_CFG_VALUE="$s"
436
+ case "$s" in *\\*) ;; *) return 0 ;; esac
437
+ while :; do
438
+ head=${s%%\\*}
439
+ if [ "$head" = "$s" ]; then
440
+ out="$out$s"
441
+ break
442
+ fi
443
+ out="$out$head"
444
+ s=${s#"$head"\\}
445
+ c=${s%"${s#?}"}
446
+ case "$c" in
447
+ t) out="$out"$'\t' ;;
448
+ n) out="$out
449
+ " ;;
450
+ r) out="$out"$'\r' ;;
451
+ \\) out="$out\\" ;;
452
+ '') out="$out\\"; break ;;
453
+ *) out="$out\\$c" ;;
454
+ esac
455
+ s=${s#?}
456
+ done
457
+ HR_CFG_VALUE="$out"
458
+ return 0
459
+ }
460
+
461
+ # Load (or reuse) the parsed configuration for <root>.
462
+ #
463
+ # 0 loaded — the document is present, parses as an object, and
464
+ # carries the required `defaultBranch`
465
+ # 1 not adopted — there is no `harness.config.json` at <root>
466
+ # 2 unresolvable — it is there and could not be turned into an answer:
467
+ # unreadable, absent or pre-1.5 `jq`, invalid JSON, more
468
+ # than one JSON document, not an object, or no
469
+ # `defaultBranch`
470
+ #
471
+ # THE 1/2 SPLIT IS THE POINT. "Not adopted" is a repository this script was
472
+ # never meant to run in; "unresolvable" is the repository it WAS meant to run in
473
+ # with a configuration it cannot read. Both are closed for a mutating caller,
474
+ # and they need different messages.
475
+ #
476
+ # A MEMO THIS PROCESS DID NOT WRITE IS IGNORED. `HR_CFG_*` are ordinary shell
477
+ # variables and a child inherits its parent's exported ones, so an inherited
478
+ # `HR_CFG_SCALARS` would otherwise be read as this repository's configuration
479
+ # and the file on disk never opened — an environment that dictates
480
+ # `defaultBranch` and `protectedBranches` would turn a refusal into a permit.
481
+ # The memo is stamped with `$$` and reused only when the stamp is this shell's
482
+ # own. `$$` is unchanged inside `$(…)` and `( )`, so the inherit-into-a-subshell
483
+ # property the warm-up rests on is untouched.
484
+ hr_config_load() {
485
+ local root="${1-}" f out line tag rest nl tab
486
+ [ -n "$root" ] || return 2
487
+
488
+ if [ "${HR_CFG_PID-}" = "$$" ] && [ "${HR_CFG_ROOT-}" = "$root" ] && [ -n "${HR_CFG_STATE-}" ]; then
489
+ case "${HR_CFG_STATE-}" in
490
+ ok) return 0 ;;
491
+ absent) return 1 ;;
492
+ *) return 2 ;;
493
+ esac
494
+ fi
495
+
496
+ hr_config_reset
497
+ HR_CFG_PID=$$
498
+ HR_CFG_ROOT="$root"
499
+
500
+ f="${root%/}/harness.config.json"
501
+ if [ ! -e "$f" ]; then
502
+ HR_CFG_STATE="absent"
503
+ return 1
504
+ fi
505
+ HR_CFG_STATE="bad"
506
+ hr_config_file_var "$root" || return 2
507
+ hr_have_jq || return 2
508
+
509
+ # ONE `jq`, emitting `<tag>\t<key>\t<value>` per line through `@tsv`, which
510
+ # escapes the only four characters that could break the format (tab, newline,
511
+ # carriage return, backslash) and nothing else. `-n` plus `input` reads the
512
+ # FIRST document and counting what is left is what refuses a `jq` stream,
513
+ # which is not a valid `.json` file and which the schema cannot describe: a
514
+ # per-key read would have applied its filter to every document and silently
515
+ # unioned them. A key whose PARENT has the wrong type (`"commands": "x"`)
516
+ # yields `null` for that key alone via `try … catch`, so one key fails rather
517
+ # than the document. `null`, the string `"null"` and `""` are not emitted at
518
+ # all, so a reader reports them as "not set".
519
+ #
520
+ # `protectedBranches.present` records that the key was there AS AN ARRAY,
521
+ # which is what lets an explicitly EMPTY list read as a configured set rather
522
+ # than as an absent one.
523
+ out=$(jq -n -r '
524
+ def s($k; $v):
525
+ if $v == null then empty
526
+ else ($v | tostring) as $t
527
+ | if $t == "" or $t == "null" then empty else ["S", $k, $t] | @tsv end
528
+ end;
529
+ def l($k; $v):
530
+ if ($v | type) == "array"
531
+ then $v[] | ["L", $k, (if . == null then "null" else tostring end)] | @tsv
532
+ else empty
533
+ end;
534
+ input as $doc
535
+ | (reduce inputs as $extra (0; . + 1)) as $rest
536
+ | if $rest > 0 then error("more than one JSON document") else $doc end
537
+ | if type != "object" then error("not an object") else . end
538
+ | s("projectName"; try .projectName catch null),
539
+ s("defaultBranch"; try .defaultBranch catch null),
540
+ s("stateDir"; try .stateDir catch null),
541
+ s("appDir"; try .appDir catch null),
542
+ s("scriptsDir"; try .scriptsDir catch null),
543
+ s("githooksDir"; try .githooksDir catch null),
544
+ s("agentModel"; try .agentModel catch null),
545
+ s("agentEffort"; try .agentEffort catch null),
546
+ s("pushEnvPath"; try .pushEnvPath catch null),
547
+ s("qa.credentialsPath"; try .qa.credentialsPath catch null),
548
+ s("commands.typecheck"; try .commands.typecheck catch null),
549
+ s("commands.test"; try .commands.test catch null),
550
+ s("commands.build"; try .commands.build catch null),
551
+ s("commands.devServer"; try .commands.devServer catch null),
552
+ s("commands.depInstall"; try .commands.depInstall catch null),
553
+ s("protectedBranches.present";
554
+ try (if (.protectedBranches | type) == "array" then "1" else null end) catch null),
555
+ l("protectedBranches"; try .protectedBranches catch null)
556
+ ' "$HR_CFG_FILE" 2>/dev/null) || return 2
557
+
558
+ nl="
559
+ "
560
+ tab=$'\t'
561
+ while IFS= read -r line; do
562
+ [ -n "$line" ] || continue
563
+ tag=${line%%"$tab"*}
564
+ rest=${line#*"$tab"}
565
+ case "$tag" in
566
+ S) HR_CFG_SCALARS="$HR_CFG_SCALARS$nl$rest" ;;
567
+ L) HR_CFG_LISTS="$HR_CFG_LISTS$nl$rest" ;;
568
+ esac
569
+ done <<EOF
570
+ $out
571
+ EOF
572
+ HR_CFG_SCALARS="$HR_CFG_SCALARS$nl"
573
+ HR_CFG_LISTS="$HR_CFG_LISTS$nl"
574
+
575
+ # `defaultBranch` is required by the schema, so its absence is an
576
+ # unresolvable configuration rather than a defaultable one — and defaulting it
577
+ # here would put a remembered branch name into the protected set.
578
+ hr_cfg_scalar_var "defaultBranch" || return 2
579
+ [ -n "$HR_CFG_VALUE" ] || return 2
580
+
581
+ HR_CFG_STATE="ok"
582
+ return 0
583
+ }
584
+
585
+ # Set `HR_CFG_VALUE` to one cached scalar; return 1 when the key was not emitted
586
+ # (absent, `null`, `"null"` or empty). Lookup is a parameter expansion on
587
+ # `\n<key>\t`, which cannot false-match inside a value because a value carries
588
+ # no raw newline.
589
+ hr_cfg_scalar_var() {
590
+ local key="${1-}" rest nl tab
591
+ HR_CFG_VALUE=""
592
+ [ -n "$key" ] || return 1
593
+ nl="
594
+ "
595
+ tab=$'\t'
596
+ rest=${HR_CFG_SCALARS-}
597
+ case "$rest" in
598
+ *"$nl$key$tab"*) ;;
599
+ *) return 1 ;;
600
+ esac
601
+ rest=${rest#*"$nl$key$tab"}
602
+ rest=${rest%%"$nl"*}
603
+ hr_tsv_unescape_var "$rest"
604
+ [ -n "$HR_CFG_VALUE" ] || return 1
605
+ return 0
606
+ }
607
+
608
+ # Set `HR_CFG_VALUE` to the cached list elements, newline-joined in document
609
+ # order; return 1 when the key emitted no element at all.
610
+ hr_cfg_list_var() {
611
+ local key="${1-}" rest item out="" first=1 nl tab
612
+ HR_CFG_VALUE=""
613
+ [ -n "$key" ] || return 1
614
+ nl="
615
+ "
616
+ tab=$'\t'
617
+ rest=${HR_CFG_LISTS-}
618
+ while :; do
619
+ case "$rest" in
620
+ *"$nl$key$tab"*) ;;
621
+ *) break ;;
622
+ esac
623
+ rest=${rest#*"$nl$key$tab"}
624
+ item=${rest%%"$nl"*}
625
+ hr_tsv_unescape_var "$item"
626
+ if [ "$first" -eq 1 ]; then
627
+ out="$HR_CFG_VALUE"
628
+ first=0
629
+ else
630
+ out="$out
631
+ $HR_CFG_VALUE"
632
+ fi
633
+ done
634
+ [ "$first" -eq 0 ] || return 1
635
+ HR_CFG_VALUE="$out"
636
+ return 0
637
+ }
638
+
639
+ # The shared body of every scalar reader: print the configured value, else the
640
+ # supplied schema default, else return 1. Returns 2 — printing nothing — when
641
+ # the configuration could not be read, whether because there is none or because
642
+ # the one there does not parse.
643
+ hr_config_scalar() {
644
+ local root="${1-}" key="${2-}" default="${3-}"
645
+ [ -n "$key" ] || return 1
646
+ hr_config_load "$root" || return 2
647
+ if hr_cfg_scalar_var "$key"; then
648
+ printf '%s\n' "$HR_CFG_VALUE"
649
+ return 0
650
+ fi
651
+ [ -n "$default" ] || return 1
652
+ printf '%s\n' "$default"
653
+ return 0
654
+ }
655
+
656
+ # The same for a repo-relative directory key, with trailing slashes stripped so
657
+ # a caller can join with `/`. The value stays repo-relative: joining it to the
658
+ # repository root is the caller's step (`hr_state_path` is the one exception,
659
+ # and it says so).
660
+ hr_config_dir() {
661
+ local root="${1-}" key="${2-}" default="${3-}" value status
662
+ value=$(hr_config_scalar "$root" "$key" "$default")
663
+ status=$?
664
+ [ "$status" -eq 0 ] || return "$status"
665
+ while [ -n "$value" ] && [ "${value%/}" != "$value" ]; do value=${value%/}; done
666
+ [ -n "$value" ] || value="$default"
667
+ [ -n "$value" ] || return 1
668
+ printf '%s\n' "$value"
669
+ }
670
+
671
+ # ---------------------------------------------------------------------------
672
+ # Typed readers. Each takes <repo_root> first, prints its value on stdout, and
673
+ # applies that key's schema default and nothing else. 0 = a value; 1 = the key
674
+ # is not set and has no default; 2 = the configuration is unresolvable.
675
+ #
676
+ # There is deliberately NO reader for `clientEnvPrefix`: it is review vocabulary
677
+ # (the prefix a build tool requires before exposing a variable to a client
678
+ # bundle), no shipped script consumes it, and a reader nothing calls is dead
679
+ # surface. The worktree bootstrap's machine-local env symlink derives from
680
+ # `appDir` and a file-existence test instead.
681
+ # ---------------------------------------------------------------------------
682
+
683
+ # `stateDir` — every run artifact's repo-relative home. Schema default
684
+ # `sdlc-harness/`, normalized here to carry no trailing slash.
685
+ hr_state_dir() {
686
+ hr_config_dir "${1-}" stateDir "sdlc-harness"
687
+ }
688
+
689
+ # `scriptsDir` — where the generated wrappers, and this library, are written.
690
+ hr_scripts_dir() {
691
+ hr_config_dir "${1-}" scriptsDir "scripts"
692
+ }
693
+
694
+ # `appDir` — the repo-relative directory the application lives in. It anchors
695
+ # where the application is; it is not a working directory.
696
+ hr_app_dir() {
697
+ hr_config_dir "${1-}" appDir "."
698
+ }
699
+
700
+ # `githooksDir` — the committed git hooks the worktree bootstrap points
701
+ # `core.hooksPath` at.
702
+ hr_githooks_dir() {
703
+ hr_config_dir "${1-}" githooksDir "githooks"
704
+ }
705
+
706
+ # `projectName` — the stem of worktree directory names and of generated service
707
+ # labels. Falls back to the repository directory's own name, which is what the
708
+ # CLI seeds when the key is unset.
709
+ hr_project_name() {
710
+ local root="${1-}" value status
711
+ value=$(hr_config_scalar "$root" projectName "")
712
+ status=$?
713
+ [ "$status" -ne 2 ] || return 2
714
+ if [ "$status" -eq 0 ] && [ -n "$value" ]; then
715
+ printf '%s\n' "$value"
716
+ return 0
717
+ fi
718
+ hr_default_project_name "$root"
719
+ }
720
+
721
+ # `defaultBranch` — required, so this returns 2 rather than a remembered name
722
+ # when it is missing (`hr_config_load` has already refused such a document).
723
+ hr_default_branch() {
724
+ hr_config_scalar "${1-}" defaultBranch ""
725
+ }
726
+
727
+ # `agentModel` — what the outer loop passes to a headless run.
728
+ hr_agent_model() {
729
+ hr_config_scalar "${1-}" agentModel "opus"
730
+ }
731
+
732
+ # `agentEffort` — the reasoning-effort level the outer loop pins a headless run
733
+ # to. No schema default, so 1 means the adopter pinned none and the runtime
734
+ # applies its own per-model default; that is an ordinary project, not an error.
735
+ hr_agent_effort() {
736
+ hr_config_scalar "${1-}" agentEffort ""
737
+ }
738
+
739
+ # `pushEnvPath` — repo-relative path to the gitignored push-notification
740
+ # settings. No schema default: 1 means the adopter configured none, which is an
741
+ # ordinary project and not an error.
742
+ hr_push_env_path() {
743
+ hr_config_scalar "${1-}" pushEnvPath ""
744
+ }
745
+
746
+ # `qa.credentialsPath` — repo-relative path to the gitignored test-account
747
+ # credentials. No schema default, same as above.
748
+ hr_qa_creds_path() {
749
+ hr_config_scalar "${1-}" "qa.credentialsPath" ""
750
+ }
751
+
752
+ # The `commands.*` key set, in one place so a key added to the schema reaches
753
+ # every caller. Set as a variable rather than printed: callers test membership.
754
+ hr_command_keys_var() {
755
+ HR_CFG_COMMAND_KEYS='typecheck test build devServer depInstall'
756
+ }
757
+
758
+ # `commands.<key>` — the configured shell command for one verification or
759
+ # lifecycle step. 1 when that key is unset (a project with no build step is
760
+ # ordinary, and the caller prints one line and skips it) or when <key> is not
761
+ # one of the five; 2 when the configuration is unresolvable.
762
+ hr_command() {
763
+ local root="${1-}" key="${2-}" known=1 candidate
764
+ [ -n "$key" ] || return 1
765
+ hr_command_keys_var
766
+ for candidate in $HR_CFG_COMMAND_KEYS; do
767
+ # An `if` rather than a bare `[ … ] && …`: a trailing AND-list that fails is
768
+ # not in a `set -e`-exempt position, and a caller that sets `-e` would exit
769
+ # on the ordinary non-matching key.
770
+ if [ "$candidate" = "$key" ]; then known=0; fi
771
+ done
772
+ [ "$known" -eq 0 ] || return 1
773
+ hr_config_scalar "$root" "commands.$key" ""
774
+ }
775
+
776
+ # ---------------------------------------------------------------------------
777
+ # The protected-branch trichotomy.
778
+ # ---------------------------------------------------------------------------
779
+
780
+ # The schema default for `protectedBranches`. THE ONLY BRANCH NAME IN THIS FILE
781
+ # — see "THE ONE BRANCH NAME IN THIS FILE" in the header for why this one
782
+ # occurrence is a mirrored schema default rather than a remembered matcher: it
783
+ # is unioned with the configured set and can only ever widen a refusal.
784
+ hr_protected_default_var() {
785
+ HR_PROTECTED_DEFAULT='main'
786
+ }
787
+
788
+ # Print the resolved protected set, one glob pattern per line, de-duplicated:
789
+ # the configured list when the key is present, else that key's schema default,
790
+ # always unioned with `defaultBranch`. Return 2 (printing nothing) when the
791
+ # configuration cannot be resolved.
792
+ hr_protected_patterns() {
793
+ local root="${1-}" default_branch list pattern seen=""
794
+ hr_config_load "$root" || return 2
795
+ hr_cfg_scalar_var "defaultBranch" || return 2
796
+ default_branch="$HR_CFG_VALUE"
797
+
798
+ if hr_cfg_scalar_var "protectedBranches.present"; then
799
+ # Present, so the configured set wins even when it is EMPTY: an adopter who
800
+ # emptied the list configured "only the default branch", and re-adding the
801
+ # schema default there would protect a name they removed.
802
+ hr_cfg_list_var "protectedBranches" || HR_CFG_VALUE=""
803
+ list="$HR_CFG_VALUE"
804
+ else
805
+ hr_protected_default_var
806
+ list="$HR_PROTECTED_DEFAULT"
807
+ fi
808
+
809
+ while IFS= read -r pattern; do
810
+ [ -n "$pattern" ] || continue
811
+ case "$seen" in
812
+ *"|$pattern|"*) continue ;;
813
+ esac
814
+ seen="$seen|$pattern|"
815
+ printf '%s\n' "$pattern"
816
+ done <<EOF
817
+ $list
818
+ $default_branch
819
+ EOF
820
+ return 0
821
+ }
822
+
823
+ # Print the checked-out branch; print nothing on a detached HEAD or when the
824
+ # path is not a repository. Always returns 0 — emptiness is the signal, and what
825
+ # a detached HEAD *means* is the caller's decision.
826
+ hr_current_branch() {
827
+ local root="${1-}" branch
828
+ [ -n "$root" ] || return 0
829
+ branch=$(git -C "$root" symbolic-ref --short HEAD 2>/dev/null) || branch=""
830
+ if [ -n "$branch" ]; then printf '%s\n' "$branch"; fi
831
+ return 0
832
+ }
833
+
834
+ # 0 = protected, 1 = not protected, 2 = unresolvable. Patterns are matched as
835
+ # `case` globs, so one `release/*` entry covers its namespace.
836
+ #
837
+ # An EMPTY branch — a detached HEAD, or a repository that could not be read —
838
+ # returns 2 rather than 1: there is nothing to judge, and 1 would read as a
839
+ # permit.
840
+ hr_branch_is_protected() {
841
+ local root="${1-}" branch="${2-}" patterns pattern
842
+ patterns=$(hr_protected_patterns "$root") || return 2
843
+ [ -n "$branch" ] || return 2
844
+
845
+ while IFS= read -r pattern; do
846
+ [ -n "$pattern" ] || continue
847
+ case "$branch" in
848
+ $pattern) return 0 ;;
849
+ esac
850
+ done <<EOF
851
+ $patterns
852
+ EOF
853
+ return 1
854
+ }
855
+
856
+ # ---------------------------------------------------------------------------
857
+ # Anchors, the slug and the machine-local directory.
858
+ # ---------------------------------------------------------------------------
859
+
860
+ # `/` → `-`, which is all a worktree directory name needs: it is the source
861
+ # derivation, and widening it would move existing checkouts.
862
+ hr_sanitize_branch() {
863
+ local branch="${1-}"
864
+ [ -n "$branch" ] || return 1
865
+ printf '%s\n' "${branch//\//-}"
866
+ }
867
+
868
+ # `<work_root>/<projectName>-<sanitized branch>` — byte-identical to what the
869
+ # CLI's `worktreeGlob()` materializes into the generated permission profile, so
870
+ # a worktree this creates is one that profile matches. Derive it here; never
871
+ # re-build the string in a caller.
872
+ hr_worktree_dir() {
873
+ local root="${1-}" branch="${2-}" work name safe
874
+ work=$(hr_work_root "$root") || return 1
875
+ name=$(hr_project_name "$root") || return 2
876
+ safe=$(hr_sanitize_branch "$branch") || return 1
877
+ printf '%s/%s-%s\n' "${work%/}" "$name" "$safe"
878
+ }
879
+
880
+ # The MACHINE-UNIQUE identifier for this repository: the daemon label, the
881
+ # systemd unit name, the machine-level usage lane and every notification title
882
+ # key on it.
883
+ #
884
+ # It is the MAIN checkout's absolute path, lowercased, with every character
885
+ # outside `[a-z0-9]` replaced by `-`, runs collapsed, leading and trailing `-`
886
+ # stripped, and the result truncated to 64 characters KEEPING THE TAIL — the
887
+ # tail is the distinguishing part, since two checkouts on one machine usually
888
+ # share a long prefix. Deriving it from the MAIN checkout is what makes every
889
+ # worktree of one repository answer the same slug.
890
+ #
891
+ # DETERMINISTIC AND HASH-FREE ON PURPOSE: the CLI implements the same function
892
+ # in TypeScript — `repoSlug()` in `cli/src/daemon/units.ts`, exercised against
893
+ # this one by the agreement test in `cli/test/daemon.test.mjs` — and that test
894
+ # only stays honest while both are one readable line-for-line transformation. If
895
+ # you change a step here, change it there in the same commit. Truncation is
896
+ # LAST, so a truncated slug may begin with `-`; that is the published order and
897
+ # both halves keep it. Both halves are also ASCII-only: `é` is a `-`, never a
898
+ # letter (see the `LC_ALL=C` below and the CLI's ASCII-only case fold).
899
+ #
900
+ # When the main-checkout probe does not answer, the given root is used instead:
901
+ # a slug is an identifier, not a permission, so a derivable answer beats a
902
+ # refusal. The consequence is worth knowing — a worktree whose `git worktree
903
+ # list` fails answers a different slug than its own main checkout would.
904
+ hr_repo_slug() {
905
+ # `[!a-z0-9]` below is a COLLATION range, so this line is load-bearing: under a
906
+ # UTF-8 collation locale `a-z` also matches `é`, and the slug would keep it —
907
+ # disagreeing with the CLI's `repoSlug()` (which is ASCII-only) and putting a
908
+ # character outside `[a-z0-9-]` into a daemon label and a lane key. `local`
909
+ # restores the caller's locale on return, and bash applies an LC_* assignment
910
+ # immediately whether or not it is exported.
911
+ local LC_ALL=C
912
+ local root="${1-}" main_repo lower slug
913
+ [ -n "$root" ] || return 1
914
+ main_repo=$(hr_main_repo "$root") || main_repo="$root"
915
+ [ -n "$main_repo" ] || return 1
916
+
917
+ # bash 3.2 has no case-folding expansion, so this forks once. `LC_ALL=C` keeps
918
+ # the fold ASCII-only and byte-safe; any character it leaves alone is outside
919
+ # `[a-z0-9]` and becomes a `-` on the next line anyway.
920
+ lower=$(printf '%s' "$main_repo" | LC_ALL=C tr 'ABCDEFGHIJKLMNOPQRSTUVWXYZ' 'abcdefghijklmnopqrstuvwxyz')
921
+ slug=${lower//[!a-z0-9]/-}
922
+ while :; do
923
+ case "$slug" in
924
+ *--*) slug=${slug//--/-} ;;
925
+ *) break ;;
926
+ esac
927
+ done
928
+ slug=${slug#-}
929
+ slug=${slug%-}
930
+ [ -n "$slug" ] || return 1
931
+ if [ "${#slug}" -gt 64 ]; then
932
+ slug=${slug: -64}
933
+ fi
934
+ printf '%s\n' "$slug"
935
+ }
936
+
937
+ # `<repo_root>/<state_dir>/<relative>` — the one place a run-artifact path is
938
+ # built, with `stateDir` normalized. With no <relative>, the state directory
939
+ # itself. Returns 2 when the configuration is unresolvable, because a script
940
+ # that guessed here would watch, log to, or stop the wrong directory silently.
941
+ hr_state_path() {
942
+ local root="${1-}" relative="${2-}" state status
943
+ [ -n "$root" ] || return 1
944
+ state=$(hr_state_dir "$root")
945
+ status=$?
946
+ [ "$status" -eq 0 ] || return "$status"
947
+ root=${root%/}
948
+ while [ -n "$relative" ] && [ "${relative#/}" != "$relative" ]; do relative=${relative#/}; done
949
+ if [ -n "$relative" ]; then
950
+ printf '%s/%s/%s\n' "$root" "$state" "$relative"
951
+ else
952
+ printf '%s/%s\n' "$root" "$state"
953
+ fi
954
+ }
955
+
956
+ # The machine-local settings directory — one place an operator keeps values that
957
+ # vary per machine rather than per repository, which is why it is not a config
958
+ # key and not a repository file. Return 1 when there is no home to anchor it to.
959
+ hr_machine_config_dir() {
960
+ local base="${XDG_CONFIG_HOME-}"
961
+ [ -n "$base" ] || base="${HOME-}/.config"
962
+ case "$base" in
963
+ /.config) return 1 ;;
964
+ esac
965
+ [ -n "$base" ] || return 1
966
+ printf '%s/autonomous-sdlc-harness\n' "${base%/}"
967
+ }
968
+
969
+ # The push-notification credential files, in RESOLUTION ORDER, one per line and
970
+ # whether or not each exists — the caller sources the first that does:
971
+ #
972
+ # 1. the machine-local file, so credentials for a machine are kept once rather
973
+ # than per repository;
974
+ # 2. the repository's configured `pushEnvPath`, when there is one.
975
+ #
976
+ # UNLIKE THE TYPED READERS THIS DEGRADES INSTEAD OF REFUSING: it returns 0 and
977
+ # prints just the machine-local candidate when the configuration cannot be read.
978
+ # Delivering a notification is not a mutating action, and a run that has just
979
+ # refused something unresolvable is exactly the run whose operator most needs to
980
+ # hear about it.
981
+ hr_push_env_files() {
982
+ local root="${1-}" dir path
983
+ if dir=$(hr_machine_config_dir); then
984
+ printf '%s/push.env\n' "$dir"
985
+ fi
986
+ path=$(hr_push_env_path "$root") || return 0
987
+ [ -n "$path" ] || return 0
988
+ case "$path" in
989
+ /*) printf '%s\n' "$path" ;;
990
+ *) printf '%s/%s\n' "${root%/}" "$path" ;;
991
+ esac
992
+ return 0
993
+ }
994
+
995
+ # ---------------------------------------------------------------------------
996
+ # THE MACHINE-LEVEL USAGE LANE.
997
+ #
998
+ # WHAT IT EXISTS TO STOP. The rate-limit window these runs consume belongs to the
999
+ # ACCOUNT, while every pause and resume decision is made from per-repository
1000
+ # files. Two daemons on one machine therefore each compute a resume time as if
1001
+ # they were the sole consumer: repository A pauses, repository B keeps spending
1002
+ # the shared window, A's resume time arrives already stale, A wakes, re-hits its
1003
+ # own gate and re-pauses. The published record carries the one fact a repository
1004
+ # cannot see for itself: what the rest of the machine has already observed of the
1005
+ # shared window. The advisory lock is the separate, opt-in half — it serializes
1006
+ # which repository on this machine starts.
1007
+ #
1008
+ # TWO HALVES, AND NEITHER LIMIT IS DERIVABLE FROM THE OTHER. The record
1009
+ # COORDINATES and is on by default (`USAGE_LANE_STATE_ENABLED`, `1`); by itself
1010
+ # it defers nobody. The lock SERIALIZES which repository starts, and is opt-in
1011
+ # and off by default (`USAGE_LANE_LOCK_ENABLED`, `0`). The per-repository cap
1012
+ # bounds HOW MANY runs one repository has in flight: a repository holding the
1013
+ # lock still obeys its own cap, and one that cannot take it starts nothing
1014
+ # however much of its own capacity is free. Both knobs belong to the WATCHER —
1015
+ # this library reads neither, and reads no environment variable in place of a
1016
+ # configured value.
1017
+ #
1018
+ # TWO ARTIFACTS, BOTH UNDER `hr_lane_dir` (created 0700, outside every
1019
+ # repository):
1020
+ #
1021
+ # usage-state.json the worst assessment any watcher has published:
1022
+ # {"schema":1,"state":"allowed|warning|overage|rejected|unknown",
1023
+ # "resume_at":<epoch>,"observed_at":<epoch>,
1024
+ # "observed_by":{"repo":"<slug>","branch":"<branch>"}}
1025
+ # Written atomically — a temp file in the same directory plus
1026
+ # a rename — so a reader sees the old record or the new one
1027
+ # and never a half-written line.
1028
+ # run-lane.lock a DIRECTORY holding one `owner` file whose single line is
1029
+ # `<slug> <pid> <acquired_at>`.
1030
+ #
1031
+ # THE LOCK IS A DIRECTORY BECAUSE `mkdir` IS THE ATOMIC PRIMITIVE AVAILABLE HERE.
1032
+ # `flock(1)` is a util-linux program and is absent on macOS; the shell's own
1033
+ # `set -o noclobber` redirection is defeated by an inherited `-C`. `mkdir` fails
1034
+ # when the name exists, on both supported platforms and on bash 3.2, which is the
1035
+ # whole test-and-set this needs.
1036
+ #
1037
+ # MERGED WORST-WINS, AND "WORSE" IS (STATE, THEN RESUME TIME). A publisher
1038
+ # replaces the stored record when its own state ranks higher, when the state
1039
+ # ranks the SAME and its resume time is later (the same state binding for longer
1040
+ # is the worse fact for every consumer), or when the stored record is SPENT — its
1041
+ # `resume_at` has passed, or it reported no resume time at all and has aged past
1042
+ # `HR_LANE_STATE_MAX_AGE_SECS`. Without that last clause a record that named no
1043
+ # reset would pin the file for the life of the machine.
1044
+ #
1045
+ # FAIL OPEN ON THE STATE, CLOSED ON THE LANE. An absent, unreadable or
1046
+ # unparseable `usage-state.json` reads as `unknown 0`, which defers nobody:
1047
+ # pausing on an unreadable file would put a machine-level fault in charge of run
1048
+ # state, and each repository's own gate is what pauses its runs. A lock directory
1049
+ # that cannot be read or created is NOT assumed free: `hr_lane_acquire` returns
1050
+ # non-zero, and the caller defers exactly as it defers for its own cap. The
1051
+ # closed half is reached only when the caller has enabled the lock.
1052
+ #
1053
+ # THE STALE-BREAKER, AND WHY IT REPORTS THROUGH A VARIABLE. A lock is broken and
1054
+ # re-taken when its owning pid no longer exists AND its record has aged past
1055
+ # `HR_LANE_LOCK_STALE_SECS`, or — whatever that pid says — when the record has
1056
+ # aged past `HR_LANE_LOCK_MAX_AGE_SECS`. NEITHER SIGNAL IS SUFFICIENT ON ITS
1057
+ # OWN. A dead pid does not mean an idle machine: a one-shot pass takes the lane,
1058
+ # starts a run that outlives it and exits, so its pid is gone within the second
1059
+ # while its run is still going — breaking on that alone would put two
1060
+ # repositories on the machine every time. A live pid does not mean a live
1061
+ # holder either, since a pid is recycled. Breaking renames the directory aside
1062
+ # before removing it, so a competing breaker that has already re-created the
1063
+ # lock cannot have its fresh `owner` deleted by this one — and, for the same
1064
+ # reason, a lock too young to be stale is held even when its `owner` file has
1065
+ # not been written yet. The previous owner is reported in
1066
+ # `HR_LANE_BROKEN_OWNER` for the caller to log, because this file prints nothing.
1067
+ #
1068
+ # THE THREE CEILINGS ARE THE ONLY ENVIRONMENT VALUES THAT CARRY POLICY HERE. The
1069
+ # file's other environment reads are location anchors, not policy:
1070
+ # `XDG_STATE_HOME` and `HOME` in `hr_lane_dir`, `XDG_CONFIG_HOME` and `HOME` in
1071
+ # `hr_machine_config_dir`, `PWD` in `hr_repo_root` and `hr_main_repo`. The
1072
+ # ceilings are machine-scoped policy with no configuration key:
1073
+ #
1074
+ # HR_LANE_STATE_MAX_AGE_SECS 21600 when a PUBLISHED RECORD THAT NAMED NO
1075
+ # reset time stops holding the merge — one
1076
+ # 5-hour window plus slack. Applied only to
1077
+ # such a record, so a legitimately long
1078
+ # weekly window is never aged out from
1079
+ # under its own `resume_at`.
1080
+ # HR_LANE_LOCK_STALE_SECS 900 how long a lock whose owner pid is GONE
1081
+ # is still honored — long enough to cover a
1082
+ # holder that runs as a series of one-shot
1083
+ # passes rather than as a daemon, short
1084
+ # enough that a crashed holder frees the
1085
+ # machine in minutes.
1086
+ # HR_LANE_LOCK_MAX_AGE_SECS 86400 when a lock is broken regardless of its
1087
+ # pid — far longer than any single run,
1088
+ # since a holder releases the lane as soon
1089
+ # as it has nothing live.
1090
+ # ---------------------------------------------------------------------------
1091
+
1092
+ # The machine-local lane directory — one per machine, deliberately not per
1093
+ # repository and deliberately not a configuration key: the thing it coordinates
1094
+ # is an account budget that no single repository owns. Return 1 when there is no
1095
+ # home to anchor it to. The location stays redirectable through
1096
+ # `XDG_STATE_HOME`.
1097
+ hr_lane_dir() {
1098
+ local base="${XDG_STATE_HOME-}"
1099
+ [ -n "$base" ] || base="${HOME-}/.local/state"
1100
+ case "$base" in
1101
+ /.local/state) return 1 ;;
1102
+ esac
1103
+ [ -n "$base" ] || return 1
1104
+ printf '%s/autonomous-sdlc-harness\n' "${base%/}"
1105
+ }
1106
+
1107
+ # The two artifact paths, so no caller re-joins either string.
1108
+ hr_lane_state_file() {
1109
+ local dir
1110
+ dir=$(hr_lane_dir) || return 1
1111
+ printf '%s/usage-state.json\n' "$dir"
1112
+ }
1113
+
1114
+ hr_lane_lock_dir() {
1115
+ local dir
1116
+ dir=$(hr_lane_dir) || return 1
1117
+ printf '%s/run-lane.lock\n' "$dir"
1118
+ }
1119
+
1120
+ # Print the lane directory, creating it 0700 when it is not there. Return 1 —
1121
+ # printing nothing — when it cannot be created or cannot be written to, which is
1122
+ # the fail-CLOSED half of the contract: every writer below starts here.
1123
+ hr_lane_mkdir() {
1124
+ local dir
1125
+ dir=$(hr_lane_dir) || return 1
1126
+ if [ ! -d "$dir" ]; then
1127
+ # The umask makes the directory private from the instant it exists rather
1128
+ # than a `chmod` later; the `chmod` narrows a directory created under a
1129
+ # laxer umask by a version of this file that did not.
1130
+ (umask 077 && mkdir -p "$dir") 2>/dev/null || return 1
1131
+ chmod 700 "$dir" 2>/dev/null || :
1132
+ fi
1133
+ [ -d "$dir" ] && [ -w "$dir" ] || return 1
1134
+ printf '%s\n' "$dir"
1135
+ }
1136
+
1137
+ # A path's modification time as an epoch, or 0. THE FALLBACK IS CHOSEN ON THE
1138
+ # VALUE, NOT THE EXIT STATUS: `-f` means `--file-system` to GNU `stat`, which
1139
+ # therefore succeeds while printing something that is not a timestamp. The only
1140
+ # `stat` in this file, and it exists for one case — a lock directory whose
1141
+ # `owner` file is missing or unreadable, where the directory's own mtime is the
1142
+ # only acquisition time there is.
1143
+ hr_lane_mtime() {
1144
+ local path="${1-}" m=""
1145
+ [ -n "$path" ] && [ -e "$path" ] || {
1146
+ printf '0\n'
1147
+ return 0
1148
+ }
1149
+ m=$(stat -f %m "$path" 2>/dev/null)
1150
+ case "$m" in '' | *[!0-9]*) m="" ;; esac
1151
+ if [ -z "$m" ]; then
1152
+ m=$(stat -c %Y "$path" 2>/dev/null)
1153
+ case "$m" in '' | *[!0-9]*) m="" ;; esac
1154
+ fi
1155
+ [ -n "$m" ] || m=0
1156
+ printf '%s\n' "$m"
1157
+ }
1158
+
1159
+ # Set `HR_LANE_RANK` to a lane state's severity. A state this vocabulary does not
1160
+ # know ranks 0 — "no information" — so a hand-edited or truncated value can never
1161
+ # defer anybody, which is the fail-open rule applied at the one place it decides
1162
+ # anything.
1163
+ hr_lane_rank_var() {
1164
+ case "${1-}" in
1165
+ rejected) HR_LANE_RANK=4 ;;
1166
+ overage) HR_LANE_RANK=3 ;;
1167
+ warning) HR_LANE_RANK=2 ;;
1168
+ allowed) HR_LANE_RANK=1 ;;
1169
+ *) HR_LANE_RANK=0 ;;
1170
+ esac
1171
+ }
1172
+
1173
+ # Set `HR_LANE_STATE`, `HR_LANE_RESUME_AT`, `HR_LANE_OBSERVED_AT` and
1174
+ # `HR_LANE_OBSERVED_REPO` from the published record. ALWAYS returns 0 with a
1175
+ # usable answer — `unknown`, `0`, `0`, `` — when the record is absent,
1176
+ # unreadable, not an object, not JSON at all, or `jq` is missing. Every field is
1177
+ # validated after it is read, so a hand-written file cannot put a value the rest
1178
+ # of the lane does not understand into a comparison.
1179
+ hr_lane_read_var() {
1180
+ local file out st ra oa repo
1181
+ HR_LANE_STATE="unknown"
1182
+ HR_LANE_RESUME_AT=0
1183
+ HR_LANE_OBSERVED_AT=0
1184
+ HR_LANE_OBSERVED_REPO=""
1185
+ file=$(hr_lane_state_file) || return 0
1186
+ [ -f "$file" ] && [ -r "$file" ] || return 0
1187
+ hr_have_jq || return 0
1188
+ out=$(jq -r '
1189
+ if type == "object"
1190
+ then "\(.state // "unknown") \(.resume_at // 0) \(.observed_at // 0) \(.observed_by.repo // "")"
1191
+ else empty
1192
+ end' "$file" 2>/dev/null) || return 0
1193
+ [ -n "$out" ] || return 0
1194
+ st=""
1195
+ ra=""
1196
+ oa=""
1197
+ repo=""
1198
+ read -r st ra oa repo <<EOF
1199
+ $out
1200
+ EOF
1201
+ case "$st" in
1202
+ allowed | warning | overage | rejected | unknown) ;;
1203
+ *) st="unknown" ;;
1204
+ esac
1205
+ # A non-integer epoch — a float, a quoted string, a truncated write — reads as
1206
+ # "no time reported" rather than as an error: the consumers below compare it
1207
+ # with `-gt`, where a non-numeric operand aborts the pass.
1208
+ case "$ra" in '' | *[!0-9]*) ra=0 ;; esac
1209
+ case "$oa" in '' | *[!0-9]*) oa=0 ;; esac
1210
+ HR_LANE_STATE="$st"
1211
+ HR_LANE_RESUME_AT="$ra"
1212
+ HR_LANE_OBSERVED_AT="$oa"
1213
+ HR_LANE_OBSERVED_REPO="$repo"
1214
+ return 0
1215
+ }
1216
+
1217
+ # Echo `"<state> <resume_at>"` — the published record, or `unknown 0` when there
1218
+ # is nothing usable to publish from. The shape deliberately matches the per-repo
1219
+ # gate's own assessment output, so a caller reads both the same way.
1220
+ hr_lane_read() {
1221
+ hr_lane_read_var
1222
+ printf '%s %s\n' "$HR_LANE_STATE" "$HR_LANE_RESUME_AT"
1223
+ }
1224
+
1225
+ # hr_lane_publish <slug> <branch> <state> <resume_at>
1226
+ #
1227
+ # Merge this repository's assessment into the shared record, worst-wins (see the
1228
+ # section header for what "worse" means and when a stored record is spent).
1229
+ # Returns 0 when the file now reflects the worst known observation — INCLUDING
1230
+ # the case where the stored record was already worse and was deliberately left
1231
+ # alone — and 1 only when the lane directory or the write could not be had.
1232
+ #
1233
+ # `<state>` outside the vocabulary is published as `unknown`, and both identity
1234
+ # fields are reduced to a safe character set: this writes JSON with `printf`
1235
+ # rather than `jq`, so a value that could carry a quote or a backslash into the
1236
+ # document is not written at all.
1237
+ hr_lane_publish() {
1238
+ local slug="${1-}" branch="${2-}" state="${3-}" resume_at="${4-}"
1239
+ local dir file tmp now new_rank old_rank replace=0 ceiling
1240
+ [ -n "$slug" ] || return 1
1241
+ case "$state" in
1242
+ allowed | warning | overage | rejected | unknown) ;;
1243
+ *) state="unknown" ;;
1244
+ esac
1245
+ case "$resume_at" in '' | *[!0-9]*) resume_at=0 ;; esac
1246
+ slug=${slug//[!a-zA-Z0-9._-]/-}
1247
+ branch=${branch//[!a-zA-Z0-9._\/-]/-}
1248
+ now=$(date +%s 2>/dev/null) || now=0
1249
+ case "$now" in '' | *[!0-9]*) now=0 ;; esac
1250
+
1251
+ dir=$(hr_lane_mkdir) || return 1
1252
+ file="$dir/usage-state.json"
1253
+
1254
+ hr_lane_read_var
1255
+ hr_lane_rank_var "$state"
1256
+ new_rank=$HR_LANE_RANK
1257
+ hr_lane_rank_var "$HR_LANE_STATE"
1258
+ old_rank=$HR_LANE_RANK
1259
+ ceiling=${HR_LANE_STATE_MAX_AGE_SECS:-21600}
1260
+ case "$ceiling" in '' | *[!0-9]*) ceiling=21600 ;; esac
1261
+
1262
+ if [ ! -f "$file" ]; then
1263
+ replace=1
1264
+ elif [ "$new_rank" -gt "$old_rank" ]; then
1265
+ replace=1
1266
+ elif [ "$new_rank" -eq "$old_rank" ] && [ "$resume_at" -gt "$HR_LANE_RESUME_AT" ]; then
1267
+ replace=1
1268
+ elif [ "$HR_LANE_RESUME_AT" -gt 0 ] && [ "$now" -ge "$HR_LANE_RESUME_AT" ]; then
1269
+ replace=1
1270
+ elif [ "$HR_LANE_RESUME_AT" -le 0 ] && [ "$now" -ge $((HR_LANE_OBSERVED_AT + ceiling)) ]; then
1271
+ replace=1
1272
+ fi
1273
+ [ "$replace" -eq 1 ] || return 0
1274
+
1275
+ # Same directory, so the rename below is within one filesystem and therefore
1276
+ # atomic; a reader mid-publish sees the previous record, never a partial one.
1277
+ tmp=$(mktemp "$dir/.usage-state.XXXXXX" 2>/dev/null) || tmp="$dir/.usage-state.$$.tmp"
1278
+ chmod 600 "$tmp" 2>/dev/null || :
1279
+ if ! printf '{"schema":1,"state":"%s","resume_at":%s,"observed_at":%s,"observed_by":{"repo":"%s","branch":"%s"}}\n' \
1280
+ "$state" "$resume_at" "$now" "$slug" "$branch" >"$tmp" 2>/dev/null; then
1281
+ rm -f "$tmp" 2>/dev/null || :
1282
+ return 1
1283
+ fi
1284
+ if ! mv "$tmp" "$file" 2>/dev/null; then
1285
+ rm -f "$tmp" 2>/dev/null || :
1286
+ return 1
1287
+ fi
1288
+ return 0
1289
+ }
1290
+
1291
+ # Write the owner record of a lock we hold or have just created: `<slug> <pid>
1292
+ # <acquired_at>`, through a temp file and a rename so a reader never sees a
1293
+ # half-written line. `$$` is this shell's pid and is unchanged inside `$(…)`, so
1294
+ # a caller that reaches the lane through a command substitution would record its
1295
+ # PARENT's pid — which is why every lane call site invokes these unsubstituted.
1296
+ hr_lane_write_owner() {
1297
+ local lock="${1-}" slug="${2-}" at="${3-}" tmp
1298
+ [ -n "$lock" ] && [ -n "$slug" ] || return 1
1299
+ case "$at" in '' | *[!0-9]*) at=0 ;; esac
1300
+ tmp="$lock/.owner.$$"
1301
+ if ! printf '%s %s %s\n' "$slug" "$$" "$at" >"$tmp" 2>/dev/null; then
1302
+ rm -f "$tmp" 2>/dev/null || :
1303
+ return 1
1304
+ fi
1305
+ if ! mv "$tmp" "$lock/owner" 2>/dev/null; then
1306
+ rm -f "$tmp" 2>/dev/null || :
1307
+ return 1
1308
+ fi
1309
+ return 0
1310
+ }
1311
+
1312
+ # Set `HR_LANE_OWNER_SLUG`, `HR_LANE_OWNER_PID` and `HR_LANE_OWNER_AT` and return
1313
+ # 0 when the lane IS HELD; return 1 (with all three cleared) when it is free or
1314
+ # when the lane directory cannot be resolved.
1315
+ #
1316
+ # A lock whose `owner` file is missing or unreadable is still HELD — the
1317
+ # directory is the lock, not the file inside it — and the directory's own mtime
1318
+ # stands in for the acquisition time so the ceiling can still break it. Reading
1319
+ # it as free would hand two repositories the lane at once, which is the one
1320
+ # outcome this whole mechanism exists to prevent.
1321
+ hr_lane_owner_var() {
1322
+ local lock line
1323
+ HR_LANE_OWNER_SLUG=""
1324
+ HR_LANE_OWNER_PID=""
1325
+ # Empty, not 0: the mtime fallback below is applied to an EMPTY value, and a
1326
+ # placeholder 0 here would look like an acquisition time that was read.
1327
+ HR_LANE_OWNER_AT=""
1328
+ lock=$(hr_lane_lock_dir) || return 1
1329
+ [ -d "$lock" ] || return 1
1330
+ line=""
1331
+ if [ -r "$lock/owner" ]; then
1332
+ IFS= read -r line <"$lock/owner" 2>/dev/null || line=""
1333
+ fi
1334
+ if [ -n "$line" ]; then
1335
+ read -r HR_LANE_OWNER_SLUG HR_LANE_OWNER_PID HR_LANE_OWNER_AT <<EOF
1336
+ $line
1337
+ EOF
1338
+ fi
1339
+ case "${HR_LANE_OWNER_PID-}" in '' | *[!0-9]*) HR_LANE_OWNER_PID="" ;; esac
1340
+ case "${HR_LANE_OWNER_AT-}" in '' | *[!0-9]*) HR_LANE_OWNER_AT="" ;; esac
1341
+ [ -n "$HR_LANE_OWNER_AT" ] || HR_LANE_OWNER_AT=$(hr_lane_mtime "$lock")
1342
+ return 0
1343
+ }
1344
+
1345
+ # Echo `"<slug> <pid> <acquired_at>"` for a HELD lane, using `-` for a field the
1346
+ # owner record did not carry; return 1, printing nothing, when the lane is free
1347
+ # or unresolvable. A pure reader: it neither creates the lane directory nor
1348
+ # breaks anything.
1349
+ hr_lane_owner() {
1350
+ hr_lane_owner_var || return 1
1351
+ printf '%s %s %s\n' "${HR_LANE_OWNER_SLUG:--}" "${HR_LANE_OWNER_PID:--}" "${HR_LANE_OWNER_AT:-0}"
1352
+ }
1353
+
1354
+ # hr_lane_acquire <slug>
1355
+ #
1356
+ # 0 the lane is ours — taken now, already ours, or taken after breaking a
1357
+ # stale lock (in which case `HR_LANE_BROKEN_OWNER` names the previous owner
1358
+ # for the caller to log)
1359
+ # 1 it is held by a foreign owner the breaker's two ceilings still honor, or
1360
+ # the lane could not be reached at all — an unwritable directory, a lost
1361
+ # race with another breaker, no home
1362
+ #
1363
+ # IT NEVER BLOCKS AND NEVER SLEEPS. The caller is a poll loop: waiting inside
1364
+ # this function would stall every other pass of that loop behind a lock some
1365
+ # other machine-local daemon is holding for hours. A caller that cannot take the
1366
+ # lane defers, exactly as it defers for its own concurrency cap, and asks again
1367
+ # on its next pass.
1368
+ hr_lane_acquire() {
1369
+ local slug="${1-}" dir lock now ceiling stale_ceiling past_ceiling stale live=0
1370
+ HR_LANE_BROKEN_OWNER=""
1371
+ [ -n "$slug" ] || return 1
1372
+ dir=$(hr_lane_mkdir) || return 1
1373
+ lock="$dir/run-lane.lock"
1374
+ now=$(date +%s 2>/dev/null) || now=0
1375
+ case "$now" in '' | *[!0-9]*) now=0 ;; esac
1376
+
1377
+ # The test-and-set. `mkdir` fails when the name exists, atomically, which is
1378
+ # the whole primitive this rests on.
1379
+ if mkdir "$lock" 2>/dev/null; then
1380
+ if hr_lane_write_owner "$lock" "$slug" "$now"; then
1381
+ return 0
1382
+ fi
1383
+ rmdir "$lock" 2>/dev/null || :
1384
+ return 1
1385
+ fi
1386
+
1387
+ # Anything other than "it already exists" is a lane this process cannot reason
1388
+ # about, and an unreachable lane is never assumed free.
1389
+ [ -d "$lock" ] || return 1
1390
+ hr_lane_owner_var || return 1
1391
+
1392
+ if [ "$HR_LANE_OWNER_SLUG" = "$slug" ]; then
1393
+ # Already ours. Re-stamp it, so that after a daemon restart the pid the
1394
+ # liveness breaker tests is the live one rather than its predecessor's.
1395
+ hr_lane_write_owner "$lock" "$slug" "$now" || :
1396
+ return 0
1397
+ fi
1398
+
1399
+ # The two ceilings of the stale-breaker (see the section header). The pid only
1400
+ # chooses WHICH one applies: a live owner is held until the long ceiling, a
1401
+ # vanished one until the short ceiling — never instantly, because a one-shot
1402
+ # pass that started a run and exited is a dead pid with a live run behind it.
1403
+ ceiling=${HR_LANE_LOCK_MAX_AGE_SECS:-86400}
1404
+ case "$ceiling" in '' | *[!0-9]*) ceiling=86400 ;; esac
1405
+ if [ -n "$HR_LANE_OWNER_PID" ] && kill -0 "$HR_LANE_OWNER_PID" 2>/dev/null; then
1406
+ live=1
1407
+ else
1408
+ stale_ceiling=${HR_LANE_LOCK_STALE_SECS:-900}
1409
+ case "$stale_ceiling" in '' | *[!0-9]*) stale_ceiling=900 ;; esac
1410
+ if [ "$stale_ceiling" -lt "$ceiling" ]; then
1411
+ ceiling="$stale_ceiling"
1412
+ fi
1413
+ fi
1414
+ # Age is evidence only when both timestamps are real. A record whose
1415
+ # acquisition time could not be read at all — no `owner` file AND no usable
1416
+ # directory mtime — is not breakable by age: the lane is never taken on a
1417
+ # guess. `live` is not tested again here; it has already chosen the ceiling.
1418
+ past_ceiling=0
1419
+ if [ "$now" -gt 0 ] && [ "$HR_LANE_OWNER_AT" -gt 0 ] && [ $((now - HR_LANE_OWNER_AT)) -ge "$ceiling" ]; then
1420
+ past_ceiling=1
1421
+ fi
1422
+ if [ "$past_ceiling" -eq 0 ]; then
1423
+ return 1
1424
+ fi
1425
+
1426
+ # Break it. RENAMED ASIDE FIRST, then emptied: a second breaker that has
1427
+ # already re-created the lock must not have its fresh `owner` removed by this
1428
+ # one. Debris under `run-lane.lock.stale.*` means an operator put something
1429
+ # else inside the lock directory — visible, and harmless.
1430
+ stale="$lock.stale.$$"
1431
+ if [ -e "$stale" ]; then
1432
+ # A leftover from an earlier break by this same pid. Cleared first, because
1433
+ # `mv` into an EXISTING directory would move the lock INSIDE it instead of
1434
+ # renaming it, and the lock would then still be there.
1435
+ rm -f "$stale/owner" 2>/dev/null || :
1436
+ rmdir "$stale" 2>/dev/null || :
1437
+ if [ -e "$stale" ]; then
1438
+ return 1
1439
+ fi
1440
+ fi
1441
+ HR_LANE_BROKEN_OWNER="${HR_LANE_OWNER_SLUG:--} ${HR_LANE_OWNER_PID:--} ${HR_LANE_OWNER_AT:-0}"
1442
+ if ! mv "$lock" "$stale" 2>/dev/null; then
1443
+ HR_LANE_BROKEN_OWNER=""
1444
+ return 1
1445
+ fi
1446
+ rm -f "$stale/owner" 2>/dev/null || :
1447
+ rmdir "$stale" 2>/dev/null || :
1448
+ if mkdir "$lock" 2>/dev/null; then
1449
+ if hr_lane_write_owner "$lock" "$slug" "$now"; then
1450
+ return 0
1451
+ fi
1452
+ # Took the directory and could not name an owner in it: remove it rather
1453
+ # than leave an unattributable lock the ceilings would honor.
1454
+ rmdir "$lock" 2>/dev/null || :
1455
+ fi
1456
+ # Another breaker won the re-take, or the owner could not be written. Nothing
1457
+ # was granted, so nothing is reported.
1458
+ HR_LANE_BROKEN_OWNER=""
1459
+ return 1
1460
+ }
1461
+
1462
+ # hr_lane_release <slug>
1463
+ #
1464
+ # 0 the lane is not held by <slug> any more — released now, or already free
1465
+ # 1 it is held by SOMEBODY ELSE (nothing was touched), or the removal failed
1466
+ #
1467
+ # ONLY THE OWNER RELEASES. A watcher that cleared a lane it does not own would
1468
+ # put two repositories on the machine at once, which is exactly what the lock is
1469
+ # for; a lane held by a dead foreign owner is `hr_lane_acquire`'s business to
1470
+ # break, not this one's.
1471
+ hr_lane_release() {
1472
+ local slug="${1-}" lock
1473
+ [ -n "$slug" ] || return 1
1474
+ lock=$(hr_lane_lock_dir) || return 1
1475
+ [ -d "$lock" ] || return 0
1476
+ hr_lane_owner_var || return 0
1477
+ [ "$HR_LANE_OWNER_SLUG" = "$slug" ] || return 1
1478
+ rm -f "$lock/owner" 2>/dev/null || :
1479
+ rmdir "$lock" 2>/dev/null || return 1
1480
+ return 0
1481
+ }