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/doclint ADDED
@@ -0,0 +1,460 @@
1
+ #!/usr/bin/env python3
2
+ """doclint — refuse a doc that phones out, or that nobody can answer.
3
+
4
+ bin/doclint every *.html in the project's docs directory
5
+ bin/doclint <path> [<path>…] those files (a directory expands to its *.html)
6
+
7
+ Exit 0 clean, 1 with violations named, 2 refused (bad usage, unreadable input,
8
+ no docs directory). Every problem found is reported, not just the first.
9
+
10
+ WHAT IT ENFORCES, AND WHY THOSE TWO THINGS
11
+
12
+ 1. LOCAL ONLY. A report, plan or PRD in this factory is a file on this
13
+ machine, served by bin/serve, answered in a browser and saved back to disk.
14
+ It is never published to an external site and it never depends on one. So
15
+ any reference — an href, a src, a CSS url(), a string literal inside a
16
+ script — pointing at http://, https:// or a protocol-relative //host is
17
+ refused, naming the file, the line and the URL. A doc that loads something
18
+ from someone else's server stops working the moment it leaves this machine
19
+ or the machine goes offline, and it leaks the fact that it was opened.
20
+
21
+ This subsumes the remote-webfont case for the right reason: a Google Fonts
22
+ stylesheet is refused because it is remote, not because it is a font.
23
+
24
+ PROSE IS NOT A REFERENCE. Text that merely mentions a URL fetches nothing
25
+ and passes. So do data: URIs, blob:, mailto:, same-document #anchors, and
26
+ every relative or root-relative path.
27
+
28
+ 2. ANSWERABLE. A doc with nowhere to answer is a broadcast. Every <h2> gets an
29
+ answer box, the boxes have unique keys and a textarea each, and the page
30
+ carries the save button and the saved-answers JSON block that get an answer
31
+ from the browser onto disk. Miss any of those and the owner's typing dies
32
+ in the tab.
33
+
34
+ A section that is genuinely NOT for answering — orientation prose, a
35
+ findings page the agent fills in — says so in the markup: put
36
+ data-informational on its <h2> and this check skips it. That is a
37
+ declaration, not a switch: it is visible in the file, it is per-section,
38
+ and writing it is a claim that nobody is expected to respond there. There
39
+ is no flag, no env var and no whole-file exemption, because the failure
40
+ this rule exists to catch is a doc that LOOKS answerable and is not.
41
+
42
+ WHAT IT DELIBERATELY DOES NOT ENFORCE: the look. Colours, fonts, type scale,
43
+ layout, whether there is a theme toggle — all of it is the author's. The house
44
+ style in lib/REPORT-TEMPLATE.html is a good default and a starting point, not
45
+ a conformance target. Somebody using this harness may want a report that looks
46
+ nothing like ours, and that is fine; a report that fetches from the internet is
47
+ not.
48
+
49
+ TARGETED REGEX, NOT AN HTML PARSER. Deliberate, and the same thing the rest of
50
+ this harness does (bin/serve's save merge and answer counts, bin/status's
51
+ unanswered scan): the questions asked here are lexical — does this attribute
52
+ value name a remote origin, is there an answer box between these two headings
53
+ — and vendoring a parser to answer them would add a dependency to a harness
54
+ that has none. The cost is real and bounded: exotic markup (an attribute value
55
+ split across a CDATA section, a URL assembled at runtime from fragments) can
56
+ slip past. A doc built by hand or from the template does not do those things.
57
+
58
+ HTML COMMENTS ARE MASKED BEFORE ANYTHING IS COUNTED. A commented-out remote
59
+ link loads nothing, and — the expensive half — a comment that DOCUMENTS the
60
+ answer-box markup is not a question. lib/doc-template.html once carried a
61
+ literal example in its header comment; every regex reader of these docs counted
62
+ it, so each doc reported one phantom unanswered question nobody could answer
63
+ and "fully answered" was unreachable. Masking keeps line numbers intact.
64
+ """
65
+ import os
66
+ import re
67
+ import sys
68
+
69
+ # ENTROPY_MACHINES_HOME is the harness DIRECTORY — where lib/config.py sits — which may
70
+ # be the repo root or a subdirectory of it. Not a root and not a separate repo;
71
+ # see lib/roots.sh.
72
+ ENTROPY_MACHINES_HOME = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
73
+ sys.path.insert(0, os.path.join(ENTROPY_MACHINES_HOME, "lib"))
74
+ import config # noqa: E402 -- needs ENTROPY_MACHINES_HOME on the path first
75
+
76
+
77
+ # ---------------------------------------------------------------------------
78
+ # lexing helpers
79
+ # ---------------------------------------------------------------------------
80
+ _COMMENT_RE = re.compile(r"<!--[\s\S]*?-->")
81
+
82
+
83
+ def mask_comments(text):
84
+ """Blank every HTML comment, preserving length and newlines so offsets and
85
+ line numbers still refer to the real file."""
86
+ def blank(m):
87
+ return "".join("\n" if c == "\n" else " " for c in m.group(0))
88
+ return _COMMENT_RE.sub(blank, text)
89
+
90
+
91
+ def line_of(text, offset):
92
+ return text.count("\n", 0, offset) + 1
93
+
94
+
95
+ # ---------------------------------------------------------------------------
96
+ # rule 1 — no external references
97
+ # ---------------------------------------------------------------------------
98
+ # Attributes whose value the browser FETCHES. `data` is <object data=…>.
99
+ _URL_ATTRS = (
100
+ "href|src|srcset|action|formaction|poster|cite|ping|manifest|data|background|longdesc"
101
+ )
102
+ _ATTR_RE = re.compile(
103
+ r"\b(" + _URL_ATTRS + r")\s*=\s*(?:\"([^\"]*)\"|'([^']*)'|([^\s>\"'`]+))",
104
+ re.I,
105
+ )
106
+ _URL_FN_RE = re.compile(r"url\(\s*(?:\"([^\"]*)\"|'([^']*)'|([^)\s]*))\s*\)", re.I)
107
+ _IMPORT_RE = re.compile(r"@import\s+(?:\"([^\"]*)\"|'([^']*)')", re.I)
108
+ _SCRIPT_RE = re.compile(r"<script\b[^>]*>([\s\S]*?)</script>", re.I)
109
+ _STRING_RE = re.compile(r"\"([^\"\n]*)\"|'([^'\n]*)'|`([^`]*)`")
110
+
111
+ # http/https/ftp/ws and friends are off-machine by definition. file: is not
112
+ # checked here: it is a local path, brittle but not a request to somebody
113
+ # else's server, and this rule is about the latter.
114
+ _REMOTE_SCHEME_RE = re.compile(r"^(?:https?|ftps?|wss?)://", re.I)
115
+ # Protocol-relative //host/path inherits the page's scheme and is a remote
116
+ # fetch when the page is served over http, which is exactly how bin/serve
117
+ # serves it. Requires a host character so a bare "//" string in script code is
118
+ # not mistaken for one.
119
+ _PROTOCOL_RELATIVE_RE = re.compile(r"^//[A-Za-z0-9]")
120
+
121
+
122
+ def remote_target(value):
123
+ """The URL if this reference leaves the machine, else None."""
124
+ if value is None:
125
+ return None
126
+ v = value.strip()
127
+ if not v:
128
+ return None
129
+ if _REMOTE_SCHEME_RE.match(v) or _PROTOCOL_RELATIVE_RE.match(v):
130
+ return v
131
+ return None
132
+
133
+
134
+ def _group(m):
135
+ """The first non-None capture — the quoted or unquoted value."""
136
+ for g in m.groups()[1:] if m.re is _ATTR_RE else m.groups():
137
+ if g is not None:
138
+ return g
139
+ return None
140
+
141
+
142
+ def check_external(masked, findings):
143
+ seen = set()
144
+
145
+ def flag(offset, url, where):
146
+ key = (offset, url)
147
+ if key in seen:
148
+ return
149
+ seen.add(key)
150
+ findings.append((
151
+ line_of(masked, offset),
152
+ "external-reference",
153
+ "%s points off this machine: %s" % (where, url),
154
+ ))
155
+
156
+ for m in _ATTR_RE.finditer(masked):
157
+ attr = m.group(1).lower()
158
+ value = _group(m)
159
+ if value is None:
160
+ continue
161
+ # srcset holds a comma-separated candidate list, each "<url> <descriptor>".
162
+ candidates = [c.strip().split()[0] for c in value.split(",") if c.strip()] \
163
+ if attr == "srcset" else [value]
164
+ for c in candidates:
165
+ url = remote_target(c)
166
+ if url:
167
+ flag(m.start(), url, "%s=" % attr)
168
+
169
+ for m in _URL_FN_RE.finditer(masked):
170
+ url = remote_target(_group(m))
171
+ if url:
172
+ flag(m.start(), url, "css url()")
173
+
174
+ for m in _IMPORT_RE.finditer(masked):
175
+ url = remote_target(_group(m))
176
+ if url:
177
+ flag(m.start(), url, "@import")
178
+
179
+ # Inside a script, any string literal naming a remote origin is a fetch
180
+ # waiting to happen — fetch(), XHR, import(), new Worker(), an <img>.src
181
+ # assignment. Matching string LITERALS and not bare text is what keeps a
182
+ # `// comment` from reading as a protocol-relative URL.
183
+ for block in _SCRIPT_RE.finditer(masked):
184
+ body = block.group(1)
185
+ base = block.start(1)
186
+ for m in _STRING_RE.finditer(body):
187
+ url = remote_target(_group(m))
188
+ if url:
189
+ flag(base + m.start(), url, "script string")
190
+
191
+
192
+ # ---------------------------------------------------------------------------
193
+ # rule 2 — the doc is answerable
194
+ # ---------------------------------------------------------------------------
195
+ _H2_RE = re.compile(r"<h2\b([^>]*)>([\s\S]*?)</h2>", re.I)
196
+ # An <h2 data-informational> declares "nobody is expected to answer here".
197
+ _INFORMATIONAL_RE = re.compile(r"\bdata-informational\b", re.I)
198
+ _RESP_TAG_RE = re.compile(r"<[a-zA-Z][a-zA-Z0-9]*\b[^>]*\bdata-resp\s*=\s*\"([^\"]+)\"[^>]*>")
199
+ _CLASS_RE = re.compile(r"\bclass\s*=\s*\"([^\"]*)\"", re.I)
200
+ _SAVE_BTN_RE = re.compile(r"\bid\s*=\s*[\"']saveBtn[\"']")
201
+ _RESP_DATA_RE = re.compile(r"\bid\s*=\s*[\"']responses-data[\"']")
202
+ # The exact shape bin/serve's merge() rewrites. A different attribute order or
203
+ # a missing type= means a saved answer never reaches the mirror block, so the
204
+ # answer counters keep reporting the question as open after it was answered.
205
+ _RESP_DATA_EXACT_RE = re.compile(
206
+ r"<script type=\"application/json\" id=\"responses-data\">", re.I
207
+ )
208
+ _TEXTAREA_RE = re.compile(r"<textarea\b", re.I)
209
+ _TAGS_RE = re.compile(r"<[^>]+>")
210
+
211
+
212
+ def check_answerable(masked, findings):
213
+ boxes = list(_RESP_TAG_RE.finditer(masked))
214
+
215
+ if not boxes:
216
+ findings.append((
217
+ 1, "no-answer-boxes",
218
+ "no answer box anywhere — this is a broadcast, not a doc anyone "
219
+ "can respond to",
220
+ ))
221
+ else:
222
+ seen_keys = {}
223
+ for i, m in enumerate(boxes):
224
+ key = m.group(1)
225
+ line = line_of(masked, m.start())
226
+ if key in seen_keys:
227
+ findings.append((
228
+ line, "duplicate-key",
229
+ "answer key %r is already used on line %d — the second "
230
+ "answer overwrites the first on save" % (key, seen_keys[key]),
231
+ ))
232
+ else:
233
+ seen_keys[key] = line
234
+
235
+ cls = _CLASS_RE.search(m.group(0))
236
+ if not cls or "response" not in cls.group(1).split():
237
+ findings.append((
238
+ line, "answer-box-missing-class",
239
+ "the box for %r has no class=\"response\" — bin/status "
240
+ "will not count it" % key,
241
+ ))
242
+
243
+ end = boxes[i + 1].start() if i + 1 < len(boxes) else len(masked)
244
+ if not _TEXTAREA_RE.search(masked, m.end(), end):
245
+ findings.append((
246
+ line, "empty-answer-box",
247
+ "the box for %r has no <textarea> — there is nothing to "
248
+ "type into" % key,
249
+ ))
250
+
251
+ box_starts = [m.start() for m in boxes]
252
+ heads = list(_H2_RE.finditer(masked))
253
+ for i, h in enumerate(heads):
254
+ end = heads[i + 1].start() if i + 1 < len(heads) else len(masked)
255
+ if any(h.end() <= s < end for s in box_starts):
256
+ continue
257
+ if _INFORMATIONAL_RE.search(h.group(1)):
258
+ continue # declared not-for-answering, in the markup itself
259
+ title = " ".join(_TAGS_RE.sub("", h.group(2)).split()) or "(untitled)"
260
+ findings.append((
261
+ line_of(masked, h.start()), "no-answer-box",
262
+ "the section %r has no answer box under it — add one, or mark the "
263
+ "heading data-informational if nobody is meant to answer there"
264
+ % title[:60],
265
+ ))
266
+
267
+ if not _SAVE_BTN_RE.search(masked):
268
+ findings.append((
269
+ 1, "no-save-button",
270
+ "no element with id=\"saveBtn\" — nothing can write the answers to "
271
+ "disk, so they die in the browser",
272
+ ))
273
+ if not _RESP_DATA_RE.search(masked):
274
+ findings.append((
275
+ 1, "no-responses-data",
276
+ "no <script type=\"application/json\" id=\"responses-data\"> block "
277
+ "— bin/serve has nowhere to mirror the saved answers",
278
+ ))
279
+ elif not _RESP_DATA_EXACT_RE.search(masked):
280
+ findings.append((
281
+ 1, "responses-data-shape",
282
+ "the responses-data block is not the exact shape bin/serve "
283
+ "rewrites — it must read: "
284
+ "<script type=\"application/json\" id=\"responses-data\">",
285
+ ))
286
+
287
+
288
+ # ---------------------------------------------------------------------------
289
+ # driving it
290
+ # ---------------------------------------------------------------------------
291
+ _SCRIPT_BODY_RE = re.compile(r"(<script\b[^>]*>)([\s\S]*?)(</script\s*>)", re.I)
292
+ _GENERATED_RE = re.compile(r"<html\b[^>]*\bdata-generated\b", re.I)
293
+
294
+
295
+ def mask_script_bodies(text):
296
+ """Blank the INSIDE of every <script>, preserving offsets.
297
+
298
+ check_answerable counts <h2> tags, and a generator that builds markup in JS
299
+ has `'<h2>' + title` in a string literal. Those were reported as sections
300
+ with no answer box: a rule firing on code that is not markup. The script
301
+ TAGS stay visible because check_external still has to see src= and any
302
+ remote URL inside the body.
303
+ """
304
+ def blank(m):
305
+ body = "".join("\n" if c == "\n" else " " for c in m.group(2))
306
+ return m.group(1) + body + m.group(3)
307
+ return _SCRIPT_BODY_RE.sub(blank, text)
308
+
309
+
310
+ def is_generated(text):
311
+ """True when the root element declares data-generated.
312
+
313
+ A generated read-only view — bin/tracker render's TRACKER.html — legitimately
314
+ has no answer boxes: it is a report of state, regenerated on demand, and hand
315
+ edits to it are discarded. The ANSWERABILITY rule does not apply to it. The
316
+ external-reference rule still does, and is the one that actually matters:
317
+ generated or not, a doc that phones out is not a local doc.
318
+
319
+ Declared in the markup on the root element, not a flag and not a filename
320
+ allow-list, so the exemption travels with the document and is visible to
321
+ anyone reading it.
322
+ """
323
+ return bool(_GENERATED_RE.search(text))
324
+
325
+
326
+ def lint_file(path):
327
+ """[(line, code, message), …], or None if the file could not be read.
328
+
329
+ None is not "clean" — a gate that cannot read its input must say so. The
330
+ caller turns it into a refusal.
331
+ """
332
+ try:
333
+ with open(path, encoding="utf-8") as f:
334
+ text = f.read()
335
+ except (OSError, UnicodeDecodeError):
336
+ return None
337
+ masked = mask_comments(text)
338
+ findings = []
339
+ check_external(masked, findings)
340
+ if not is_generated(masked):
341
+ check_answerable(mask_script_bodies(masked), findings)
342
+ findings.sort(key=lambda f: (f[0], f[1]))
343
+ return findings
344
+
345
+
346
+ def html_files_in(directory):
347
+ try:
348
+ return [os.path.join(directory, n)
349
+ for n in sorted(os.listdir(directory)) if n.endswith(".html")]
350
+ except OSError:
351
+ return None
352
+
353
+
354
+ def docs_dir(cfg):
355
+ d = (cfg.get("docs") or {}).get("dir")
356
+ if isinstance(d, str) and d.strip():
357
+ d = d.strip()
358
+ else:
359
+ # Same default bin/init writes and bin/serve serves from. docs.dir has
360
+ # no entry in lib/config.py's DEFAULTS; that shared file is not this
361
+ # tool's to extend, so the fallback lives here exactly as it does in
362
+ # bin/serve and bin/status.
363
+ d = "entropy-machines-docs"
364
+ return d if os.path.isabs(d) else os.path.join(cfg["_root"], d.strip("/"))
365
+
366
+
367
+ def refuse(msg, *more):
368
+ sys.stderr.write("doclint: REFUSED — %s\n" % msg)
369
+ for line in more:
370
+ sys.stderr.write(" %s\n" % line)
371
+ return 2
372
+
373
+
374
+ def main(argv):
375
+ if argv and argv[0] in ("-h", "--help"):
376
+ print(__doc__.strip("\n"))
377
+ return 0
378
+ for a in argv:
379
+ if a.startswith("-"):
380
+ sys.stderr.write("usage: bin/doclint [path…]\n")
381
+ return 2
382
+
383
+ config.refuse_nested_clone("doclint")
384
+
385
+ targets = []
386
+ if argv:
387
+ for a in argv:
388
+ if os.path.isdir(a):
389
+ found = html_files_in(a)
390
+ if found is None:
391
+ return refuse("cannot read the directory %s." % a)
392
+ if not found:
393
+ return refuse("no .html files in %s; nothing was checked." % a)
394
+ targets.extend(found)
395
+ elif os.path.isfile(a):
396
+ targets.append(a)
397
+ else:
398
+ return refuse("no such file: %s." % a,
399
+ "Nothing was checked — an unreadable input is not a pass.")
400
+ else:
401
+ cfg = config.load_config()
402
+ if not cfg["_path"]:
403
+ return refuse("no config.json at %s." % cfg["_root"],
404
+ "Run bin/init, or name the docs to check explicitly:",
405
+ " bin/doclint path/to/doc.html")
406
+ d = docs_dir(cfg)
407
+ if not os.path.isdir(d):
408
+ return refuse("the docs directory %s does not exist." % d,
409
+ "That is docs.dir in config.json. Nothing was checked.")
410
+ found = html_files_in(d)
411
+ if found is None:
412
+ return refuse("cannot read the docs directory %s." % d)
413
+ if not found:
414
+ return refuse("no .html docs in %s; nothing was checked." % d,
415
+ "This is a DECLINE, not a pass — a gate with no input",
416
+ "must not report success.")
417
+ targets = found
418
+
419
+ total = 0
420
+ bad_files = 0
421
+ for path in targets:
422
+ findings = lint_file(path)
423
+ if findings is None:
424
+ return refuse("cannot read %s." % path,
425
+ "Nothing was checked — an unreadable input is not a pass.")
426
+ if not findings:
427
+ continue
428
+ bad_files += 1
429
+ total += len(findings)
430
+ for line, code, msg in findings:
431
+ print("%s:%d: %s: %s" % (path, line, code, msg))
432
+
433
+ if total:
434
+ print("")
435
+ print("doclint: REFUSED — %d problem(s) in %d of %d doc(s)."
436
+ % (total, bad_files, len(targets)))
437
+ print(" A doc in this factory is a LOCAL file that the owner can "
438
+ "answer in place.")
439
+ print(" It never fetches from an external site, and every section "
440
+ "has a box to")
441
+ print(" answer in. Start from lib/REPORT-TEMPLATE.html. The look is "
442
+ "yours; these")
443
+ print(" two rules are not.")
444
+ return 1
445
+
446
+ print("doclint: %d doc(s) checked, all local and answerable." % len(targets))
447
+ return 0
448
+
449
+
450
+ if __name__ == "__main__":
451
+ try:
452
+ sys.exit(main(sys.argv[1:]))
453
+ except KeyboardInterrupt:
454
+ sys.exit(130)
455
+ except Exception as exc: # noqa: BLE001
456
+ # A gate that dies must REFUSE, never look like a pass and never exit
457
+ # via a traceback. lib/handoff-guard.sh was found doing exactly that.
458
+ sys.stderr.write("doclint: REFUSED — could not complete the check (%s: %s).\n"
459
+ % (type(exc).__name__, exc))
460
+ sys.exit(2)