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/serve ADDED
@@ -0,0 +1,724 @@
1
+ #!/bin/sh
2
+ # The factory lights. A local web server, not a CLI:
3
+ #
4
+ # bin/serve [port] # default 8787
5
+ #
6
+ # GET / dashboard — ready issues, in-flight claims,
7
+ # per-doc answered/unanswered counts. Built
8
+ # only from what bin/tracker and lib/notes.py
9
+ # already know; no new state.
10
+ # GET /<doc>.html a doc from the configured docs directory,
11
+ # with the Downloads-folder save always
12
+ # defeated (see SAVE_PATCH below), and the
13
+ # theme named by docs.theme INLINED into it
14
+ # (see THEME below).
15
+ # GET /__docversion?file=<f> {"reviews": "<hash>"} — polled by the doc's
16
+ # own baked-in reload watcher (see
17
+ # lib/doc-template.html's __docversion
18
+ # script). Changes when the doc changes for a
19
+ # reason OTHER than the reader's own answer
20
+ # being written back — see docs/SERVE.md.
21
+ # POST /__save?file=<f> body is {data-resp: answer}; merged back
22
+ # into <f> ON DISK, atomically.
23
+ #
24
+ # See docs/SERVE.md for the full contract, the docversion hash's exact
25
+ # semantics, and the dashboard's data sources.
26
+ set -e
27
+
28
+ usage() {
29
+ cat >&2 <<'EOF'
30
+ usage: bin/serve [port]
31
+ EOF
32
+ exit 2
33
+ }
34
+
35
+ case "$1" in
36
+ -h|--help) usage ;;
37
+ esac
38
+
39
+ . "$(dirname "$0")/../lib/roots.sh"
40
+ ENTROPY_MACHINES_HOME=$(entropy_machines_home "$0")
41
+ entropy_machines_require_root serve
42
+
43
+ CONFIG_PY="$ENTROPY_MACHINES_HOME/lib/config.py"
44
+ NOTES_PY="$ENTROPY_MACHINES_HOME/lib/notes.py"
45
+ TRACKER="$ENTROPY_MACHINES_HOME/bin/tracker"
46
+
47
+ # docs.dir has no entry in lib/config.py's DEFAULTS (that file is shared
48
+ # infrastructure, not this bullet's to extend) — so a project that has not
49
+ # set it fails config.py's "no such key" lookup, and that failure IS the
50
+ # default path, not an error to surface.
51
+ docs_dir=$(python3 "$CONFIG_PY" get docs.dir 2>/dev/null) || docs_dir="entropy-machines-docs"
52
+ case "$docs_dir" in
53
+ /*) docs_path="$docs_dir" ;;
54
+ *) docs_path="$ENTROPY_MACHINES_ROOT/$docs_dir" ;;
55
+ esac
56
+ mkdir -p "$docs_path"
57
+
58
+ # ---------------------------------------------------------------------------
59
+ # THEME. docs.theme names a file in lib/themes/, and its tokens are INLINED
60
+ # into every doc served and into the dashboard — never <link>ed. A doc in this
61
+ # factory is a standalone local file the owner may open straight off disk or
62
+ # move somewhere else, and an external-looking stylesheet reference is the
63
+ # exact thing bin/doclint exists to refuse.
64
+ #
65
+ # An unknown name is REFUSED here, before anything binds a port, and the
66
+ # refusal lists what exists. No silent fallback: docs/CONFIG.md rule 2 says a
67
+ # key with a bad value is refused by name, and a server that quietly served
68
+ # the default would look identical to one that applied the theme.
69
+ #
70
+ # Like docs.dir above, docs.theme has no entry in lib/config.py's DEFAULTS —
71
+ # that shared file is not this command's to extend — so a project that never
72
+ # set it fails the lookup, and that failure IS the default path.
73
+ # ---------------------------------------------------------------------------
74
+ THEMES_DIR="$ENTROPY_MACHINES_HOME/lib/themes"
75
+
76
+ list_themes() {
77
+ for _f in "$THEMES_DIR"/*.css; do
78
+ if [ -f "$_f" ]; then
79
+ _n=$(basename "$_f")
80
+ echo " ${_n%.css}"
81
+ fi
82
+ done
83
+ }
84
+
85
+ if theme=$(python3 "$CONFIG_PY" get docs.theme 2>/dev/null); then
86
+ theme_source="docs.theme in config.json"
87
+ else
88
+ theme="high-contrast"
89
+ theme_source="the default (docs.theme is not set)"
90
+ fi
91
+
92
+ theme_bad=""
93
+ case "$theme" in
94
+ '') theme_bad="it is empty" ;;
95
+ */*) theme_bad="it contains a path separator" ;;
96
+ *..*) theme_bad="it contains .." ;;
97
+ .*) theme_bad="it starts with a dot" ;;
98
+ esac
99
+ if [ -n "$theme_bad" ]; then
100
+ echo "serve: REFUSED — docs.theme is not a usable theme name ($theme_bad): '$theme'." >&2
101
+ echo " A theme is a NAME, resolved to $THEMES_DIR/<name>.css. Available:" >&2
102
+ list_themes >&2
103
+ exit 1
104
+ fi
105
+
106
+ theme_file="$THEMES_DIR/$theme.css"
107
+ if [ ! -r "$theme_file" ]; then
108
+ echo "serve: REFUSED — no such theme '$theme' (from $theme_source)." >&2
109
+ echo " Looked for: $theme_file" >&2
110
+ if [ -n "$(list_themes)" ]; then
111
+ echo " Available themes:" >&2
112
+ list_themes >&2
113
+ else
114
+ echo " No themes are installed — $THEMES_DIR holds no readable .css file." >&2
115
+ fi
116
+ echo " Set docs.theme to one of those, or add that file. Nothing was served:" >&2
117
+ echo " a theme that cannot be read is not silently replaced with another one." >&2
118
+ exit 1
119
+ fi
120
+
121
+ port="${1:-8787}"
122
+ case "$port" in
123
+ ''|*[!0-9]*)
124
+ echo "serve: REFUSED — port must be a number, got '$port'." >&2
125
+ usage
126
+ ;;
127
+ esac
128
+
129
+ export ENTROPY_MACHINES_HOME ENTROPY_MACHINES_ROOT
130
+ export SERVE_DOCS_PATH="$docs_path"
131
+ export SERVE_DOCS_DIR="$docs_dir"
132
+ export SERVE_PORT="$port"
133
+ export SERVE_TRACKER="$TRACKER"
134
+ export SERVE_THEME_NAME="$theme"
135
+ export SERVE_THEME_FILE="$theme_file"
136
+ export SERVE_NOTES_PY="$NOTES_PY"
137
+
138
+ exec python3 - <<'PY'
139
+ """bin/serve's HTTP server, run via `python3 -` from the shell shim above.
140
+ Everything it needs — roots, docs dir, port — arrives as environment, already
141
+ resolved by bin/serve (docs/CONFIG.md rule 3: a backend never re-reads
142
+ config.json itself; this applies the same discipline to bin/serve's own
143
+ python half).
144
+ """
145
+ from __future__ import annotations
146
+
147
+ import hashlib
148
+ import html
149
+ import json
150
+ import os
151
+ import re
152
+ import socket
153
+ import subprocess
154
+ import sys
155
+ import tempfile
156
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
157
+ from urllib.parse import urlparse, parse_qs
158
+
159
+ ENTROPY_MACHINES_HOME = os.environ["ENTROPY_MACHINES_HOME"]
160
+ ENTROPY_MACHINES_ROOT = os.environ["ENTROPY_MACHINES_ROOT"]
161
+ DOCS_PATH = os.environ["SERVE_DOCS_PATH"]
162
+ DOCS_DIR = os.environ["SERVE_DOCS_DIR"]
163
+ PORT = int(os.environ["SERVE_PORT"])
164
+ TRACKER = os.environ["SERVE_TRACKER"]
165
+ NOTES_PY = os.environ["SERVE_NOTES_PY"]
166
+ THEME_NAME = os.environ["SERVE_THEME_NAME"]
167
+ THEME_FILE = os.environ["SERVE_THEME_FILE"]
168
+
169
+ # ---------------------------------------------------------------------------
170
+ # THEME — read ONCE, at startup, and refused loudly if it cannot be read.
171
+ #
172
+ # The shell half above already checked the name and the file's readability, so
173
+ # reaching the except here means the file went away or turned unreadable
174
+ # between that check and this read. Either way the server does not start: a
175
+ # transform that cannot read its input must refuse, never pass its input
176
+ # through unchanged. Serving the doc's own baked-in tokens after being told to
177
+ # apply another theme is indistinguishable, in the browser, from a theme that
178
+ # does not work.
179
+ # ---------------------------------------------------------------------------
180
+ try:
181
+ with open(THEME_FILE, encoding="utf-8") as _f:
182
+ THEME_CSS = _f.read()
183
+ except (OSError, UnicodeDecodeError) as exc:
184
+ sys.stderr.write(
185
+ "serve: REFUSED — cannot read the theme '%s' at %s (%s).\n"
186
+ " Nothing was served. A theme that cannot be read is not silently\n"
187
+ " replaced with the doc's own tokens.\n" % (THEME_NAME, THEME_FILE, exc)
188
+ )
189
+ sys.exit(1)
190
+
191
+ if not THEME_CSS.strip():
192
+ sys.stderr.write(
193
+ "serve: REFUSED — the theme '%s' at %s is empty.\n"
194
+ " A theme file is a :root token block; an empty one would leave every\n"
195
+ " doc with no colours and no type scale at all.\n" % (THEME_NAME, THEME_FILE)
196
+ )
197
+ sys.exit(1)
198
+
199
+ # The span a doc hands over to the theme. Everything between the two markers is
200
+ # replaced, the markers themselves are kept, and NOTHING else in the file is
201
+ # touched — no regex over the doc's CSS, no guessing at which :root rule is the
202
+ # token block. A doc's structural CSS lives below the end marker and is shared
203
+ # by every theme, which is the whole reason this ships themes and not a second
204
+ # template. A doc with no markers is served exactly as it is, and said so in
205
+ # the log, because the alternative is rewriting a span this never agreed on.
206
+ THEME_BEGIN = "/* entropy-machines-theme:begin */"
207
+ THEME_END = "/* entropy-machines-theme:end */"
208
+
209
+ _unthemed_reported = set()
210
+
211
+
212
+ def apply_theme(text: str, doc_name: str) -> str:
213
+ begin = text.find(THEME_BEGIN)
214
+ end = text.find(THEME_END, begin + len(THEME_BEGIN)) if begin != -1 else -1
215
+ if begin == -1 or end == -1:
216
+ if doc_name not in _unthemed_reported:
217
+ _unthemed_reported.add(doc_name)
218
+ sys.stderr.write(
219
+ " theme '%s' NOT applied to %s — it has no %s / %s markers "
220
+ "around its token block; served with its own tokens.\n"
221
+ % (THEME_NAME, doc_name, THEME_BEGIN, THEME_END)
222
+ )
223
+ return text
224
+ head = text[: begin + len(THEME_BEGIN)]
225
+ body = THEME_CSS if THEME_CSS.endswith("\n") else THEME_CSS + "\n"
226
+ return head + "\n" + body + text[end:]
227
+
228
+
229
+ # ---------------------------------------------------------------------------
230
+ # save patch — injected at serve time, never written into a file. Keeping
231
+ # this out of the doc on disk is the whole point: no doc can be served with
232
+ # the browser's Downloads-folder save (showSaveFilePicker / <a download>)
233
+ # still wired up as the only way to save, because this always runs first,
234
+ # in the capture phase, and stopImmediatePropagation kills every listener
235
+ # behind it on the same click — including lib/doc-template.html's own baked
236
+ # -in __disk-save-patch, which already does the right thing but is not
237
+ # guaranteed to be present on every doc this ever serves.
238
+ # ---------------------------------------------------------------------------
239
+ SAVE_PATCH = """
240
+ <script>
241
+ (function(){
242
+ var btn=document.getElementById('saveBtn'), stat=document.getElementById('saveStat');
243
+ if(!btn) return;
244
+ var file=location.pathname.replace(/^\\//,'');
245
+ btn.addEventListener('click', function(ev){
246
+ ev.stopImmediatePropagation(); ev.preventDefault();
247
+ var out={};
248
+ document.querySelectorAll('[data-resp]').forEach(function(el){
249
+ var ta=el.querySelector('textarea'); if(!ta) return;
250
+ var v=(ta.value||''); if(v.trim()) out[el.getAttribute('data-resp')]=v;
251
+ });
252
+ if(stat) stat.textContent='Saving…';
253
+ fetch('/__save?file='+encodeURIComponent(file),
254
+ {method:'POST',headers:{'Content-Type':'application/json'},
255
+ body:JSON.stringify(out)})
256
+ .then(function(r){ if(!r.ok) throw new Error('HTTP '+r.status); return r.json(); })
257
+ .then(function(j){
258
+ if(stat) stat.textContent='Saved '+j.count+' answer(s) to '+file;
259
+ var el=document.getElementById('responses-data');
260
+ if(el) el.textContent=JSON.stringify(out,null,2);
261
+ })
262
+ .catch(function(e){ if(stat) stat.textContent='SAVE FAILED: '+e.message; });
263
+ }, true);
264
+ })();
265
+ </script>
266
+ """
267
+
268
+
269
+ def safe_doc_name(name: str):
270
+ """A save endpoint that takes a path is a file-write primitive aimed at
271
+ the whole disk — reject anything that is not a plain .html filename
272
+ directly inside the configured docs directory. Same three rules for GET
273
+ and POST alike: no '/', no '..', must end '.html'."""
274
+ if not name or "/" in name or ".." in name or not name.endswith(".html"):
275
+ return None
276
+ return name
277
+
278
+
279
+ def doc_path(name: str):
280
+ safe = safe_doc_name(name)
281
+ if safe is None:
282
+ return None
283
+ return os.path.join(DOCS_PATH, safe)
284
+
285
+
286
+ # ---------------------------------------------------------------------------
287
+ # /__docversion — a content hash the doc's own baked-in reload watcher polls
288
+ # (lib/doc-template.html's __docversion script, present on any doc built
289
+ # from that template — see docs/SERVE.md for what happens on an older doc
290
+ # that lacks it: nothing breaks, it just never reloads on its own).
291
+ #
292
+ # WHY NOT A HASH OF THE WHOLE FILE. The reader's own 💾 rewrites the
293
+ # #responses-data block and every answered <textarea> — hash the whole file
294
+ # and saving your OWN answer changes the hash, which the watcher reads as
295
+ # "a reply arrived" and either reloads for no reason or, worse, throws up
296
+ # the dirty-changes bar over nothing. So the hash excludes exactly the two
297
+ # regions /__save owns: the #responses-data JSON block's contents, and the
298
+ # text inside every <textarea>. What is left changes only when something
299
+ # OTHER than an answer round-trip touched the file — an agent's reply
300
+ # written into the doc's prose, a new response box, a restructured section.
301
+ # ---------------------------------------------------------------------------
302
+ _RESPONSES_DATA_RE = re.compile(
303
+ r'(<script type="application/json" id="responses-data">)(.*?)(</script>)',
304
+ re.S,
305
+ )
306
+ _TEXTAREA_RE = re.compile(r"(<textarea\b[^>]*>)(.*?)(</textarea>)", re.S)
307
+
308
+
309
+ def doc_signature(text: str) -> str:
310
+ stripped = _RESPONSES_DATA_RE.sub(lambda m: m.group(1) + m.group(3), text)
311
+ stripped = _TEXTAREA_RE.sub(lambda m: m.group(1) + m.group(3), stripped)
312
+ return hashlib.sha256(stripped.encode("utf-8")).hexdigest()
313
+
314
+
315
+ # ---------------------------------------------------------------------------
316
+ # save merge — same shape as the verified planning/serve.py reference:
317
+ # rewrite #responses-data and each matching <textarea>, atomically.
318
+ # ---------------------------------------------------------------------------
319
+ def merge(path: str, answers: dict) -> int:
320
+ with open(path, encoding="utf-8") as f:
321
+ doc = f.read()
322
+
323
+ doc = _RESPONSES_DATA_RE.sub(
324
+ lambda m: m.group(1) + json.dumps(answers, indent=1) + m.group(3),
325
+ doc,
326
+ count=1,
327
+ )
328
+
329
+ for key, val in answers.items():
330
+ pat = re.compile(
331
+ r'(data-resp="' + re.escape(key) + r'"[\s\S]*?<textarea[^>]*>)([\s\S]*?)(</textarea>)'
332
+ )
333
+ doc = pat.sub(lambda m: m.group(1) + html.escape(val) + m.group(3), doc, count=1)
334
+
335
+ d = os.path.dirname(path) or "."
336
+ fd, tmp = tempfile.mkstemp(prefix=".serve-", dir=d)
337
+ try:
338
+ with os.fdopen(fd, "w", encoding="utf-8") as f:
339
+ f.write(doc)
340
+ os.replace(tmp, path) # atomic: a reader never sees a torn file
341
+ except Exception:
342
+ try:
343
+ os.unlink(tmp)
344
+ except OSError:
345
+ pass
346
+ raise
347
+ return len(answers)
348
+
349
+
350
+ # ---------------------------------------------------------------------------
351
+ # per-doc answered/unanswered counts, for the dashboard
352
+ # ---------------------------------------------------------------------------
353
+ _DATA_RESP_RE = re.compile(r'data-resp="([^"]+)"')
354
+ _COMMENT_RE = re.compile(r"<!--[\s\S]*?-->")
355
+
356
+
357
+ def doc_counts(path: str):
358
+ try:
359
+ with open(path, encoding="utf-8") as f:
360
+ text = f.read()
361
+ except OSError:
362
+ return None
363
+ # STRIP COMMENTS BEFORE COUNTING. lib/doc-template.html documents the
364
+ # contract in its own header comment, and that documentation contains a
365
+ # literal data-resp="UNIQUE-KEY" example. Counting it made every doc built
366
+ # from the template report one phantom unanswered question that no reader
367
+ # could ever answer -- so "fully answered" was an unreachable state and the
368
+ # dashboard could never go green.
369
+ keys = _DATA_RESP_RE.findall(_COMMENT_RE.sub("", text))
370
+ total = len(keys)
371
+ m = _RESPONSES_DATA_RE.search(text)
372
+ answered_keys = set()
373
+ if m:
374
+ try:
375
+ data = json.loads(m.group(2) or "{}")
376
+ if isinstance(data, dict):
377
+ answered_keys = {k for k, v in data.items() if isinstance(v, str) and v.strip()}
378
+ except json.JSONDecodeError:
379
+ pass
380
+ answered = sum(1 for k in keys if k in answered_keys)
381
+ return {"total": total, "answered": answered}
382
+
383
+
384
+ def list_docs():
385
+ try:
386
+ names = sorted(n for n in os.listdir(DOCS_PATH) if n.endswith(".html"))
387
+ except OSError:
388
+ return []
389
+ out = []
390
+ for n in names:
391
+ counts = doc_counts(os.path.join(DOCS_PATH, n))
392
+ out.append({"name": n, "counts": counts})
393
+ return out
394
+
395
+
396
+ # ---------------------------------------------------------------------------
397
+ # tracker data for the dashboard — shells to bin/tracker exactly the way
398
+ # bin/dispatch does, so a reader sees the same "ready" and "in flight" the
399
+ # dispatcher itself enforces against, not a second opinion.
400
+ # ---------------------------------------------------------------------------
401
+ def run(cmd, input_text=None):
402
+ try:
403
+ return subprocess.run(
404
+ cmd, input=input_text, capture_output=True, text=True, timeout=10, check=False,
405
+ )
406
+ except (OSError, subprocess.SubprocessError):
407
+ return None
408
+
409
+
410
+ def ready_issues():
411
+ r = run([TRACKER, "ready"])
412
+ if r is None or r.returncode != 0:
413
+ return None
414
+ out = []
415
+ for line in r.stdout.splitlines():
416
+ line = line.strip()
417
+ if not line:
418
+ continue
419
+ try:
420
+ out.append(json.loads(line))
421
+ except json.JSONDecodeError:
422
+ continue
423
+ return out
424
+
425
+
426
+ def in_flight_claims():
427
+ """CLAIM/PATHS lines from lib/notes.py, over ALL notes — same derivation
428
+ bin/dispatch uses: an issue's last event is a DISPATCH (still holding
429
+ its scope) or a HANDOFF (released). 24h default expiry, matching
430
+ DISPATCH_CLAIM_HOURS's default in bin/dispatch."""
431
+ notes = run([TRACKER, "notes"])
432
+ if notes is None or notes.returncode != 0:
433
+ return None
434
+ hours = os.environ.get("DISPATCH_CLAIM_HOURS", "24")
435
+ r = run(["python3", NOTES_PY, "claims", "--hours", hours], input_text=notes.stdout)
436
+ if r is None or r.returncode != 0:
437
+ return None
438
+ claims = []
439
+ for line in r.stdout.splitlines():
440
+ parts = line.split("\t")
441
+ if parts and parts[0] == "CLAIM" and len(parts) >= 3:
442
+ claims.append({"issue": parts[1], "paths": parts[2]})
443
+ return claims
444
+
445
+
446
+ def recent_notes(limit=20):
447
+ r = run([TRACKER, "notes"])
448
+ if r is None or r.returncode != 0:
449
+ return None
450
+ recs = []
451
+ for line in r.stdout.splitlines():
452
+ line = line.strip()
453
+ if not line:
454
+ continue
455
+ try:
456
+ recs.append(json.loads(line))
457
+ except json.JSONDecodeError:
458
+ continue
459
+ return recs[-limit:][::-1]
460
+
461
+
462
+ # ---------------------------------------------------------------------------
463
+ # dashboard page
464
+ # ---------------------------------------------------------------------------
465
+ # The dashboard is a viewable interface too, so it wears the same theme the
466
+ # docs do — same tokens, same file, no second palette to keep in step. The
467
+ # names it uses that a theme does not define (--fg, --link, --negative, --font)
468
+ # are mapped onto theme tokens right after, as var() references so they follow
469
+ # the toggle on their own.
470
+ PAGE_HEAD = """<!doctype html>
471
+ <html lang="en"><head>
472
+ <meta charset="utf-8">
473
+ <meta name="viewport" content="width=device-width, initial-scale=1">
474
+ <title>the factory</title>
475
+ <style>
476
+ /* entropy-machines-theme:begin */
477
+ __THEME__
478
+ /* entropy-machines-theme:end */
479
+ :root{
480
+ --fg:var(--ink); --link:var(--accent); --negative:var(--bad);
481
+ --font:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;
482
+ }
483
+ *{box-sizing:border-box;}
484
+ body{background:var(--bg);color:var(--fg);font:var(--fs-base)/1.5 var(--font);margin:0;padding:2rem 2.5rem 4rem;}
485
+ h1{font-size:var(--fs-title);margin:0 0 .3rem;}
486
+ h2{font-size:var(--fs-head);margin:2rem 0 .6rem;border-bottom:2px solid var(--border);padding-bottom:.3rem;}
487
+ .sub{font-size:var(--fs-sm);text-transform:uppercase;letter-spacing:.04em;margin:0 0 1.5rem;}
488
+ .empty{font-size:var(--fs-sm);font-style:italic;}
489
+ table{border-collapse:collapse;width:100%;font-size:var(--fs-base);}
490
+ th,td{border:1px solid var(--border);padding:.5rem .7rem;text-align:left;vertical-align:top;}
491
+ th{font-size:var(--fs-sm);text-transform:uppercase;letter-spacing:.03em;}
492
+ code,.mono{font:var(--fs-sm) var(--mono);}
493
+ a{color:var(--link);}
494
+ a:focus-visible,button:focus-visible{outline:2px solid var(--focus);outline-offset:2px;}
495
+ .neg{color:var(--negative);font-weight:700;}
496
+ .pos{color:var(--positive);font-weight:700;}
497
+ .caution{color:var(--caution);font-weight:700;}
498
+ .unavailable{border:2px solid var(--negative);padding:.7rem 1rem;font-weight:700;}
499
+ #theme{float:right;background:none;border:1px solid var(--border);color:var(--fg);
500
+ padding:.35rem .7rem;cursor:pointer;font:600 var(--fs-sm) var(--font);}
501
+ #theme:hover{box-shadow:inset 0 0 0 2px var(--focus);}
502
+ </style>
503
+ <script>
504
+ /* Both shipped themes name one state in the bare :root and the other under
505
+ [data-theme=…], so stamping the root is what makes BOTH directions
506
+ reachable whichever theme is inlined above. Same localStorage key the
507
+ docs use, so the dashboard and a doc agree. */
508
+ (function(){
509
+ var r=document.documentElement;
510
+ try{
511
+ r.dataset.theme = localStorage.reportTheme ||
512
+ (matchMedia('(prefers-color-scheme:dark)').matches ? 'dark' : 'light');
513
+ }catch(e){}
514
+ })();
515
+ </script>
516
+ </head><body>
517
+ <button id="theme" type="button">&#9689; theme</button>
518
+ <script>
519
+ (function(){
520
+ var r=document.documentElement, b=document.getElementById('theme');
521
+ b.onclick=function(){
522
+ var next = r.dataset.theme === 'light' ? 'dark' : 'light';
523
+ r.dataset.theme = next;
524
+ try{ localStorage.reportTheme = next; }catch(e){}
525
+ };
526
+ })();
527
+ </script>
528
+ """.replace("__THEME__", THEME_CSS.rstrip("\n"))
529
+
530
+ PAGE_TAIL = "</body></html>\n"
531
+
532
+
533
+ def esc(s):
534
+ return html.escape(str(s), quote=True)
535
+
536
+
537
+ def render_dashboard() -> str:
538
+ ready = ready_issues()
539
+ claims = in_flight_claims()
540
+ notes = recent_notes()
541
+ docs = list_docs()
542
+
543
+ parts = [PAGE_HEAD]
544
+ parts.append(f"<h1>the factory</h1>\n<p class=\"sub\">{esc(ENTROPY_MACHINES_ROOT)}</p>\n")
545
+
546
+ parts.append("<h2>ready</h2>\n")
547
+ if ready is None:
548
+ parts.append('<p class="unavailable">tracker unavailable — could not run `bin/tracker ready`.</p>\n')
549
+ elif not ready:
550
+ parts.append('<p class="empty">nothing ready — every issue is done, blocked, held or gated.</p>\n')
551
+ else:
552
+ parts.append("<table><tr><th>id</th><th>title</th><th>effort</th></tr>\n")
553
+ for issue in ready:
554
+ parts.append(
555
+ f"<tr><td class=\"mono\">{esc(issue.get('id',''))}</td>"
556
+ f"<td>{esc(issue.get('title',''))}</td>"
557
+ f"<td class=\"mono\">{esc(issue.get('effort',''))}</td></tr>\n"
558
+ )
559
+ parts.append("</table>\n")
560
+
561
+ parts.append("<h2>in flight</h2>\n")
562
+ if claims is None:
563
+ parts.append('<p class="unavailable">tracker unavailable — could not derive live claims.</p>\n')
564
+ elif not claims:
565
+ parts.append('<p class="empty">nothing dispatched right now (or every dispatch has been handed off).</p>\n')
566
+ else:
567
+ parts.append("<table><tr><th>issue</th><th>scope held</th></tr>\n")
568
+ for c in claims:
569
+ parts.append(
570
+ f"<tr><td class=\"mono\">{esc(c['issue'])}</td><td class=\"mono\">{esc(c['paths'])}</td></tr>\n"
571
+ )
572
+ parts.append("</table>\n")
573
+
574
+ parts.append(f"<h2>docs ({esc(DOCS_DIR)})</h2>\n")
575
+ if not docs:
576
+ parts.append(f'<p class="empty">no .html docs in {esc(DOCS_DIR)}.</p>\n')
577
+ else:
578
+ parts.append("<table><tr><th>doc</th><th>answered</th></tr>\n")
579
+ for d in docs:
580
+ c = d["counts"]
581
+ if c is None or c["total"] == 0:
582
+ status = '<span class="empty">no questions</span>'
583
+ elif c["answered"] >= c["total"]:
584
+ status = f'<span class="pos">{c["answered"]}/{c["total"]} answered</span>'
585
+ else:
586
+ status = f'<span class="caution">{c["answered"]}/{c["total"]} answered</span>'
587
+ parts.append(
588
+ f"<tr><td><a href=\"/{esc(d['name'])}\">{esc(d['name'])}</a></td><td>{status}</td></tr>\n"
589
+ )
590
+ parts.append("</table>\n")
591
+
592
+ parts.append("<h2>recent events</h2>\n")
593
+ if notes is None:
594
+ parts.append('<p class="unavailable">tracker unavailable — could not run `bin/tracker notes`.</p>\n')
595
+ elif not notes:
596
+ parts.append('<p class="empty">no notes recorded yet.</p>\n')
597
+ else:
598
+ parts.append("<table><tr><th>ts</th><th>verb</th><th>issue</th><th>actor</th></tr>\n")
599
+ for rec in notes:
600
+ parts.append(
601
+ f"<tr><td class=\"mono\">{esc(rec.get('ts',''))}</td>"
602
+ f"<td class=\"mono\">{esc(rec.get('verb',''))}</td>"
603
+ f"<td class=\"mono\">{esc(rec.get('issue',''))}</td>"
604
+ f"<td class=\"mono\">{esc(rec.get('actor',''))}</td></tr>\n"
605
+ )
606
+ parts.append("</table>\n")
607
+
608
+ parts.append(PAGE_TAIL)
609
+ return "".join(parts)
610
+
611
+
612
+ # ---------------------------------------------------------------------------
613
+ # HTTP handler
614
+ # ---------------------------------------------------------------------------
615
+ class Handler(BaseHTTPRequestHandler):
616
+ server_version = "entropy-serve/1"
617
+
618
+ def log_message(self, fmt, *args):
619
+ sys.stderr.write(" %s\n" % (fmt % args))
620
+
621
+ def _send(self, code, body: bytes, content_type: str):
622
+ self.send_response(code)
623
+ self.send_header("Content-Type", content_type)
624
+ self.send_header("Content-Length", str(len(body)))
625
+ self.send_header("Cache-Control", "no-store")
626
+ self.end_headers()
627
+ self.wfile.write(body)
628
+
629
+ def _send_json(self, code, obj):
630
+ self._send(code, json.dumps(obj).encode("utf-8"), "application/json")
631
+
632
+ def do_GET(self):
633
+ parsed = urlparse(self.path)
634
+ path = parsed.path
635
+
636
+ if path == "/":
637
+ self._send(200, render_dashboard().encode("utf-8"), "text/html; charset=utf-8")
638
+ return
639
+
640
+ if path == "/__docversion":
641
+ name = (parse_qs(parsed.query).get("file") or [""])[0]
642
+ target = doc_path(name)
643
+ if target is None or not os.path.isfile(target):
644
+ self._send_json(404, {"error": "no such doc"})
645
+ return
646
+ with open(target, encoding="utf-8") as f:
647
+ text = f.read()
648
+ self._send_json(200, {"reviews": doc_signature(text)})
649
+ return
650
+
651
+ # a bare filename request — the only other shape this server serves.
652
+ name = path.lstrip("/")
653
+ target = doc_path(name)
654
+ if target is not None and os.path.isfile(target):
655
+ with open(target, encoding="utf-8") as f:
656
+ body = f.read()
657
+ body = apply_theme(body, name)
658
+ if "</body>" in body:
659
+ body = body.replace("</body>", SAVE_PATCH + "</body>", 1)
660
+ else:
661
+ body += SAVE_PATCH
662
+ self._send(200, body.encode("utf-8"), "text/html; charset=utf-8")
663
+ return
664
+
665
+ self._send(404, b"not found", "text/plain; charset=utf-8")
666
+
667
+ def do_POST(self):
668
+ parsed = urlparse(self.path)
669
+ if parsed.path != "/__save":
670
+ self._send(404, b"not found", "text/plain; charset=utf-8")
671
+ return
672
+ name = (parse_qs(parsed.query).get("file") or [""])[0]
673
+ target = doc_path(name)
674
+ if target is None:
675
+ self._send_json(400, {"error": "bad file"})
676
+ return
677
+ if not os.path.isfile(target):
678
+ self._send_json(404, {"error": "no such doc"})
679
+ return
680
+ try:
681
+ n = int(self.headers.get("Content-Length") or 0)
682
+ answers = json.loads(self.rfile.read(n) or b"{}")
683
+ if not isinstance(answers, dict):
684
+ raise ValueError("expected an object")
685
+ count = merge(target, answers)
686
+ except Exception as exc: # noqa: BLE001 - report, don't crash the server
687
+ self._send_json(500, {"error": str(exc)})
688
+ return
689
+ self._send_json(200, {"ok": True, "count": count})
690
+
691
+
692
+ def main():
693
+ try:
694
+ server = ThreadingHTTPServer(("127.0.0.1", PORT), Handler)
695
+ except OSError as exc:
696
+ if exc.errno == 48 or "Address already in use" in str(exc): # EADDRINUSE
697
+ print(
698
+ "serve: REFUSED — port %d is already in use.\n"
699
+ " Another server (maybe a previous `bin/serve`) already holds it.\n"
700
+ " Try a different port: bin/serve %d" % (PORT, PORT + 1),
701
+ file=sys.stderr,
702
+ )
703
+ sys.exit(1)
704
+ print("serve: REFUSED — could not bind 127.0.0.1:%d (%s)." % (PORT, exc), file=sys.stderr)
705
+ sys.exit(1)
706
+ # flush=True IS THE POINT OF THESE TWO LINES. Python block-buffers stdout
707
+ # when it is not a tty — which is exactly the case a bootstrapping agent
708
+ # hits: `bin/serve &> serve.log &`, then read the URL out of the file and
709
+ # hand it to the human. Without the flush the buffer is never filled and
710
+ # never drained, because the very next statement blocks in serve_forever()
711
+ # forever; the log stays 0 bytes while the server runs and 0 bytes after
712
+ # it is killed. The URL is the one thing this command exists to produce.
713
+ print("serving %s on http://localhost:%d" % (ENTROPY_MACHINES_ROOT, PORT), flush=True)
714
+ print(" docs: %s (%s)" % (DOCS_PATH, DOCS_DIR), flush=True)
715
+ print(" theme: %s (%s)" % (THEME_NAME, THEME_FILE), flush=True)
716
+ try:
717
+ server.serve_forever()
718
+ except KeyboardInterrupt:
719
+ pass
720
+
721
+
722
+ if __name__ == "__main__":
723
+ main()
724
+ PY