entropy-machines 0.1.1

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 (51) hide show
  1. package/LICENSE +93 -0
  2. package/README.md +68 -0
  3. package/agents/isolated-worker.md +128 -0
  4. package/agents/verifier.md +158 -0
  5. package/bin/dispatch +700 -0
  6. package/bin/doclint +460 -0
  7. package/bin/drain +507 -0
  8. package/bin/drain-pick.py +168 -0
  9. package/bin/drain-prompt.md +67 -0
  10. package/bin/drain-run.sh +342 -0
  11. package/bin/entropy-machines-init +285 -0
  12. package/bin/handoff +1151 -0
  13. package/bin/init +232 -0
  14. package/bin/post-fold-audit +377 -0
  15. package/bin/serve +724 -0
  16. package/bin/status +208 -0
  17. package/bin/tracker +153 -0
  18. package/docs/AGENT-QUICKSTART.md +86 -0
  19. package/docs/CONFIG.md +68 -0
  20. package/docs/NPM.md +91 -0
  21. package/docs/SERVE.md +74 -0
  22. package/docs/TRACKER-ADAPTER.md +66 -0
  23. package/doctrine/HANDOFF-PROMPT.md +63 -0
  24. package/doctrine/README.md +62 -0
  25. package/doctrine/ROLES.md +27 -0
  26. package/doctrine/WORKFLOW.md +87 -0
  27. package/hooks/commit-msg +24 -0
  28. package/hooks/post-checkout +354 -0
  29. package/hooks/pre-commit +33 -0
  30. package/lib/PRD-001-orientation.html +1180 -0
  31. package/lib/REPORT-TEMPLATE.html +413 -0
  32. package/lib/changelog-collate.mjs +328 -0
  33. package/lib/changelog-guard.sh +157 -0
  34. package/lib/changelog-new.mjs +70 -0
  35. package/lib/config.mjs +283 -0
  36. package/lib/config.py +317 -0
  37. package/lib/doc-template.html +807 -0
  38. package/lib/entropy-drain.plist.in +59 -0
  39. package/lib/entropy-drain.service.in +53 -0
  40. package/lib/entropy-drain.timer.in +36 -0
  41. package/lib/fail-first.mjs +901 -0
  42. package/lib/handoff-guard.sh +623 -0
  43. package/lib/install-hooks.sh +169 -0
  44. package/lib/notes.py +675 -0
  45. package/lib/preflight-tree.mjs +82 -0
  46. package/lib/roots.sh +212 -0
  47. package/lib/themes/daylight.css +84 -0
  48. package/lib/themes/high-contrast.css +36 -0
  49. package/lib/tracker-file +333 -0
  50. package/lib/tracker-view.py +784 -0
  51. package/package.json +38 -0
package/bin/drain ADDED
@@ -0,0 +1,507 @@
1
+ #!/bin/sh
2
+ # Arm, disarm, inspect, install, or hand-fire the unattended drainer.
3
+ #
4
+ # bin/drain on # arm it — the next scheduled fire does work
5
+ # bin/drain off # disarm — fires still happen and exit immediately
6
+ # bin/drain status # armed? scheduled? last fire? last quota wall? eligible?
7
+ # bin/drain now # run one fire in the foreground, right now
8
+ # bin/drain at HH:MM # one detached one-shot fire, today or tomorrow
9
+ # bin/drain cancel # cancel a pending one-shot
10
+ # bin/drain install # install the scheduled job (launchd on macOS,
11
+ # # a systemd --user timer on Linux)
12
+ #
13
+ # WHY A FLAG FILE RATHER THAN LOADING/UNLOADING THE SCHEDULER.
14
+ #
15
+ # Unloading a scheduled job is a stateful operation on the job definition that
16
+ # also has to survive reboots and login cycles, and getting it wrong leaves the
17
+ # job either permanently dead or permanently running with no obvious tell. A
18
+ # flag file is a boolean the runner reads at the top of every fire: `off` takes
19
+ # effect on the next fire with nothing to reload, `on` needs no privileges, and
20
+ # `status` can answer honestly by reading one path. The scheduled job stays
21
+ # installed either way.
22
+ set -e
23
+
24
+ . "$(dirname "$0")/../lib/roots.sh"
25
+ ENTROPY_MACHINES_HOME=$(entropy_machines_home "$0")
26
+ entropy_machines_require_root drain
27
+ os=$(uname)
28
+
29
+ # ---- config -----------------------------------------------------------------
30
+ # cfg <dotted.path> <default>
31
+ # One value out of config.json at the project root. A missing file, a missing
32
+ # key, or a value that is not a plain scalar all fall back to <default> — this
33
+ # harness has to keep running before a project has written every key, per
34
+ # docs/CONFIG.md rule 2 (a working default, or a refusal naming the key; the
35
+ # refusal is this script's job for the keys that truly have none, e.g. an
36
+ # unsupported scheduler below).
37
+ cfg() {
38
+ python3 - "$ENTROPY_MACHINES_HOME/config.json" "$1" "$2" <<'PY'
39
+ import json, sys
40
+ path, key, default = sys.argv[1], sys.argv[2], sys.argv[3]
41
+ try:
42
+ with open(path) as f:
43
+ data = json.load(f)
44
+ except Exception:
45
+ print(default); sys.exit(0)
46
+ cur = data
47
+ # TOLERATE A LEADING DOT. Every call site in this file and in bin/drain writes
48
+ # the key as '.unattended.x' (a jq habit), which split('.') turns into a first
49
+ # segment of '' that matches no dict key — so this helper silently returned its
50
+ # DEFAULT for every lookup, and no unattended setting in config.json had any
51
+ # effect. Fixed here rather than at a dozen call sites so both spellings work.
52
+ for part in key.lstrip('.').split('.'):
53
+ if isinstance(cur, dict) and part in cur:
54
+ cur = cur[part]
55
+ else:
56
+ print(default); sys.exit(0)
57
+ print(default if cur is None else (cur if isinstance(cur, str) else json.dumps(cur)))
58
+ PY
59
+ }
60
+
61
+ state_default=$(cfg '.unattended.stateHome' '~/.entropy')
62
+ case "$state_default" in
63
+ "~"*) state_default="$HOME${state_default#\~}" ;;
64
+ esac
65
+ state="${ENTROPY_DRAIN_HOME:-$state_default}"
66
+ label=$(cfg '.unattended.label' 'com.entropy.drain')
67
+ flag="$state/armed"
68
+ log="$state/last-run.json"
69
+
70
+ mkdir -p "$state"
71
+
72
+ # ---- which scheduler ---------------------------------------------------------
73
+ # Sets $scheduler, $scheduler_source, $have_launchd, $have_systemd. Called by
74
+ # both `status` and `install` so the two cannot disagree about what this machine
75
+ # can do — the previous split (status hardcoded "Darwin or unsupported", install
76
+ # hardcoded a Darwin refusal) is exactly the shape that let `status` claim a
77
+ # platform had no scheduler while an installer for it existed.
78
+ #
79
+ # AVAILABLE means the client binary is reachable, not that the platform sounds
80
+ # right: launchd needs macOS AND launchctl; systemd needs systemctl (present on
81
+ # Linux, and absent on a Linux running some other init, which is the case a
82
+ # uname check gets wrong).
83
+ #
84
+ # An explicit unattended.scheduler always wins, including when it names one this
85
+ # machine cannot run — a wrong value must be a refusal that says so, never a
86
+ # silent fallback to the other scheduler. Only an ABSENT key is detected.
87
+ drain_scheduler_probe() {
88
+ have_launchd=no
89
+ have_systemd=no
90
+ # `if`, not `[ test ] && VAR=value` — see the note in `install` below: as a
91
+ # bare statement under `set -e` that form EXITS the script when the test is
92
+ # false, which is how a probe would take the whole command down on Linux.
93
+ if [ "$os" = Darwin ] && command -v launchctl >/dev/null 2>&1; then
94
+ have_launchd=yes
95
+ fi
96
+ if command -v systemctl >/dev/null 2>&1; then
97
+ have_systemd=yes
98
+ fi
99
+ scheduler=$(cfg '.unattended.scheduler' '')
100
+ if [ -n "$scheduler" ]; then
101
+ scheduler_source=config
102
+ return 0
103
+ fi
104
+ scheduler_source=detected
105
+ if [ "$have_launchd" = yes ]; then
106
+ scheduler=launchd
107
+ elif [ "$have_systemd" = yes ]; then
108
+ scheduler=systemd
109
+ else
110
+ scheduler=none
111
+ fi
112
+ return 0
113
+ }
114
+
115
+ # What the probe looked for, printed verbatim by every refusal so the message
116
+ # names the check rather than the conclusion.
117
+ drain_scheduler_looked_for() {
118
+ echo " It looked for:" >&2
119
+ echo " launchd — macOS (uname reports '$os') and 'launchctl' on PATH: $have_launchd" >&2
120
+ echo " systemd — 'systemctl' on PATH: $have_systemd" >&2
121
+ }
122
+
123
+ case "${1:-status}" in
124
+ on)
125
+ date -u +%Y-%m-%dT%H:%M:%SZ > "$flag"
126
+ echo "drain: ARMED (since $(cat "$flag"))"
127
+ echo " the next scheduled fire will pick up work; 'bin/drain off' stops it."
128
+ ;;
129
+
130
+ off)
131
+ rm -f "$flag"
132
+ echo "drain: DISARMED"
133
+ echo " fires still happen on schedule and exit immediately. Nothing to unload."
134
+ ;;
135
+
136
+ status)
137
+ # Two independent switches, and confusing them is the likely support
138
+ # question: the job can be installed but disarmed (fires, does nothing), or
139
+ # armed but never installed (nothing ever fires). Report both.
140
+ scheduled=no
141
+ drain_scheduler_probe
142
+ case "$scheduler" in
143
+ launchd)
144
+ if [ "$have_launchd" != yes ]; then
145
+ scheduled=unavailable
146
+ elif launchctl list 2>/dev/null | grep -q "$label"; then
147
+ scheduled=yes
148
+ fi
149
+ ;;
150
+ systemd)
151
+ if [ "$have_systemd" != yes ]; then
152
+ scheduled=unavailable
153
+ elif systemctl --user is-active --quiet "$label.timer" 2>/dev/null; then
154
+ # The TIMER is the thing that is loaded and waiting; the .service it
155
+ # fires is inactive almost all the time and would read as "no".
156
+ scheduled=yes
157
+ fi
158
+ ;;
159
+ none)
160
+ # Nothing this installer implements exists on this machine, so there is
161
+ # no job of ours to look for — say that rather than pointing at an
162
+ # install that refuses.
163
+ scheduled=unsupported
164
+ ;;
165
+ *)
166
+ # config.json names a scheduler this installer does not implement. A
167
+ # different message: the machine may well be fine, the config is not.
168
+ scheduled=unknown
169
+ ;;
170
+ esac
171
+ if [ "$scheduled" = yes ]; then
172
+ echo "scheduled: yes (hourly, at :07, via $scheduler)"
173
+ elif [ "$scheduled" = unknown ]; then
174
+ echo "scheduled: n/a — config.json's unattended.scheduler is '$scheduler',"
175
+ echo " which 'bin/drain install' does not implement (launchd, systemd)"
176
+ elif [ "$scheduled" = unavailable ]; then
177
+ # The scheduler was NAMED (only config.json can name one that is not
178
+ # here — detection never picks an unusable one), and it is not usable on
179
+ # this machine. Say which one and which check failed, not "unsupported":
180
+ # the other scheduler may well be sitting right there.
181
+ echo "scheduled: n/a — unattended.scheduler is '$scheduler' ($scheduler_source),"
182
+ echo " not usable here (launchctl: $have_launchd, systemctl: $have_systemd, uname: $os);"
183
+ echo " 'bin/drain install' will refuse and say the same"
184
+ elif [ "$scheduled" = unsupported ]; then
185
+ echo "scheduled: n/a — no scheduler 'bin/drain install' can use is available"
186
+ echo " here (launchctl: $have_launchd, systemctl: $have_systemd, uname: $os);"
187
+ echo " fire bin/drain-run.sh from your own cron entry instead"
188
+ else
189
+ echo "scheduled: no — run 'bin/drain install'"
190
+ fi
191
+ if [ -f "$state/once.pid" ] && kill -0 "$(cat "$state/once.pid")" 2>/dev/null; then
192
+ echo "one-shot: $(cat "$state/once.at" 2>/dev/null) (pid $(cat "$state/once.pid"))"
193
+ else
194
+ echo "one-shot: none — 'bin/drain at HH:MM' to set one"
195
+ fi
196
+ if [ -f "$flag" ]; then
197
+ echo "armed: yes (since $(cat "$flag"))"
198
+ else
199
+ echo "armed: no — run 'bin/drain on'"
200
+ fi
201
+ if [ -f "$log" ]; then
202
+ # Heredoc + env var rather than `python3 -c '...'`: the inline form needs
203
+ # quotes nested three deep (sh, python, f-string) and the first version
204
+ # of this line printed its own source back four times.
205
+ DRAIN_LOG="$log" python3 <<'PY'
206
+ import json, os
207
+ try:
208
+ d = json.load(open(os.environ["DRAIN_LOG"]))
209
+ print(f'last fire: {d.get("finishedAt", "?")} — {d.get("outcome", "?")}'
210
+ f' ({d.get("detail", "")})')
211
+ except Exception as exc:
212
+ print(f"last fire: unreadable ({exc})")
213
+ PY
214
+ else
215
+ echo "last fire: never"
216
+ fi
217
+ # The quota probe lives in bin/drain-run.sh (--probe-quota) so there is
218
+ # exactly one implementation; see that file for what it assumes.
219
+ lookback=$(cfg '.unattended.agent.quotaProbe.lookbackSeconds' '3600')
220
+ l=$("$ENTROPY_MACHINES_HOME/bin/drain-run.sh" --probe-quota 2>/dev/null || true)
221
+ if [ -n "$l" ]; then
222
+ echo "quota wall: $l"
223
+ else
224
+ echo "quota wall: none seen in the last $((lookback / 60))m (or no probe configured)"
225
+ fi
226
+ # What it would actually take, so `status` answers "is there anything to
227
+ # do" and not merely "is the switch up".
228
+ if [ -x "$ENTROPY_MACHINES_HOME/bin/tracker" ]; then
229
+ n=$("$ENTROPY_MACHINES_HOME/bin/tracker" ready 2>/dev/null | grep -c . || true)
230
+ echo "eligible: ${n:-0} issue(s) an unattended run may take"
231
+ fi
232
+ ;;
233
+
234
+ now)
235
+ echo "drain: firing once in the foreground (ignores the armed flag)"
236
+ exec "$ENTROPY_MACHINES_HOME/bin/drain-run.sh" --foreground
237
+ ;;
238
+
239
+ at)
240
+ # One fire at a time you name — the answer to "can I just read a reset time
241
+ # off the agent's own usage panel and resume then". You can, and a rolling
242
+ # window means that reading is only true for today, which is exactly what a
243
+ # ONE-SHOT is for. The hourly probe chases a moving window; this hits a
244
+ # boundary you already know. They do not conflict and neither needs the
245
+ # other.
246
+ when="$2"
247
+ case "$when" in
248
+ [0-9][0-9]:[0-9][0-9]) ;;
249
+ *) echo "usage: bin/drain at HH:MM (24-hour, local)" >&2; exit 2 ;;
250
+ esac
251
+ hh=${when%:*}; mm=${when#*:}
252
+ # Strip a leading zero before arithmetic — 08 and 09 are invalid octal.
253
+ hh=$((10#$hh)); mm=$((10#$mm))
254
+ if [ "$hh" -gt 23 ] || [ "$mm" -gt 59 ]; then
255
+ echo "bin/drain at: $when is not a real time" >&2; exit 2
256
+ fi
257
+ # A DETACHED SLEEP, not a scheduled job. On macOS specifically, a repo
258
+ # under a TCC-protected folder (see `install` below) cannot be reached by
259
+ # a LaunchAgent at all, but a process spawned from your terminal inherits
260
+ # the terminal's TCC grant and reads the repo fine — so this is the form
261
+ # that works everywhere, no permission changes, no scheduler install.
262
+ #
263
+ # The trade: it does not survive a reboot, and if the machine sleeps
264
+ # through the target the timer resumes on wake and fires late rather than
265
+ # on time. For "resume at the reset I just read off my own usage panel",
266
+ # that is the right shape anyway — you are naming a moment tonight, not a
267
+ # standing schedule.
268
+ #
269
+ # Portable date math: BSD date (macOS) and GNU date (Linux) parse and
270
+ # format dates with incompatible flags (`-j -f` vs `-d`), and neither can
271
+ # be assumed present in the other's form. Routing through python3's
272
+ # stdlib `datetime` sidesteps both dialects entirely — it behaves
273
+ # identically on every platform this harness targets, unlike `date` itself.
274
+ now_s=$(date +%s)
275
+ tgt_s=$(python3 - "$hh" "$mm" <<'PY'
276
+ import datetime, sys
277
+ hh, mm = int(sys.argv[1]), int(sys.argv[2])
278
+ tgt = datetime.datetime.now().replace(hour=hh, minute=mm, second=0, microsecond=0)
279
+ print(int(tgt.timestamp()))
280
+ PY
281
+ )
282
+ [ -z "$tgt_s" ] && { echo "bin/drain at: could not compute a target time" >&2; exit 2; }
283
+ if [ "$tgt_s" -le "$now_s" ]; then
284
+ tgt_s=$((tgt_s + 86400)) # already past today → mean tomorrow
285
+ fi
286
+ wait_s=$((tgt_s - now_s))
287
+ tgt_str=$(python3 - "$tgt_s" <<'PY'
288
+ import datetime, sys
289
+ print(datetime.datetime.fromtimestamp(int(sys.argv[1])).strftime('%Y-%m-%d %H:%M'))
290
+ PY
291
+ )
292
+ # Replace any pending one-shot rather than stacking two fires.
293
+ if [ -f "$state/once.pid" ] && kill -0 "$(cat "$state/once.pid")" 2>/dev/null; then
294
+ kill "$(cat "$state/once.pid")" 2>/dev/null || true
295
+ fi
296
+ nohup sh -c "sleep $wait_s; exec '$ENTROPY_MACHINES_HOME/bin/drain-run.sh' --foreground" \
297
+ > "$state/once.log" 2>&1 &
298
+ echo $! > "$state/once.pid"
299
+ printf '%s\n' "$tgt_str" > "$state/once.at"
300
+ echo "drain: one fire at $tgt_str — in $((wait_s / 60)) min (pid $(cat "$state/once.pid"))"
301
+ echo " runs whether or not 'drain on' is set; log: $state/once.log"
302
+ echo " cancel with: bin/drain cancel"
303
+ echo " NOTE: a detached timer, so it does not survive a reboot."
304
+ ;;
305
+
306
+ cancel)
307
+ if [ -f "$state/once.pid" ] && kill -0 "$(cat "$state/once.pid")" 2>/dev/null; then
308
+ kill "$(cat "$state/once.pid")" 2>/dev/null || true
309
+ echo "drain: cancelled the one-shot that was set for $(cat "$state/once.at" 2>/dev/null)"
310
+ else
311
+ echo "drain: no one-shot pending"
312
+ fi
313
+ rm -f "$state/once.pid" "$state/once.at"
314
+ if [ "$os" = "Darwin" ] && command -v launchctl >/dev/null 2>&1; then
315
+ launchctl bootout "gui/$(id -u)/$label.once" 2>/dev/null || true
316
+ rm -f "$HOME/Library/LaunchAgents/$label.once.plist"
317
+ fi
318
+ ;;
319
+
320
+ install)
321
+ # ONLY THE INSTALLER IS PLATFORM-SPECIFIC. Everything else in this script —
322
+ # on, off, status, now, at, cancel — is plain POSIX sh and runs anywhere, so
323
+ # the platform check belongs here and nowhere higher up.
324
+ #
325
+ # Two schedulers, and nothing else. An installer that reports success and
326
+ # leaves a broken job behind is worse than no installer, because every
327
+ # surface (`status`, `launchctl list`, `systemctl --user is-active`) then
328
+ # agrees it is armed — so anything we cannot write a unit for is a refusal
329
+ # that installs nothing, and says which check failed.
330
+ drain_scheduler_probe
331
+
332
+ runner="$ENTROPY_MACHINES_HOME/bin/drain-run.sh"
333
+
334
+ if [ "$scheduler" = none ]; then
335
+ echo "bin/drain install: refusing — no scheduler this installer supports is" >&2
336
+ echo " available on this machine, and config.json's unattended.scheduler" >&2
337
+ echo " does not name one." >&2
338
+ drain_scheduler_looked_for
339
+ echo " Nothing was installed. Drive the fires yourself instead: put" >&2
340
+ echo " $runner" >&2
341
+ echo " on your own cron entry or timer. 'bin/drain on/off' is still the" >&2
342
+ echo " arm/disarm switch whatever triggers the fire, and 'bin/drain now'" >&2
343
+ echo " / 'bin/drain at HH:MM' need no scheduler at all." >&2
344
+ exit 3
345
+ fi
346
+
347
+ case "$scheduler" in
348
+ launchd)
349
+ # The platform gate that used to sit above the whole command, now
350
+ # scoped to the path it is actually about. Reached when this is the
351
+ # DETECTED scheduler (in which case it passes by construction) or when
352
+ # config.json asks for launchd on a machine that has no launchctl.
353
+ if [ "$have_launchd" != yes ]; then
354
+ echo "bin/drain install: refusing — unattended.scheduler is 'launchd'" >&2
355
+ echo " ($scheduler_source), and launchd is not usable on this machine." >&2
356
+ drain_scheduler_looked_for
357
+ if [ "$have_systemd" = yes ]; then
358
+ echo " systemd IS available here: set unattended.scheduler to 'systemd'" >&2
359
+ echo " in config.json, or remove the key and let this detect it." >&2
360
+ fi
361
+ echo " Nothing was installed. 'bin/drain now' / 'bin/drain at HH:MM'" >&2
362
+ echo " need no scheduler at all." >&2
363
+ exit 3
364
+ fi
365
+ # TCC (Transparency, Consent and Control) — a macOS-only OS security
366
+ # subsystem — refuses a LaunchAgent filesystem access to ~/Desktop,
367
+ # ~/Documents and ~/Downloads. Installing a job that is denied before
368
+ # its first line is worse than installing none, because `launchctl
369
+ # list` shows it loaded and everything looks armed. Check the actual
370
+ # repo location(s) and say so. This entire check is a macOS concept
371
+ # and does not apply on Linux.
372
+ #
373
+ # Both locations are checked: launchd has to reach ENTROPY_MACHINES_HOME to
374
+ # exec the runner script at all, and drain-run.sh then reads/writes
375
+ # inside ENTROPY_MACHINES_ROOT (git, config.json, .entropy/) to do the work.
376
+ # They are usually the same tree now, but not the same DIRECTORY when
377
+ # the harness is vendored in a subdirectory, so either one sitting
378
+ # under a protected folder denies the job — checked separately so the
379
+ # message names the one that is actually the problem.
380
+ tcc_blocked=""
381
+ for tcc_dir in "$ENTROPY_MACHINES_HOME" "$ENTROPY_MACHINES_ROOT"; do
382
+ case "$tcc_dir" in
383
+ "$HOME"/Desktop/*|"$HOME"/Documents/*|"$HOME"/Downloads/*)
384
+ echo "bin/drain install: refusing — this is in a TCC-protected folder:" >&2
385
+ echo " $tcc_dir" >&2
386
+ tcc_blocked=1
387
+ ;;
388
+ esac
389
+ done
390
+ if [ -n "$tcc_blocked" ]; then
391
+ echo " A LaunchAgent is denied here before it runs a line. Three ways forward:" >&2
392
+ echo " 1. bin/drain at HH:MM — a detached timer, works today," >&2
393
+ echo " no permissions, no reboot survival" >&2
394
+ echo " 2. move it out of that folder (e.g. ~/code/<project>)" >&2
395
+ echo " 3. System Settings -> Privacy & Security -> Full Disk Access," >&2
396
+ echo " add /bin/sh — broad, since it grants every shell script" >&2
397
+ echo " Override once you have done 2 or 3: ENTROPY_DRAIN_FORCE=1" >&2
398
+ # `if`, not `[ ... ] && exit`. Under `set -e` a bare `[ test ] &&
399
+ # cmd` used as a STATEMENT exits the whole script when the test is
400
+ # FALSE — so with the override set, this line silently killed the
401
+ # install it was meant to permit. Also `${...:-}`: the variable is
402
+ # normally unset.
403
+ if [ -z "${ENTROPY_DRAIN_FORCE:-}" ]; then
404
+ exit 3
405
+ fi
406
+ echo " ENTROPY_DRAIN_FORCE set — installing anyway." >&2
407
+ fi
408
+ # A scheduler unit fires with no useful cwd. That used to be a gap:
409
+ # nothing carried the project root in, so an installed job only worked
410
+ # when the harness sat AT the project root. With one root it resolves
411
+ # itself — bin/drain-run.sh cd's into the harness directory, which is
412
+ # inside the repo, and git answers from there. No placeholder needed.
413
+ plist="$HOME/Library/LaunchAgents/$label.plist"
414
+ mkdir -p "$HOME/Library/LaunchAgents"
415
+ sed "s|__RUNNER__|$runner|g; s|__STATE__|$state|g; s|__LABEL__|$label|g" \
416
+ "$ENTROPY_MACHINES_HOME/lib/entropy-drain.plist.in" > "$plist"
417
+ launchctl bootout "gui/$(id -u)/$label" 2>/dev/null || true
418
+ launchctl bootstrap "gui/$(id -u)" "$plist"
419
+ echo "drain: launchd job installed and loaded (hourly probe)"
420
+ echo " it is INERT until you run 'bin/drain on'."
421
+ echo " to remove: launchctl bootout gui/$(id -u)/$label && rm $plist"
422
+ echo
423
+ echo " One step left, and it needs sudo so it is yours to run — without it"
424
+ echo " the job cannot fire while the machine is asleep:"
425
+ echo " sudo pmset repeat wakeorpoweron MTWRFSU 02:00:00"
426
+ ;;
427
+
428
+ systemd)
429
+ if [ "$have_systemd" != yes ]; then
430
+ echo "bin/drain install: refusing — unattended.scheduler is 'systemd'" >&2
431
+ echo " ($scheduler_source), and there is no 'systemctl' on PATH here." >&2
432
+ drain_scheduler_looked_for
433
+ if [ "$have_launchd" = yes ]; then
434
+ echo " launchd IS available here: set unattended.scheduler to 'launchd'" >&2
435
+ echo " in config.json, or remove the key and let this detect it." >&2
436
+ fi
437
+ echo " Nothing was installed. 'bin/drain now' / 'bin/drain at HH:MM'" >&2
438
+ echo " need no scheduler at all." >&2
439
+ exit 3
440
+ fi
441
+ # systemctl EXISTS is not the same as a user manager we can talk to: in
442
+ # an ssh session or a container with no per-user systemd instance,
443
+ # `--user` fails on a missing DBUS_SESSION_BUS_ADDRESS/XDG_RUNTIME_DIR.
444
+ # Ask before writing anything, so the failure mode is a refusal rather
445
+ # than two unit files on disk that no daemon-reload ever picked up.
446
+ if ! systemctl --user show-environment >/dev/null 2>&1; then
447
+ echo "bin/drain install: refusing — 'systemctl --user' cannot reach a user" >&2
448
+ echo " manager for $(id -un) (no session bus; XDG_RUNTIME_DIR=${XDG_RUNTIME_DIR:-unset})." >&2
449
+ echo " This is usually an ssh or container session with no logind seat." >&2
450
+ echo " Nothing was installed. Either run this from a full login session," >&2
451
+ echo " or enable a lingering one first:" >&2
452
+ echo " sudo loginctl enable-linger $(id -un)" >&2
453
+ exit 3
454
+ fi
455
+ # No TCC equivalent to check: the macOS branch above refuses a repo
456
+ # under ~/Desktop, ~/Documents or ~/Downloads because a LaunchAgent is
457
+ # denied those paths by the OS before it runs a line. Linux has no such
458
+ # per-directory grant for a user unit — a user timer reads whatever the
459
+ # user can read — so there is nothing here to refuse for.
460
+ #
461
+ # A user unit, so no sudo and no system-wide state: everything lands
462
+ # under the operator's own XDG config dir and is undone by deleting two
463
+ # files. Same substitution as the plist, from templates beside it.
464
+ unit_dir="${XDG_CONFIG_HOME:-$HOME/.config}/systemd/user"
465
+ service="$unit_dir/$label.service"
466
+ timer="$unit_dir/$label.timer"
467
+ mkdir -p "$unit_dir"
468
+ sed "s|__RUNNER__|$runner|g; s|__STATE__|$state|g; s|__LABEL__|$label|g" \
469
+ "$ENTROPY_MACHINES_HOME/lib/entropy-drain.service.in" > "$service"
470
+ sed "s|__RUNNER__|$runner|g; s|__STATE__|$state|g; s|__LABEL__|$label|g" \
471
+ "$ENTROPY_MACHINES_HOME/lib/entropy-drain.timer.in" > "$timer"
472
+ systemctl --user daemon-reload
473
+ # --now starts the timer as well as enabling it, so the first fire does
474
+ # not wait for the next login. The TIMER is enabled, never the service:
475
+ # enabling a oneshot service would run it at boot instead of on
476
+ # schedule. It is still inert until `bin/drain on`.
477
+ systemctl --user enable --now "$label.timer"
478
+ echo "drain: systemd user timer installed and started (hourly probe)"
479
+ echo " it is INERT until you run 'bin/drain on'."
480
+ echo " to check: systemctl --user list-timers $label.timer"
481
+ echo " to remove: systemctl --user disable --now $label.timer &&"
482
+ echo " rm $timer $service && systemctl --user daemon-reload"
483
+ echo
484
+ echo " One step left, and it needs sudo so it is yours to run — without it"
485
+ echo " the timer is torn down when your last session logs out, and never"
486
+ echo " fires on a machine you are not sitting at:"
487
+ echo " sudo loginctl enable-linger $(id -un)"
488
+ ;;
489
+
490
+ *)
491
+ echo "bin/drain install: unknown scheduler '$scheduler' (unattended.scheduler" >&2
492
+ echo " in config.json) — the schedulers this installer supports are" >&2
493
+ echo " 'launchd' (macOS) and 'systemd' (a systemd --user timer). Remove the" >&2
494
+ echo " key to have it detect one. On anything else, call bin/drain-run.sh" >&2
495
+ echo " from a timer you write yourself (cron, a CI schedule)." >&2
496
+ exit 2
497
+ ;;
498
+ esac
499
+ ;;
500
+
501
+ *)
502
+ echo "usage: bin/drain [status|now|at HH:MM|cancel|on|off|install]" >&2
503
+ echo " at HH:MM one fire at a time you name" >&2
504
+ echo " on/off the hourly probe, for windows you have not read" >&2
505
+ exit 2
506
+ ;;
507
+ esac
@@ -0,0 +1,168 @@
1
+ #!/usr/bin/env python3
2
+ """Choose the issues one unattended fire will take. Prints ids, space-separated.
3
+
4
+ bin/drain-pick.py [<repo-root>]
5
+
6
+ Its own file rather than a heredoc inside drain-run.sh, for two reasons. A
7
+ heredoc piped from `tracker ready` would have the pipe and the heredoc both
8
+ claiming stdin, which is a real way to have python read the TRACKER OUTPUT as
9
+ its own source and die on a syntax error. And a selection rule with a cap in
10
+ it is worth testing directly, which a heredoc cannot be.
11
+
12
+ THE RULE. A cap phrased as separate small/medium/large limits ("N small, M
13
+ medium, P large only") does not compose — it has nothing to say about a fire
14
+ that wants two smalls and one medium. Recasting the caps as unit costs that
15
+ share one budget makes mixing fall out for free instead of needing a new rule
16
+ per combination.
17
+
18
+ Costs and the budget are project config (`unattended.sizeCosts`,
19
+ `unattended.budget` in config.json) with defaults chosen so a few small caps
20
+ land on the same number:
21
+
22
+ S = 3 M = 4 L = 6 budget = 12
23
+
24
+ 4 x S = 12 3 x M = 12 2 x L = 12
25
+
26
+ Every pure-class fire is therefore identical to what flat per-size caps of
27
+ 4/3/2 would allow, and mixing now falls out of the same number: L + M + S is
28
+ 13, over budget, so it takes L + M; L + S + S is 12, so it takes all three.
29
+
30
+ Selection walks the ranked list — the order `bin/tracker ready` printed it
31
+ in — and takes anything that still fits, skipping what does not and
32
+ continuing. Skipping rather than stopping matters: one large issue at rank
33
+ three should not strand a fire with 6 unspent points when there are smaller
34
+ items below it. Rank is never reordered — a cheaper issue is only ever
35
+ reached after every dearer one above it was considered.
36
+
37
+ ENTROPY_DRAIN_BUDGET overrides the configured budget for a bigger or smaller
38
+ fire without touching the per-size costs.
39
+
40
+ Eligibility is exactly what `bin/tracker ready` prints (docs/TRACKER-ADAPTER.md)
41
+ and nothing else — so an issue cannot be picked while blocked, held, or gated
42
+ on an open decision. There is no separate "autonomous" flag in the adapter
43
+ contract: an issue a human decided an unattended run should not touch is
44
+ `held`, via `bin/tracker set <id> heldWhy=...` (see bin/drain-prompt.md), and
45
+ `ready` already excludes it.
46
+ """
47
+ import json
48
+ import os
49
+ import subprocess
50
+ import sys
51
+
52
+ DEFAULT_COST = {"S": 3, "M": 4, "L": 6}
53
+ DEFAULT_BUDGET = 12
54
+
55
+
56
+ def load_cost_model(root):
57
+ """(costs, budget) out of config.json's `unattended` block.
58
+
59
+ Not read through lib/config.py — that loader does not define these two
60
+ keys yet (see its DEFAULTS dict), and this script only needs two scalars,
61
+ not the full merge/validate pipeline. A missing file, a missing key, or a
62
+ malformed value all fall back to the defaults above rather than raising —
63
+ same posture as bin/drain's and bin/drain-run.sh's own `cfg()` helper.
64
+ """
65
+ try:
66
+ with open(os.path.join(root, "config.json"), encoding="utf-8") as f:
67
+ data = json.load(f)
68
+ except (OSError, json.JSONDecodeError):
69
+ data = {}
70
+ unattended = data.get("unattended") or {}
71
+ costs = unattended.get("sizeCosts")
72
+ if not isinstance(costs, dict) or not costs:
73
+ costs = DEFAULT_COST
74
+ budget = unattended.get("budget")
75
+ if not isinstance(budget, (int, float)):
76
+ budget = DEFAULT_BUDGET
77
+ return costs, budget
78
+
79
+
80
+ def parse(text):
81
+ """[(id, effort)] in listed order — `bin/tracker ready`'s JSONL, one
82
+ issue record per line (docs/TRACKER-ADAPTER.md).
83
+
84
+ A line that fails to parse, or parses but carries no "id", is skipped
85
+ rather than aborting the whole pick — one malformed record from a
86
+ `command` backend should not zero out an otherwise-eligible fire.
87
+ `effort` is optional in the adapter contract; a record without one is
88
+ handled by the caller's fail-closed default, same as an unrecognised
89
+ value.
90
+ """
91
+ out = []
92
+ for line in text.splitlines():
93
+ line = line.strip()
94
+ if not line:
95
+ continue
96
+ try:
97
+ rec = json.loads(line)
98
+ except json.JSONDecodeError:
99
+ continue
100
+ if not isinstance(rec, dict):
101
+ continue
102
+ iid = rec.get("id")
103
+ if not iid:
104
+ continue
105
+ out.append((iid, rec.get("effort")))
106
+ return out
107
+
108
+
109
+ def pick(rows, costs, budget):
110
+ """Walk the ranked list, take whatever still fits, return the ids.
111
+
112
+ An unrecognised or missing effort costs the most a fire can hold.
113
+ Fail-closed: a typo'd or newly-invented size (or an issue nobody sized at
114
+ all) must not become the cheapest thing on the list and let an
115
+ unattended run take an unbounded amount of it.
116
+ """
117
+ left = budget
118
+ taken = []
119
+ fallback = max(costs.values()) if costs else budget
120
+ for iid, effort in rows:
121
+ c = costs.get(effort, fallback)
122
+ if c <= left:
123
+ taken.append(iid)
124
+ left -= c
125
+ return taken
126
+
127
+
128
+ def _tracker_path():
129
+ """bin/tracker, resolved from THIS FILE rather than from the project root.
130
+
131
+ It used to be os.path.join(root, "bin", "tracker"), where root is the
132
+ PROJECT. That is only the same directory when the harness happens to sit
133
+ at the repo root; in the vendored-in-a-subdirectory layout it resolves to
134
+ <project>/bin/tracker, which does not exist, and every unattended pick
135
+ died with ENOENT. This file is a sibling of tracker inside the harness, so
136
+ its own location is the answer and is right in either layout.
137
+ """
138
+ return os.path.join(os.path.dirname(os.path.abspath(__file__)), "tracker")
139
+
140
+
141
+ def main():
142
+ root = sys.argv[1] if len(sys.argv) > 1 else \
143
+ os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
144
+ costs, budget = load_cost_model(root)
145
+ override = os.environ.get("ENTROPY_DRAIN_BUDGET")
146
+ if override:
147
+ try:
148
+ budget = float(override)
149
+ except ValueError:
150
+ print(f"drain-pick: ENTROPY_DRAIN_BUDGET={override!r} is not a "
151
+ f"number, ignoring", file=sys.stderr)
152
+ try:
153
+ r = subprocess.run(
154
+ [_tracker_path(), "ready"],
155
+ capture_output=True, text=True, timeout=60)
156
+ except (OSError, subprocess.SubprocessError) as exc:
157
+ print(f"drain-pick: cannot reach the tracker: {exc}", file=sys.stderr)
158
+ return 1
159
+ if r.returncode != 0:
160
+ print(f"drain-pick: tracker exited {r.returncode}: {r.stderr.strip()}",
161
+ file=sys.stderr)
162
+ return 1
163
+ print(" ".join(pick(parse(r.stdout), costs, budget)))
164
+ return 0
165
+
166
+
167
+ if __name__ == "__main__":
168
+ sys.exit(main())