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
@@ -0,0 +1,67 @@
1
+ You are running unattended. Nobody is watching, nobody can answer a question,
2
+ and the session will be killed after its timeout. Work accordingly: finish
3
+ and verify a smaller thing rather than leaving a larger thing half-done.
4
+
5
+ ## Your work
6
+
7
+ Take these issues, in this order, and no others:
8
+
9
+ __ISSUES__
10
+
11
+ Read each with `bin/tracker show <id>` before starting it. If an issue turns
12
+ out to need something you cannot do from a terminal with nobody present — a
13
+ live UI, an external service, a decision nobody has made — STOP on that
14
+ issue and run:
15
+
16
+ bin/tracker set <id> heldWhy="<why, specific enough for a human to act on>"
17
+
18
+ so no future unattended run picks it up again, then move to the next one.
19
+ Recording why is the whole point; silently skipping it means the next run
20
+ rediscovers the same wall.
21
+
22
+ ## How to work
23
+
24
+ 1. `git checkout -b __BRANCH__` before your first edit. Never commit on the
25
+ default branch.
26
+ 2. Implement one issue at a time. Commit each one separately.
27
+ 3. Verify before each commit: run every suite listed under `suites` in this
28
+ repo's `config.json` (docs/CONFIG.md) that is not tagged `slow`. Run the
29
+ full set, including anything tagged `slow`, at least once before you
30
+ finish. If `suites` is empty, say so in your report rather than skipping
31
+ verification silently.
32
+ 4. If this project's `config.json` has `changelog.enabled` true, write one
33
+ changelog fragment per commit using the command in
34
+ `changelog.newFragmentCmd`, naming the issue it closes. Never hand-edit
35
+ the collated file at `changelog.collatedFile` directly, and never run
36
+ this project's full build/release command — some rewrite committed
37
+ bookkeeping (a collated changelog, a checkpoint file) as a side effect,
38
+ which is not a change you were asked to make.
39
+ 5. `bin/tracker set <id> status=done` when an issue is genuinely finished
40
+ and verified — not when the code is written, when the tests passed.
41
+
42
+ ## What you may not touch
43
+
44
+ This repo's `config.json` lists paths under `project.protectedPaths` — an
45
+ append-only interface, a generated bundle, a cross-writer contract, anything
46
+ that needs a human holding both ends. Do not edit any of them on your own
47
+ initiative. If an issue cannot be done without touching one, mark it held as
48
+ above and move on. The runner also diffs your finished work against this
49
+ list before pushing and quarantines the whole run if it finds a match, so
50
+ there is no version of this where touching one helps.
51
+
52
+ ## Before you finish
53
+
54
+ Review your own diff for correctness and simplification issues before
55
+ declaring anything done, using whatever review tooling this environment
56
+ gives you — or record in your handoff why you did not.
57
+
58
+ Then write a `HANDOFF.md` at the repo root: exactly four one-line keys —
59
+ `changed` / `found` / `assumed` / `next` — plus, per issue you touched, run
60
+ `bin/tracker remember --issue <id> "<what changed, what you verified and its
61
+ numbers, anything you skipped and why>"` so the next session (attended or
62
+ not) finds it without opening this branch. If you marked an issue held, say
63
+ so in both places — that is the most useful line here, because it is the one
64
+ that changes what tomorrow's run does.
65
+
66
+ Do not push and do not open a pull request. The runner does that after it has
67
+ checked your diff.
@@ -0,0 +1,342 @@
1
+ #!/bin/sh
2
+ # One unattended fire: take eligible work, verify it, report it, push it for
3
+ # review. Invoked by the scheduled job on the hour, or by `bin/drain now`, or
4
+ # by `bin/drain at HH:MM`.
5
+ #
6
+ # Also answers `bin/drain-run.sh --probe-quota` — see QUOTA PROBE below. That
7
+ # is not a separate script; it is the same function bin/drain's `status` calls
8
+ # out to, so there is exactly one implementation instead of two that drift.
9
+ #
10
+ # The design goal is NOT "get work done overnight". It is "produce a diff worth
11
+ # reviewing in the morning, or produce nothing". Every guard below exists to
12
+ # make the second outcome cheap and the first trustworthy.
13
+ #
14
+ # GUARDS, in the order they fire. Each maps to a failure this kind of unattended
15
+ # job can produce if left unchecked:
16
+ # armed the operator's switch (bin/drain on|off)
17
+ # branch never commit onto whatever branch another workstream left
18
+ # checked out — the shared-checkout hazard (see doctrine/WORKFLOW.md,
19
+ # "the clean tree is the truth")
20
+ # clean a fire killed mid-issue by a quota ceiling leaves a dirty tree;
21
+ # the NEXT fire refuses to start rather than building on top of it
22
+ # quota skip if the quota probe recently saw a wall — see QUOTA PROBE
23
+ # paths diff the finished work against config.json's protected paths
24
+ # before pushing; the tracker's own tags say what we should not
25
+ # START, this says what we actually TOUCHED
26
+ #
27
+ # EXIT CODES are the observable contract (status reads last-run.json, but a
28
+ # human running `drain now` reads these):
29
+ # 0 work landed 3 not on the expected branch 6 nothing eligible
30
+ # 1 internal error 4 tree not clean 7 timed out (see blockedOn)
31
+ # 2 disarmed 5 quota wall 8 touched a protected path
32
+ set -e
33
+
34
+ # ONE ROOT, TWO PATHS — see lib/roots.sh. ENTROPY_MACHINES_HOME is the harness
35
+ # DIRECTORY (this script's own bin/, and the lib/ beside it); $root is the
36
+ # repository this works on. They are the same tree — the harness is vendored
37
+ # inside the project — but not necessarily the same directory.
38
+ #
39
+ # THE cd COMES FIRST AND IS LOAD-BEARING. A launchd/systemd unit fires with cwd
40
+ # at `/`, so git has nothing to answer from. Stepping into the harness
41
+ # directory — which is inside the repo — gives it one. This is what replaced
42
+ # the old "an installed job only works when the harness sits at the project
43
+ # root" gap: nothing has to carry the root in.
44
+ . "$(dirname "$0")/../lib/roots.sh"
45
+ ENTROPY_MACHINES_HOME=$(entropy_machines_home "$0")
46
+ cd "$ENTROPY_MACHINES_HOME"
47
+ # The nested-clone refusal still applies and must be asked for explicitly,
48
+ # because this script deliberately skips entropy_machines_require_root (below).
49
+ entropy_machines_refuse_nested_clone drain-run
50
+
51
+ # entropy_machines_root, not entropy_machines_require_root: the "no config.json" refusal exits
52
+ # 2, and 2 is already this script's documented "disarmed" code (see EXIT CODES
53
+ # above). A misconfigured repo is an internal error (1), not a disarmed one,
54
+ # and cfg() below already defaults every key it reads.
55
+ if ! root=$(entropy_machines_root); then
56
+ echo "drain-run: could not resolve the repository from $ENTROPY_MACHINES_HOME." >&2
57
+ exit 1
58
+ fi
59
+
60
+ # ---- config -------------------------------------------------------------
61
+ # Same cfg() as bin/drain. Kept as a second copy rather than a shared sourced
62
+ # file: these are the only two callers, both tiny, and a sourced helper would
63
+ # be a third file to keep in sync with the config.json shape for a five-line
64
+ # function. If a third caller shows up, that calculus changes.
65
+ cfg() {
66
+ python3 - "$root/config.json" "$1" "$2" <<'PY'
67
+ import json, sys
68
+ path, key, default = sys.argv[1], sys.argv[2], sys.argv[3]
69
+ try:
70
+ with open(path) as f:
71
+ data = json.load(f)
72
+ except Exception:
73
+ print(default); sys.exit(0)
74
+ cur = data
75
+ # TOLERATE A LEADING DOT. Every call site in this file and in bin/drain writes
76
+ # the key as '.unattended.x' (a jq habit), which split('.') turns into a first
77
+ # segment of '' that matches no dict key — so this helper silently returned its
78
+ # DEFAULT for every lookup, and no unattended setting in config.json had any
79
+ # effect. Fixed here rather than at a dozen call sites so both spellings work.
80
+ for part in key.lstrip('.').split('.'):
81
+ if isinstance(cur, dict) and part in cur:
82
+ cur = cur[part]
83
+ else:
84
+ print(default); sys.exit(0)
85
+ print(default if cur is None else (cur if isinstance(cur, str) else json.dumps(cur)))
86
+ PY
87
+ }
88
+
89
+ # ---- QUOTA PROBE ----------------------------------------------------------
90
+ # Whether the configured coding-agent CLI recently hit a rate/quota wall, so a
91
+ # fire that would only hit the same wall again can be skipped cheaply instead
92
+ # of burning a session discovering it. This is OPTIONAL and OFF by default:
93
+ # there is no universal way to ask an arbitrary agent CLI "are you rate
94
+ # limited right now", so absent a configured probe this must not skip fires —
95
+ # skipping without evidence is a worse failure mode than occasionally hitting
96
+ # a wall the hard way.
97
+ #
98
+ # The one probe type implemented here, "log-glob", is a real but fragile
99
+ # technique: it globs a directory of the agent CLI's own session logs for a
100
+ # literal error-marker substring and reads a timestamp field off the JSON line
101
+ # that contains it. It ASSUMES:
102
+ # - the CLI writes one JSON object per line (JSONL) to files under logGlob
103
+ # - a rate-limit response is logged as a line containing errorMarker verbatim
104
+ # - that line is valid JSON with a "timestamp" field in a sortable string form
105
+ # - file mtimes are a reasonable proxy for "was this session active recently"
106
+ # Every one of those is an implementation detail of whatever agent CLI you
107
+ # point it at, not a stable interface, and can silently stop matching the
108
+ # moment that CLI changes its log format. Configure it under
109
+ # unattended.agent.quotaProbe in config.json:
110
+ #
111
+ # "quotaProbe": {
112
+ # "type": "log-glob",
113
+ # "logGlob": "~/.claude/projects/**/*.jsonl",
114
+ # "errorMarker": "\"error\":\"rate_limit\"",
115
+ # "lookbackSeconds": 3600
116
+ # }
117
+ #
118
+ # Leave quotaProbe unset (or type "none") to run with no quota awareness at
119
+ # all — every fire attempts the work and simply fails/times out if the agent
120
+ # CLI itself refuses.
121
+ probe_quota() {
122
+ ptype=$(cfg '.unattended.agent.quotaProbe.type' 'none')
123
+ case "$ptype" in
124
+ none|"") return 0 ;; # no probe configured — print nothing, never skip
125
+ esac
126
+ glob=$(cfg '.unattended.agent.quotaProbe.logGlob' '')
127
+ marker=$(cfg '.unattended.agent.quotaProbe.errorMarker' '')
128
+ lookback=$(cfg '.unattended.agent.quotaProbe.lookbackSeconds' '3600')
129
+ if [ -z "$glob" ] || [ -z "$marker" ]; then
130
+ return 0 # misconfigured probe — same as no probe, never skip
131
+ fi
132
+ python3 - "$glob" "$marker" "$lookback" <<'PY' 2>/dev/null || true
133
+ import glob as globmod, json, os, sys, time
134
+ pattern, marker, lookback = sys.argv[1], sys.argv[2], float(sys.argv[3])
135
+ cut = time.time() - lookback
136
+ newest = None
137
+ for f in globmod.glob(os.path.expanduser(pattern), recursive=True):
138
+ try:
139
+ if os.path.getmtime(f) < cut:
140
+ continue
141
+ for line in open(f, errors="ignore"):
142
+ if marker in line:
143
+ try:
144
+ ts = json.loads(line).get("timestamp")
145
+ except Exception:
146
+ continue
147
+ if ts and (newest is None or ts > newest):
148
+ newest = ts
149
+ except OSError:
150
+ continue
151
+ print(newest or "")
152
+ PY
153
+ }
154
+
155
+ if [ "$1" = "--probe-quota" ]; then
156
+ probe_quota
157
+ exit 0
158
+ fi
159
+
160
+ state_default=$(cfg '.unattended.stateHome' '~/.entropy-machines')
161
+ case "$state_default" in
162
+ "~"*) state_default="$HOME${state_default#\~}" ;;
163
+ esac
164
+ state="${ENTROPY_DRAIN_HOME:-$state_default}"
165
+ flag="$state/armed"
166
+ runlog="$state/last-run.json"
167
+ stamp=$(date -u +%Y-%m-%dT%H:%M:%SZ)
168
+ slug=$(date -u +%Y%m%d-%H%M%S)
169
+ out="$state/runs/$slug"
170
+ foreground=""
171
+ [ "$1" = "--foreground" ] && foreground=1
172
+
173
+ want_branch=$(cfg '.unattended.branch' 'main')
174
+ remote=$(cfg '.unattended.remote' 'origin')
175
+ branch_prefix=$(cfg '.unattended.branchPrefix' 'auto/')
176
+ forge_cli=$(cfg '.unattended.forgeCli' 'gh')
177
+ timeout_s=$(cfg '.unattended.sessionTimeoutSeconds' '5400')
178
+
179
+ mkdir -p "$state/runs"
180
+
181
+ # Single-flight. Two fires in one tree is the exact thing every guard below is
182
+ # protecting against, and the scheduler will happily start a second while the
183
+ # first is still going if a run outlives its hour.
184
+ lock="$state/lock"
185
+ if ! mkdir "$lock" 2>/dev/null; then
186
+ echo "drain: another fire is in progress ($lock) — exiting"
187
+ exit 0
188
+ fi
189
+ trap 'rmdir "$lock" 2>/dev/null || true' EXIT INT TERM
190
+
191
+ finish() { # outcome, exit code, detail
192
+ cat > "$runlog" <<JSON
193
+ {
194
+ "startedAt": "$stamp",
195
+ "finishedAt": "$(date -u +%Y-%m-%dT%H:%M:%SZ)",
196
+ "outcome": "$1",
197
+ "exit": $2,
198
+ "detail": "$3",
199
+ "log": "$out"
200
+ }
201
+ JSON
202
+ echo "drain: $1 ($3)"
203
+ exit "$2"
204
+ }
205
+
206
+ # ---- guard: armed ---------------------------------------------------------
207
+ if [ -z "$foreground" ] && [ ! -f "$flag" ]; then
208
+ # Not an error and not worth a log entry — a disarmed job is meant to fire
209
+ # and do nothing. Writing last-run.json here would bury the real last run.
210
+ echo "drain: disarmed, nothing to do"
211
+ exit 2
212
+ fi
213
+
214
+ cd "$root"
215
+
216
+ # ---- guard: branch --------------------------------------------------------
217
+ branch=$(git branch --show-current 2>/dev/null || echo "")
218
+ if [ "$branch" != "$want_branch" ]; then
219
+ finish "skipped" 3 "on branch '$branch', not '$want_branch'"
220
+ fi
221
+
222
+ # ---- guard: clean tree ----------------------------------------------------
223
+ if ! git diff --quiet || ! git diff --cached --quiet; then
224
+ finish "skipped" 4 "working tree is dirty — triage the previous run first"
225
+ fi
226
+
227
+ # ---- guard: quota ---------------------------------------------------------
228
+ recent=$(probe_quota)
229
+ if [ -n "$recent" ] && [ -z "$foreground" ]; then
230
+ finish "skipped" 5 "quota probe saw a wall at $recent — window has not turned over"
231
+ fi
232
+
233
+ # ---- pick the work --------------------------------------------------------
234
+ # The cost model and budget live in config.json (unattended.sizeCosts,
235
+ # unattended.budget) and are applied by bin/drain-pick.py, which has tests.
236
+ # THE rc IS CAPTURED, NOT SWALLOWED. Under `set -e` a bare
237
+ # `picked=$(...)` assignment kills this script the moment drain-pick exits
238
+ # non-zero -- so a picker that could not reach the tracker took the whole run
239
+ # down BEFORE the idle handler below, leaving no last-run.json, the real error
240
+ # buried in pick.err, and `bin/drain status` still reporting "last fire:
241
+ # never". Silent because the two outcomes look identical from outside: an
242
+ # empty $picked means "nothing eligible", which is a normal idle tick, while a
243
+ # non-zero rc means the picker itself failed and somebody has to be told.
244
+ pick_rc=0
245
+ picked=$(python3 "$ENTROPY_MACHINES_HOME/bin/drain-pick.py" "$root" 2>>"$state/pick.err") || pick_rc=$?
246
+ if [ "$pick_rc" -ne 0 ]; then
247
+ finish "error" 7 "drain-pick failed (rc $pick_rc) — see $state/pick.err"
248
+ fi
249
+ if [ -z "$picked" ]; then
250
+ finish "idle" 6 "nothing eligible for an unattended run"
251
+ fi
252
+ count=$(echo "$picked" | wc -w | tr -d ' ')
253
+ echo "drain: taking $count issue(s): $picked"
254
+
255
+ # ---- run ------------------------------------------------------------------
256
+ # NO permission flags. An unattended session gets exactly the same permissions
257
+ # an attended one would — an unrecognised command therefore prompts, and with
258
+ # nobody there to answer, the session stalls. That is why the timeout below is
259
+ # not optional: a stall costs one window and leaves a specific line in the log
260
+ # to widen the allowlist by, rather than hanging until the scheduler kills it.
261
+ prompt=$(sed "s|__ISSUES__|$picked|g; s|__BRANCH__|${branch_prefix}${slug}|g" "$ENTROPY_MACHINES_HOME/bin/drain-prompt.md")
262
+
263
+ # Agent invocation is pluggable: unattended.agent.cmd is the argv prefix
264
+ # (default ["claude", "-p"]) and the prompt is appended as the final argument.
265
+ # Building it as newline-separated tokens rather than a JSON array kept in one
266
+ # shell variable is deliberate — POSIX sh has no arrays, and this is the
267
+ # smallest portable way to rebuild an argv from a list of strings.
268
+ mkdir -p "$out"
269
+ set --
270
+ while IFS= read -r tok; do
271
+ [ -n "$tok" ] && set -- "$@" "$tok"
272
+ done <<AGENTCMD
273
+ $(python3 - "$root/config.json" <<'PY'
274
+ import json, sys
275
+ try:
276
+ data = json.load(open(sys.argv[1]))
277
+ cmd = data.get("unattended", {}).get("agent", {}).get("cmd") or ["claude", "-p"]
278
+ except Exception:
279
+ cmd = ["claude", "-p"]
280
+ for tok in cmd:
281
+ print(tok)
282
+ PY
283
+ )
284
+ AGENTCMD
285
+ "$@" "$prompt" > "$out/session.json" 2> "$out/session.err" &
286
+ pid=$!
287
+ deadline=$(( $(date +%s) + timeout_s ))
288
+ while kill -0 "$pid" 2>/dev/null; do
289
+ if [ "$(date +%s)" -gt "$deadline" ]; then
290
+ kill -TERM "$pid" 2>/dev/null || true
291
+ sleep 5
292
+ kill -KILL "$pid" 2>/dev/null || true
293
+ blocked=$(tail -c 2000 "$out/session.err" 2>/dev/null | tr '\n' ' ' | tr -d '"' | tail -c 300)
294
+ finish "timeout" 7 "killed after $((timeout_s / 60))m — tail: ${blocked:-nothing on stderr}"
295
+ fi
296
+ sleep 20
297
+ done
298
+
299
+ # ---- guard: paths ---------------------------------------------------------
300
+ # The enforced half of the autonomy rule. The tracker's own tags say what an
301
+ # issue should not START on; this says what the session actually TOUCHED,
302
+ # which is the only one that survives an untagged issue turning out unsafe.
303
+ # protectedPaths is a plain list of path prefixes in config.json
304
+ # (project.protectedPaths) — not a project-specific Python module, so widening
305
+ # or narrowing the guarded set is a one-line config edit.
306
+ guarded=$(python3 - "$root" "$remote" "$want_branch" <<'PY'
307
+ import json, os, subprocess, sys
308
+ root, remote, branch = sys.argv[1], sys.argv[2], sys.argv[3]
309
+ protected = []
310
+ try:
311
+ with open(os.path.join(root, "config.json")) as f:
312
+ protected = json.load(f).get("project", {}).get("protectedPaths", []) or []
313
+ except Exception:
314
+ protected = []
315
+ if not protected:
316
+ print("")
317
+ sys.exit(0)
318
+ changed = subprocess.run(
319
+ ["git", "diff", "--name-only", f"{remote}/{branch}...HEAD"],
320
+ cwd=root, capture_output=True, text=True).stdout.split()
321
+ hits = [f for f in changed if any(f.startswith(p) or f == p for p in protected)]
322
+ print(",".join(hits))
323
+ PY
324
+ )
325
+ if [ -n "$guarded" ]; then
326
+ finish "quarantined" 8 "touched protected path(s): $guarded — left uncommitted for review"
327
+ fi
328
+
329
+ # ---- hand it over for review ----------------------------------------------
330
+ # Pushed, never merged. An unattended run earns no more trust than a human PR
331
+ # does — review happens on it like any other change.
332
+ branch_name="${branch_prefix}${slug}"
333
+ if git rev-parse --verify "$branch_name" >/dev/null 2>&1; then
334
+ git push -q -u "$remote" "$branch_name" 2>>"$out/session.err" || \
335
+ finish "landed-unpushed" 0 "commits made, push failed — see $out/session.err"
336
+ if [ -n "$forge_cli" ] && command -v "$forge_cli" >/dev/null 2>&1; then
337
+ "$forge_cli" pr create --draft --fill --base "$want_branch" --head "$branch_name" \
338
+ >> "$out/session.err" 2>&1 || true
339
+ fi
340
+ finish "landed" 0 "$count issue(s) on $branch_name, pushed for review"
341
+ fi
342
+ finish "no-op" 0 "session ran but produced no branch — see $out/session.json"