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,784 @@
1
+ #!/usr/bin/env python3
2
+ """tracker-view — render TRACKER.html, a browsable READ-ONLY view of the issue
3
+ store, into the project's docs directory.
4
+
5
+ Never invoked directly: `bin/tracker render` resolves the config once and runs
6
+ this with what it needs in the environment (docs/CONFIG.md rule 3 — one reader
7
+ of config.json per command).
8
+
9
+ ENTROPY_MACHINES_ROOT repo root; relative paths below resolve against it
10
+ ENTROPY_MACHINES_TRACKER_PATH the store, relative to ENTROPY_MACHINES_ROOT
11
+ ENTROPY_MACHINES_TRACKER_BACKEND "file" or "command"
12
+ ENTROPY_MACHINES_DOCS_DIR docs.dir — where TRACKER.html lands
13
+ ENTROPY_MACHINES_PROJECT_NAME project.name, for the page title (optional)
14
+
15
+ WHY THIS IS NOT A SEVENTH ADAPTER OPERATION. docs/TRACKER-ADAPTER.md asks a
16
+ backend for six things, and rendering is not one of them: it is a VIEW over
17
+ whatever a backend already stores, so making it a backend operation would ask
18
+ every future `command` backend to ship an HTML generator. It is also not
19
+ `bin/tracker`'s own job — that file is a dispatcher whose entire discipline is
20
+ to resolve config and hand off inside a subshell — so the view lives here, in
21
+ one place, and `bin/tracker render` intercepts the word before the backend
22
+ dispatch ever sees it. A backend can never be asked to implement `render`.
23
+
24
+ WHY IT READS THE STORE DIRECTLY, AND WHY IT REFUSES A `command` BACKEND. The
25
+ six operations cannot enumerate issues: `show` takes an id you already have
26
+ and `ready` deliberately returns only what is claimable — which is the exact
27
+ half of the picture this page exists to show the other side of. So this reads
28
+ the file backend's JSON, once, and refuses when tracker.backend is "command"
29
+ rather than printing a page that silently omits every issue that is held,
30
+ gated, blocked or done. A partial tracker is worse than no tracker: it reads
31
+ as complete. (If you run an external tracker, that tracker has its own UI —
32
+ this one is for the built-in store.)
33
+
34
+ ONE READ, NOT THREE SUBPROCESSES. lib/tracker-file replaces the store
35
+ atomically, so a single read is a consistent snapshot; three shell-outs to
36
+ `show`/`ready`/`notes` could interleave with a concurrent writer and produce a
37
+ page that never existed. The cost is that the readiness rule — notstarted, not
38
+ held, not gated, no open blocker (an unknown blocker counts as open) — is
39
+ stated here as well as in lib/tracker-file's cmd_ready. That duplication is
40
+ guarded by a test: tests/cases/tracker-render-writes-a-browsable-view.sh
41
+ compares the ids this page marks ready against `bin/tracker ready` itself.
42
+
43
+ HELD, GATED AND BLOCKED ARE NOT STATUSES — they are field presence, and an
44
+ issue can carry several at once (a half-implemented issue can be held). The
45
+ page never flattens them into one column: every issue lands in one bucket by
46
+ precedence for grouping, and carries a badge plus a reason line for EVERY
47
+ condition that applies. The reason is the only thing a reader actually needs:
48
+ "not ready" without "why" is what this view exists to stop.
49
+
50
+ READ-ONLY BY CONSTRUCTION. No answer boxes, no save button, no write path, no
51
+ fetch of any kind, and nothing loaded from off this machine. bin/serve serves
52
+ it because it is an .html file in the docs directory, and that is the whole
53
+ integration.
54
+ """
55
+ from __future__ import annotations
56
+
57
+ import datetime
58
+ import html
59
+ import json
60
+ import os
61
+ import sys
62
+
63
+ BUCKET_ORDER = ["ready", "inflight", "held", "gated", "blocked", "done"]
64
+
65
+
66
+ def refuse(msg_lines, code=2):
67
+ for line in msg_lines:
68
+ print(line, file=sys.stderr)
69
+ sys.exit(code)
70
+
71
+
72
+ def open_blockers_of(issue, issues):
73
+ """The blockers that still block. An id with no issue behind it counts as
74
+ open — the same call lib/tracker-file's cmd_ready makes, and the safe one:
75
+ a typo in blockedBy must not read as satisfied."""
76
+ out = []
77
+ for dep in issue.get("blockedBy") or []:
78
+ dep_issue = issues.get(dep)
79
+ if dep_issue is None or dep_issue.get("status") != "done":
80
+ out.append(dep)
81
+ return out
82
+
83
+
84
+ def derive(iid, issue, issues):
85
+ """The flags and the grouping bucket for one issue."""
86
+ status = issue.get("status") or "notstarted"
87
+ held = bool((issue.get("heldWhy") or "").strip())
88
+ gated = bool((issue.get("gate") or "").strip())
89
+ openb = open_blockers_of(issue, issues)
90
+ done = status == "done"
91
+ inflight = status == "progress"
92
+ ready = status == "notstarted" and not held and not gated and not openb
93
+
94
+ flags = []
95
+ if ready:
96
+ flags.append("ready")
97
+ if inflight:
98
+ flags.append("inflight")
99
+ if held:
100
+ flags.append("held")
101
+ if gated:
102
+ flags.append("gated")
103
+ if openb:
104
+ flags.append("blocked")
105
+ if done:
106
+ flags.append("done")
107
+
108
+ # Precedence for the single bucket a row is grouped under. Done first
109
+ # (closed work is not "held" in any useful sense); then the reasons it is
110
+ # not claimable, most decision-like first; then in flight; then ready.
111
+ if done:
112
+ bucket = "done"
113
+ elif held:
114
+ bucket = "held"
115
+ elif gated:
116
+ bucket = "gated"
117
+ elif openb:
118
+ bucket = "blocked"
119
+ elif inflight:
120
+ bucket = "inflight"
121
+ elif ready:
122
+ bucket = "ready"
123
+ else:
124
+ # notstarted with no reason recorded and no blocker is ready by
125
+ # definition, so this is unreachable today. It exists so a future
126
+ # field that suppresses readiness cannot make an issue vanish from
127
+ # every group: it lands in "blocked" with no reason and is visible.
128
+ bucket = "blocked"
129
+
130
+ return flags, bucket, openb
131
+
132
+
133
+ def build_payload(store, project, store_rel):
134
+ issues = store.get("issues") or {}
135
+ notes = store.get("notes") or []
136
+
137
+ blocks = {}
138
+ for iid, issue in issues.items():
139
+ for dep in issue.get("blockedBy") or []:
140
+ blocks.setdefault(dep, [])
141
+ if iid not in blocks[dep]:
142
+ blocks[dep].append(iid)
143
+
144
+ by_issue = {}
145
+ orphan_notes = 0
146
+ for rec in notes:
147
+ if not isinstance(rec, dict):
148
+ continue
149
+ iid = rec.get("issue")
150
+ if not isinstance(iid, str) or iid not in issues:
151
+ orphan_notes += 1
152
+ continue
153
+ fields = rec.get("fields")
154
+ by_issue.setdefault(iid, []).append({
155
+ "ts": rec.get("ts") or "",
156
+ "verb": rec.get("verb") or "NOTE",
157
+ "actor": rec.get("actor") or "unknown",
158
+ "fields": fields if isinstance(fields, dict) else {},
159
+ })
160
+
161
+ out_issues = {}
162
+ for iid in sorted(issues):
163
+ issue = issues[iid]
164
+ flags, bucket, openb = derive(iid, issue, issues)
165
+ done_blockers = [b for b in (issue.get("blockedBy") or []) if b not in openb]
166
+ out_issues[iid] = {
167
+ "id": iid,
168
+ "title": issue.get("title") or "",
169
+ "status": issue.get("status") or "notstarted",
170
+ "effort": issue.get("effort") or "",
171
+ "heldWhy": issue.get("heldWhy") or "",
172
+ "heldAt": issue.get("heldAt") or "",
173
+ "gate": issue.get("gate") or "",
174
+ "gatedAt": issue.get("gatedAt") or "",
175
+ "claimedBy": issue.get("claimedBy") or "",
176
+ "claimedAt": issue.get("claimedAt") or "",
177
+ "blockedBy": list(issue.get("blockedBy") or []),
178
+ "openBlockers": openb,
179
+ "doneBlockers": done_blockers,
180
+ "blocks": sorted(blocks.get(iid, [])),
181
+ "flags": flags,
182
+ "bucket": bucket,
183
+ "notes": by_issue.get(iid, []),
184
+ }
185
+
186
+ efforts = sorted({i["effort"] for i in out_issues.values() if i["effort"]},
187
+ key=lambda e: ({"S": 0, "M": 1, "L": 2}.get(e, 3), e))
188
+
189
+ return {
190
+ "project": project,
191
+ "store": store_rel,
192
+ "generated": datetime.datetime.now(datetime.timezone.utc)
193
+ .strftime("%Y-%m-%dT%H:%M:%SZ"),
194
+ "issues": out_issues,
195
+ "efforts": efforts,
196
+ "orphanNotes": orphan_notes,
197
+ }
198
+
199
+
200
+ def render(payload, out_path):
201
+ blob = json.dumps(payload, ensure_ascii=False, sort_keys=True).replace("</", "<\\/")
202
+ doc = (TEMPLATE
203
+ .replace("__PROJECT__", html.escape(payload["project"] or "this repo"))
204
+ .replace("__GENERATED__", html.escape(payload["generated"]))
205
+ .replace("__STORE__", html.escape(payload["store"]))
206
+ .replace("__DATA__", blob))
207
+ tmp = out_path + ".tmp"
208
+ with open(tmp, "w", encoding="utf-8") as f:
209
+ f.write(doc)
210
+ os.replace(tmp, out_path)
211
+ return out_path
212
+
213
+
214
+ def finish(payload, docs_path, root, store_rel):
215
+ """Write the page and report what went into it. Exits 0."""
216
+ out = render(payload, os.path.join(docs_path, "TRACKER.html"))
217
+ n = len(payload["issues"])
218
+ buckets = {b: 0 for b in BUCKET_ORDER}
219
+ for issue in payload["issues"].values():
220
+ buckets[issue["bucket"]] += 1
221
+ breakdown = ", ".join("%d %s" % (buckets[b], b) for b in BUCKET_ORDER if buckets[b])
222
+ print("tracker render: wrote %s" % os.path.relpath(out, root))
223
+ if n:
224
+ print(" %d issue(s): %s" % (n, breakdown))
225
+ else:
226
+ print(" no issues filed yet — `bin/tracker set <id> title=\"…\"` files "
227
+ "the first one.")
228
+ print(" read-only view, generated from %s — re-run after any change; "
229
+ "hand edits are lost." % store_rel)
230
+ print(" `bin/serve` already serves it: open /TRACKER.html there.")
231
+ return 0
232
+
233
+
234
+ def main(argv):
235
+ if argv and argv[0] in ("-h", "--help"):
236
+ print(__doc__.strip("\n"))
237
+ return 0
238
+ if argv:
239
+ print("usage: bin/tracker render", file=sys.stderr)
240
+ return 2
241
+
242
+ root = os.environ.get("ENTROPY_MACHINES_ROOT") or os.getcwd()
243
+ backend = os.environ.get("ENTROPY_MACHINES_TRACKER_BACKEND") or "file"
244
+ store_rel = os.environ.get("ENTROPY_MACHINES_TRACKER_PATH") or ".entropy-machines/issues.json"
245
+ docs_dir = os.environ.get("ENTROPY_MACHINES_DOCS_DIR") or "entropy-machines-docs"
246
+ project = os.environ.get("ENTROPY_MACHINES_PROJECT_NAME") or os.path.basename(root)
247
+
248
+ if backend != "file":
249
+ refuse([
250
+ 'tracker render: REFUSED — tracker.backend is "%s", and this view '
251
+ "can only be built" % backend,
252
+ " from the built-in \"file\" store. The adapter contract "
253
+ "(docs/TRACKER-ADAPTER.md)",
254
+ " has no operation that lists issues — `show` takes an id you "
255
+ "already have and",
256
+ " `ready` returns only what is claimable, which is the half this "
257
+ "page exists to",
258
+ " show the other side of. Rendering from those two would drop "
259
+ "every held, gated,",
260
+ " blocked and done issue and still look complete, so it refuses "
261
+ "instead.",
262
+ ])
263
+
264
+ store_path = store_rel if os.path.isabs(store_rel) else os.path.join(root, store_rel)
265
+ # A MISSING STORE IS AN EMPTY STORE, not an error — exactly what
266
+ # lib/tracker-file's _load() decides, and the same reason: the file is
267
+ # created by the first write, so a project that has just run bin/init has
268
+ # no store yet and is not broken. Refusing here would leave the link the
269
+ # shipped PRD carries to this page dead for every new project until
270
+ # somebody happened to file an issue, which is the bug this page was
271
+ # written to fix.
272
+ store = {"issues": {}, "notes": []}
273
+ if os.path.isfile(store_path):
274
+ try:
275
+ with open(store_path, encoding="utf-8") as f:
276
+ store = json.load(f)
277
+ except (OSError, ValueError) as err:
278
+ refuse([
279
+ "tracker render: REFUSED — could not read the issue store at "
280
+ "%s:" % store_path,
281
+ " %s" % err,
282
+ ])
283
+
284
+ docs_path = docs_dir if os.path.isabs(docs_dir) else os.path.join(root, docs_dir.strip("/"))
285
+ if not os.path.isdir(docs_path):
286
+ try:
287
+ os.makedirs(docs_path)
288
+ except OSError as err:
289
+ refuse([
290
+ "tracker render: REFUSED — could not create the docs directory "
291
+ "%s:" % docs_path,
292
+ " %s" % err,
293
+ ])
294
+
295
+ return finish(build_payload(store, project, store_rel), docs_path, root, store_rel)
296
+
297
+
298
+ # ---------------------------------------------------------------------------
299
+ # the page
300
+ # ---------------------------------------------------------------------------
301
+ # Palette and rules follow VS Code's High Contrast themes (hc-black default,
302
+ # hc-light under [data-theme=light]): separation by border, never a background
303
+ # tint; one focus hue that is never used as a fill; no state carried in
304
+ # opacity; four type steps. Nothing is fetched — no CDN, no webfont, no
305
+ # script, no image. The whole page is this file.
306
+ TEMPLATE = r"""<!doctype html>
307
+ <html lang="en" data-generated="bin/tracker render"><head>
308
+ <meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
309
+ <title>__PROJECT__ — issue tracker</title>
310
+ <!-- GENERATED FILE — the data-generated attribute on <html> above says the same
311
+ thing where a tool can see it, because a checker that masks HTML comments
312
+ (bin/doclint does, deliberately) cannot read this one.
313
+ Written by `bin/tracker render` (lib/tracker-view.py) from
314
+ the issue store. Hand edits are lost on the next render; change the issues
315
+ instead. This is a READ-ONLY view: it has no question boxes, no save
316
+ button and no write path of any kind, deliberately, because it is
317
+ regenerated rather than answered. It is also entirely local: every byte it
318
+ needs is in this file. -->
319
+ <style>
320
+ :root{ /* hc-black */
321
+ --bg:#000; --panel:#000;
322
+ --ink:#fff; --dim:rgba(255,255,255,.7);
323
+ --line:#6FC3DF; /* contrastBorder */
324
+ --focus:#F38518; /* focusBorder / activeContrastBorder */
325
+ --accent:#21A6FF; /* textLink.foreground */
326
+ --ready:#23D18B; --prog:#F5F543; --blocked:#F48771; --held:#D670D6;
327
+ --gated:#21A6FF; --done:#fff;
328
+ --fs-sm:12px; --fs-base:14px; --fs-head:16px; --fs-title:20px;
329
+ --mono:ui-monospace,SFMono-Regular,Menlo,monospace;
330
+ }
331
+ :root[data-theme=light]{ /* hc-light */
332
+ --bg:#fff; --panel:#fff;
333
+ --ink:#292929; --dim:rgba(41,41,41,.75);
334
+ --line:#0F4A85;
335
+ --focus:#006BBD;
336
+ --accent:#0F4A85;
337
+ --ready:#0A5C21; --prog:#7A4A00; --blocked:#A81C0B; --held:#6B21A8;
338
+ --gated:#0F4A85; --done:#292929;
339
+ }
340
+ *{box-sizing:border-box}
341
+ body{margin:0;background:var(--bg);color:var(--ink);
342
+ font:var(--fs-base)/1.5 ui-sans-serif,-apple-system,"Segoe UI",Roboto,sans-serif}
343
+ a{color:var(--accent)}
344
+ code,.mono{font-family:var(--mono);font-size:var(--fs-sm)}
345
+ code{border:1px solid var(--line);padding:0 4px}
346
+ :focus-visible{outline:2px solid var(--focus);outline-offset:1px}
347
+
348
+ header{position:sticky;top:0;z-index:20;background:var(--panel);
349
+ border-bottom:1px solid var(--line);padding:10px 16px;
350
+ display:flex;gap:12px;align-items:center;flex-wrap:wrap}
351
+ header h1{font-size:var(--fs-head);margin:0;font-weight:700}
352
+ header h1 span{color:var(--dim);font-weight:400}
353
+ #q{flex:1;min-width:200px;background:var(--panel);border:1px solid var(--line);
354
+ color:var(--ink);padding:7px 10px;font-size:var(--fs-base)}
355
+ #q::placeholder{color:var(--dim)}
356
+ #q:focus{outline:none;border-color:var(--focus);box-shadow:0 0 0 1px var(--focus)}
357
+ .seg{display:flex;border:1px solid var(--line)}
358
+ .seg button{background:var(--panel);border:0;color:var(--ink);padding:7px 11px;
359
+ font-size:var(--fs-sm);font-weight:700;cursor:pointer;border-right:1px solid var(--line)}
360
+ .seg button:last-child{border-right:0}
361
+ .seg button:hover{box-shadow:inset 0 0 0 1px var(--focus)}
362
+ .seg button.on{color:var(--focus);box-shadow:inset 0 0 0 2px var(--focus)}
363
+ #clear{background:var(--panel);border:1px solid var(--focus);color:var(--ink);
364
+ padding:7px 11px;font-size:var(--fs-sm);font-weight:700;cursor:pointer}
365
+ #clear:hover{box-shadow:inset 0 0 0 2px var(--focus)}
366
+ .tot{color:var(--ink);font-size:var(--fs-sm);white-space:nowrap;
367
+ font-variant-numeric:tabular-nums}
368
+ .jump{display:flex;flex-wrap:wrap;gap:6px;width:100%;order:9}
369
+ .jump button{background:var(--panel);border:1px solid var(--line);color:var(--ink);
370
+ font-size:var(--fs-sm);font-weight:700;padding:3px 9px;cursor:pointer;
371
+ display:inline-flex;align-items:center;gap:6px}
372
+ .jump button:hover{box-shadow:inset 0 0 0 2px var(--focus)}
373
+
374
+ main{display:flex;align-items:flex-start}
375
+ aside{width:230px;flex:none;position:sticky;top:52px;max-height:calc(100vh - 52px);
376
+ overflow:auto;padding:14px 10px 40px;border-right:1px solid var(--line)}
377
+ .grp{font-weight:700;font-size:var(--fs-sm);letter-spacing:.09em;
378
+ text-transform:uppercase;margin:14px 6px 6px}
379
+ .grp .hint{display:block;text-transform:none;letter-spacing:0;font-weight:400;
380
+ color:var(--ink);font-size:var(--fs-sm)}
381
+ .f{display:flex;align-items:center;gap:8px;width:100%;background:var(--panel);
382
+ border:1px solid transparent;color:var(--ink);padding:5px 7px;cursor:pointer;
383
+ font-size:var(--fs-base);text-align:left}
384
+ .f:hover{border-color:var(--line)}
385
+ .f.on{border-color:var(--focus);box-shadow:inset 0 0 0 1px var(--focus);font-weight:700}
386
+ .f .n{margin-left:auto;font-size:var(--fs-base);font-variant-numeric:tabular-nums;
387
+ min-width:2.5em;text-align:right}
388
+ .f.zero .n{text-decoration:line-through}
389
+ .dot{width:10px;height:10px;flex:none;border:1px solid var(--bg);
390
+ box-shadow:0 0 0 1px currentColor}
391
+ .dot.ready{background:var(--ready);color:var(--ready)}
392
+ .dot.inflight{background:var(--prog);color:var(--prog)}
393
+ .dot.held{background:var(--held);color:var(--held)}
394
+ .dot.gated{background:var(--gated);color:var(--gated)}
395
+ .dot.blocked{background:var(--blocked);color:var(--blocked)}
396
+ .dot.done{background:var(--done);color:var(--done)}
397
+
398
+ section{flex:1;min-width:0;padding:16px 18px 80px}
399
+ .bh{display:flex;align-items:baseline;gap:10px;margin:22px 0 8px}
400
+ .bh:first-child{margin-top:0}
401
+ .bh h2{font-size:var(--fs-head);margin:0;font-weight:700}
402
+ .bh .meta{color:var(--ink);font-size:var(--fs-sm)}
403
+ .rows{border:1px solid var(--line)}
404
+ .row{display:flex;gap:10px;align-items:flex-start;padding:9px 12px;cursor:pointer;
405
+ border-top:1px solid var(--line);flex-wrap:wrap}
406
+ .row:first-child{border-top:0}
407
+ .row:hover{box-shadow:inset 0 0 0 2px var(--focus)}
408
+ .row .dot{margin-top:5px}
409
+ .row .t{flex:1;min-width:220px;font-weight:700}
410
+ .row.done .t{font-weight:400} /* weight, not opacity, marks closed work */
411
+ .row .id{font-family:var(--mono);font-size:var(--fs-sm);flex:none}
412
+ .row .why{flex-basis:100%;padding-left:20px;font-size:var(--fs-sm)}
413
+ .row .why b{font-weight:700}
414
+ .tag{font-size:var(--fs-sm);font-weight:700;padding:1px 8px;border:1px solid var(--line);
415
+ flex:none;white-space:nowrap;background:transparent}
416
+ .tag.eff{font-variant-numeric:tabular-nums;min-width:24px;text-align:center}
417
+ .tag.ready{color:var(--ready);border-color:var(--ready)}
418
+ .tag.inflight{color:var(--prog);border-color:var(--prog)}
419
+ .tag.held{color:var(--held);border-color:var(--held)}
420
+ .tag.gated{color:var(--gated);border-color:var(--gated)}
421
+ .tag.blocked{color:var(--blocked);border-color:var(--blocked)}
422
+ .why.held{color:var(--held)}
423
+ .why.gated{color:var(--gated)}
424
+ .why.blocked{color:var(--blocked)}
425
+
426
+ .cols{display:grid;grid-template-columns:repeat(auto-fit,minmax(210px,1fr));gap:12px}
427
+ .col{border:1px solid var(--line);padding:8px;min-width:0}
428
+ .col h3{margin:2px 4px 8px;font-size:var(--fs-base);font-weight:700;
429
+ display:flex;gap:7px;align-items:center}
430
+ .card{border:1px solid var(--line);padding:7px 9px;margin-bottom:6px;cursor:pointer}
431
+ .card:hover{box-shadow:inset 0 0 0 2px var(--focus)}
432
+ .card .t{font-size:var(--fs-base);margin-bottom:3px;font-weight:600}
433
+ .card .m{font-family:var(--mono);font-size:var(--fs-sm);display:flex;gap:6px}
434
+
435
+ #empty{padding:40px 4px}
436
+ footer{border-top:1px solid var(--line);margin:0 18px;padding:12px 0 40px;
437
+ font-size:var(--fs-sm)}
438
+
439
+ #scrim{position:fixed;inset:0;background:#000c;z-index:30;display:none}
440
+ #scrim.on{display:block}
441
+ #det{position:fixed;top:0;right:0;bottom:0;width:min(680px,94vw);z-index:31;
442
+ background:var(--panel);border-left:2px solid var(--line);overflow:auto;
443
+ transform:translateX(100%);transition:transform .16s ease;padding:18px 22px 60px}
444
+ #det.on{transform:none}
445
+ #det h2{margin:6px 40px 4px 0;font-size:var(--fs-title);line-height:1.3}
446
+ #det .sub{font-family:var(--mono);font-size:var(--fs-sm);margin-bottom:14px}
447
+ #close{position:absolute;top:12px;right:16px;background:var(--panel);
448
+ border:1px solid var(--line);width:30px;height:30px;color:var(--ink);
449
+ font-size:var(--fs-title);cursor:pointer;line-height:1}
450
+ #close:hover{border-color:var(--focus);box-shadow:inset 0 0 0 1px var(--focus)}
451
+ .blk{margin:16px 0}
452
+ .blk h4{margin:0 0 6px;font-size:var(--fs-sm);letter-spacing:.09em;
453
+ text-transform:uppercase;font-weight:700}
454
+ .blk.reason{border:1px solid var(--line);border-left:3px solid var(--line);padding:9px 12px}
455
+ .blk.reason.held{border-color:var(--held)}
456
+ .blk.reason.held h4{color:var(--held)}
457
+ .blk.reason.gated{border-color:var(--gated)}
458
+ .blk.reason.gated h4{color:var(--gated)}
459
+ .blk.reason.blocked{border-color:var(--blocked)}
460
+ .blk.reason.blocked h4{color:var(--blocked)}
461
+ .blk .when{font-size:var(--fs-sm);font-family:var(--mono)}
462
+ .chips{display:flex;flex-wrap:wrap;gap:6px}
463
+ .chip{display:inline-flex;align-items:center;gap:6px;border:1px solid var(--line);
464
+ padding:3px 8px;font-size:var(--fs-sm);cursor:pointer}
465
+ .chip.dead{cursor:default;border-style:dashed}
466
+ .chip:hover{box-shadow:inset 0 0 0 2px var(--focus)}
467
+ .note{border-top:1px solid var(--line);padding:8px 0}
468
+ .note:first-of-type{border-top:0}
469
+ .note .hd{display:flex;gap:8px;align-items:baseline;flex-wrap:wrap;
470
+ font-family:var(--mono);font-size:var(--fs-sm)}
471
+ .note .verb{font-weight:700;border:1px solid var(--line);padding:0 6px}
472
+ .note .verb.DISPATCH{color:var(--prog);border-color:var(--prog)}
473
+ .note .verb.HANDOFF{color:var(--ready);border-color:var(--ready)}
474
+ .note .when{margin-left:auto}
475
+ .note dl{margin:6px 0 0;display:grid;grid-template-columns:max-content 1fr;
476
+ gap:2px 10px}
477
+ .note dt{font-family:var(--mono);font-size:var(--fs-sm);font-weight:700}
478
+ .note dd{margin:0;white-space:pre-wrap;overflow-wrap:anywhere}
479
+ </style></head>
480
+ <body>
481
+ <header>
482
+ <h1>__PROJECT__ <span>· issue tracker</span></h1>
483
+ <input id="q" placeholder="search id, title, reason, note… (/ to focus)">
484
+ <div class="seg" id="view">
485
+ <button data-v="list" class="on">List</button>
486
+ <button data-v="board">Board</button>
487
+ </div>
488
+ <div class="seg" id="theme"><button title="light / dark">&#9680;</button></div>
489
+ <button id="clear" hidden>clear filters</button>
490
+ <div class="tot" id="tot"></div>
491
+ <div class="jump" id="jump"></div>
492
+ </header>
493
+ <main>
494
+ <aside>
495
+ <div class="grp">State<span class="hint">held, gated and blocked are
496
+ fields, not statuses — an issue can carry more than one.</span></div>
497
+ <div id="fstate"></div>
498
+ <div class="grp">Effort</div><div id="feff"></div>
499
+ </aside>
500
+ <section id="out"></section>
501
+ </main>
502
+ <footer id="foot"></footer>
503
+ <div id="scrim"></div>
504
+ <div id="det"><button id="close" title="close">&#215;</button><div id="detBody"></div></div>
505
+
506
+ <script id="tracker-data" type="application/json">__DATA__</script>
507
+ <script>
508
+ const D = JSON.parse(document.getElementById('tracker-data').textContent);
509
+ const IS = D.issues;
510
+ const ids = Object.keys(IS);
511
+ const BUCKETS = [
512
+ ['ready','Ready','claimable now — nothing is holding it'],
513
+ ['inflight','In flight','claimed, being worked'],
514
+ ['held','Held','a decision not to do it now, with the reason'],
515
+ ['gated','Gated','waiting on a ruling outside the issue graph'],
516
+ ['blocked','Blocked','waiting on another issue'],
517
+ ['done','Done','finished']
518
+ ];
519
+ const BL = Object.fromEntries(BUCKETS.map(b => [b[0], b[1]]));
520
+ const esc = s => String(s == null ? '' : s)
521
+ .replace(/[&<>"]/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]));
522
+ const day = ts => (ts || '').slice(0, 10);
523
+
524
+ const F = {state:new Set(), eff:new Set()};
525
+ let view = 'list', q = '';
526
+
527
+ function haystack(id){
528
+ const it = IS[id];
529
+ const notes = it.notes.map(n => n.verb + ' ' + n.actor + ' ' +
530
+ Object.entries(n.fields).map(([k,v]) => k + ' ' +
531
+ (Array.isArray(v) ? v.join(' ') : String(v == null ? '' : v))).join(' ')).join(' ');
532
+ return (id + ' ' + it.title + ' ' + it.heldWhy + ' ' + it.gate + ' ' +
533
+ it.claimedBy + ' ' + it.blockedBy.join(' ') + ' ' + notes).toLowerCase();
534
+ }
535
+ function hits(id){
536
+ if (!q) return true;
537
+ const hay = haystack(id);
538
+ return q.split(/\s+/).every(w => hay.includes(w));
539
+ }
540
+ // One predicate for the list and for the facet counts. `skip` leaves one facet
541
+ // out, so each sidebar group counts against every OTHER active filter.
542
+ function passes(id, skip){
543
+ const it = IS[id];
544
+ if (!hits(id)) return false;
545
+ if (skip !== 'state' && F.state.size && !it.flags.some(f => F.state.has(f))) return false;
546
+ if (skip !== 'eff' && F.eff.size && !F.eff.has(it.effort)) return false;
547
+ return true;
548
+ }
549
+ const match = id => passes(id, null);
550
+
551
+ function reasonLines(it){
552
+ // WHY IT IS NOT CLAIMABLE — the whole point of the page. Each condition is
553
+ // its own line with its own wording and its own hue, so held, gated and
554
+ // blocked never read as the same thing.
555
+ const out = [];
556
+ if (it.heldWhy) out.push(['held',
557
+ '<b>held</b> — ' + esc(it.heldWhy) + (it.heldAt ? ' <span class="mono">(since ' +
558
+ esc(day(it.heldAt)) + ')</span>' : '')]);
559
+ if (it.gate) out.push(['gated',
560
+ '<b>gated</b> on <code>' + esc(it.gate) + '</code> — an open question outside the ' +
561
+ 'issue graph' + (it.gatedAt ? ' <span class="mono">(since ' + esc(day(it.gatedAt)) +
562
+ ')</span>' : '')]);
563
+ if (it.openBlockers.length) out.push(['blocked',
564
+ '<b>blocked by</b> ' + it.openBlockers.map(b => '<code>' + esc(b) + '</code>' +
565
+ (IS[b] ? '' : ' (no such issue)')).join(', ')]);
566
+ return out;
567
+ }
568
+
569
+ function tagsFor(it){
570
+ let t = '';
571
+ it.flags.forEach(f => {
572
+ if (f === 'done' || f === 'ready') return;
573
+ t += '<span class="tag ' + f + '">' + esc(BL[f].toLowerCase()) + '</span>';
574
+ });
575
+ if (it.flags.includes('ready')) t += '<span class="tag ready">ready</span>';
576
+ if (it.claimedBy) t += '<span class="tag">' + esc(it.claimedBy) + '</span>';
577
+ if (it.blocks.length) t += '<span class="tag">blocks ' + it.blocks.length + '</span>';
578
+ return t;
579
+ }
580
+
581
+ function rowFor(id){
582
+ const it = IS[id];
583
+ const why = reasonLines(it).map(r =>
584
+ '<div class="why ' + r[0] + '">' + r[1] + '</div>').join('');
585
+ return '<div class="row ' + it.bucket + '" data-id="' + esc(id) + '">' +
586
+ '<span class="dot ' + it.bucket + '"></span>' +
587
+ '<span class="t">' + (esc(it.title) || '<em>(no title)</em>') + '</span>' +
588
+ tagsFor(it) +
589
+ '<span class="tag eff">' + (esc(it.effort) || '·') + '</span>' +
590
+ '<span class="id">' + esc(id) + '</span>' + why + '</div>';
591
+ }
592
+
593
+ function render(){
594
+ const sel = ids.filter(match);
595
+ document.getElementById('tot').textContent =
596
+ sel.length + ' of ' + ids.length + ' · ' +
597
+ sel.filter(i => IS[i].status !== 'done').length + ' open';
598
+ const out = document.getElementById('out');
599
+
600
+ if (!sel.length){
601
+ out.innerHTML = '<div id="empty">' + (ids.length ? 'Nothing matches.' :
602
+ 'No issues filed yet. <code>bin/tracker set &lt;id&gt; title=&quot;…&quot;</code> ' +
603
+ 'files the first one, then re-run <code>bin/tracker render</code>.') + '</div>';
604
+ } else if (view === 'board'){
605
+ out.innerHTML = '<div class="cols">' + BUCKETS.map(([b, label]) => {
606
+ const items = sel.filter(i => IS[i].bucket === b);
607
+ return '<div class="col" id="b-' + b + '"><h3><span class="dot ' + b + '"></span>' +
608
+ label + '<span style="margin-left:auto">' + items.length + '</span></h3>' +
609
+ items.map(i => '<div class="card" data-id="' + esc(i) + '">' +
610
+ '<div class="t">' + (esc(IS[i].title) || '(no title)') + '</div>' +
611
+ '<div class="m">' + esc(i) + '<span style="margin-left:auto">' +
612
+ esc(IS[i].effort) + '</span></div></div>').join('') + '</div>';
613
+ }).join('') + '</div>';
614
+ } else {
615
+ let h = '';
616
+ for (const [b, label, blurb] of BUCKETS){
617
+ const items = sel.filter(i => IS[i].bucket === b);
618
+ if (!items.length) continue;
619
+ h += '<div class="bh" id="b-' + b + '"><span class="dot ' + b + '"></span>' +
620
+ '<h2>' + label + '</h2><span class="meta">' + items.length + ' · ' + blurb +
621
+ '</span></div><div class="rows">' + items.map(rowFor).join('') + '</div>';
622
+ }
623
+ out.innerHTML = h;
624
+ }
625
+ counts();
626
+ foot();
627
+ }
628
+
629
+ function counts(){
630
+ const pool = k => ids.filter(i => passes(i, k));
631
+ const opt = (k, v, label, n, on) =>
632
+ '<button class="f ' + (on ? 'on' : '') + ' ' + (n ? '' : 'zero') + '" data-k="' + k +
633
+ '" data-v="' + esc(v) + '">' + label + '<span class="n">' + n + '</span></button>';
634
+ const bs = pool('state'), be = pool('eff');
635
+ document.getElementById('fstate').innerHTML = BUCKETS.map(([b, label]) =>
636
+ opt('state', b, '<span class="dot ' + b + '"></span>' + label,
637
+ bs.filter(i => IS[i].flags.includes(b)).length, F.state.has(b))).join('');
638
+ document.getElementById('feff').innerHTML = D.efforts.length
639
+ ? D.efforts.map(e => opt('eff', e, esc(e),
640
+ be.filter(i => IS[i].effort === e).length, F.eff.has(e))).join('')
641
+ : '<div class="f" style="cursor:default">no effort recorded yet</div>';
642
+ document.getElementById('jump').innerHTML = BUCKETS.map(([b, label]) =>
643
+ '<button data-jump="' + b + '"><span class="dot ' + b + '"></span>' + label + ' ' +
644
+ ids.filter(i => IS[i].flags.includes(b)).length + '</button>').join('');
645
+ const n = F.state.size + F.eff.size + (q ? 1 : 0);
646
+ document.getElementById('clear').hidden = !n;
647
+ }
648
+
649
+ function foot(){
650
+ document.getElementById('foot').innerHTML =
651
+ 'Generated <span class="mono">__GENERATED__</span> from <code>__STORE__</code> by ' +
652
+ '<code>bin/tracker render</code>. Read-only: it is a view, not a document — ' +
653
+ 're-run that command to refresh it, and expect any hand edit to be overwritten.' +
654
+ (D.orphanNotes ? ' <b>' + D.orphanNotes + '</b> note(s) name an issue that is not ' +
655
+ 'in the store and are not shown.' : '');
656
+ }
657
+
658
+ function chipRow(list){
659
+ return list.map(i => IS[i]
660
+ ? '<span class="chip" data-id="' + esc(i) + '"><span class="dot ' + IS[i].bucket +
661
+ '"></span>' + esc(i) + ' — ' + (esc(IS[i].title) || '(no title)') + '</span>'
662
+ : '<span class="chip dead">' + esc(i) + ' — no such issue (counts as blocking)</span>'
663
+ ).join('');
664
+ }
665
+
666
+ function noteHtml(n){
667
+ const rows = Object.keys(n.fields).sort().map(k => {
668
+ const v = n.fields[k];
669
+ const text = Array.isArray(v) ? v.join('\n')
670
+ : (v && typeof v === 'object') ? JSON.stringify(v, null, 2) : String(v == null ? '' : v);
671
+ return '<dt>' + esc(k) + '</dt><dd>' + esc(text) + '</dd>';
672
+ }).join('');
673
+ return '<div class="note"><div class="hd"><span class="verb ' + esc(n.verb) + '">' +
674
+ esc(n.verb) + '</span><span>' + esc(n.actor) + '</span>' +
675
+ '<span class="when">' + esc(n.ts) + '</span></div>' +
676
+ (rows ? '<dl>' + rows + '</dl>' : '') + '</div>';
677
+ }
678
+
679
+ function openDet(id){
680
+ const it = IS[id];
681
+ let h = '<h2>' + (esc(it.title) || '(no title)') + '</h2><div class="sub">' +
682
+ esc(id) + ' · status ' + esc(it.status) + ' · effort ' + (esc(it.effort) || '—') +
683
+ (it.claimedBy ? ' · claimed by ' + esc(it.claimedBy) +
684
+ (it.claimedAt ? ' ' + esc(day(it.claimedAt)) : '') : '') + '</div>';
685
+
686
+ if (it.heldWhy) h += '<div class="blk reason held"><h4>Held — why</h4><div>' +
687
+ esc(it.heldWhy) + '</div>' + (it.heldAt ? '<div class="when">since ' +
688
+ esc(it.heldAt) + '</div>' : '') +
689
+ '<div class="when">a decision, not a completion — clear it with ' +
690
+ 'tracker set ' + esc(id) + ' heldWhy=</div></div>';
691
+
692
+ if (it.gate) h += '<div class="blk reason gated"><h4>Gated on an open question</h4>' +
693
+ '<div><code>' + esc(it.gate) + '</code></div>' + (it.gatedAt ?
694
+ '<div class="when">since ' + esc(it.gatedAt) + '</div>' : '') +
695
+ '<div class="when">no gate is ever recognised as cleared by the tracker; ' +
696
+ 'clearing it is a ruling — tracker set ' + esc(id) + ' gate=</div></div>';
697
+
698
+ if (it.openBlockers.length) h += '<div class="blk reason blocked">' +
699
+ '<h4>Blocked by (still open)</h4><div class="chips">' +
700
+ chipRow(it.openBlockers) + '</div></div>';
701
+ if (it.doneBlockers.length) h += '<div class="blk"><h4>Was blocked by (done)</h4>' +
702
+ '<div class="chips">' + chipRow(it.doneBlockers) + '</div></div>';
703
+ if (it.blocks.length) h += '<div class="blk"><h4>Blocks</h4><div class="chips">' +
704
+ chipRow(it.blocks) + '</div></div>';
705
+ if (it.flags.includes('ready')) h += '<div class="blk"><h4>Claimable</h4>' +
706
+ '<div>Nothing is holding this: not held, not gated, no open blocker. ' +
707
+ 'It is in <code>bin/tracker ready</code>.</div></div>';
708
+
709
+ h += '<div class="blk"><h4>Notes — the audit trail</h4>' + (it.notes.length
710
+ ? it.notes.map(noteHtml).join('')
711
+ : '<div>No notes on this issue. A dispatch writes one, and so does a handoff.' +
712
+ '</div>') + '</div>';
713
+
714
+ document.getElementById('detBody').innerHTML = h;
715
+ document.getElementById('det').classList.add('on');
716
+ document.getElementById('scrim').classList.add('on');
717
+ location.hash = id;
718
+ }
719
+ function closeDet(){
720
+ document.getElementById('det').classList.remove('on');
721
+ document.getElementById('scrim').classList.remove('on');
722
+ if (location.hash) history.replaceState(null, '', location.pathname);
723
+ }
724
+
725
+ document.addEventListener('click', e => {
726
+ if (e.target.id === 'clear'){
727
+ F.state.clear(); F.eff.clear(); q = '';
728
+ document.getElementById('q').value = ''; return render();
729
+ }
730
+ const j = e.target.closest('[data-jump]');
731
+ if (j){
732
+ const b = j.dataset.jump;
733
+ F.state.clear(); F.state.add(b); render();
734
+ const el = document.getElementById('b-' + b);
735
+ if (el) el.scrollIntoView({block:'start'});
736
+ return;
737
+ }
738
+ const f = e.target.closest('.f');
739
+ if (f && f.dataset.k){
740
+ const s = F[f.dataset.k];
741
+ s.has(f.dataset.v) ? s.delete(f.dataset.v) : s.add(f.dataset.v);
742
+ return render();
743
+ }
744
+ const v = e.target.closest('#view button');
745
+ if (v){
746
+ view = v.dataset.v;
747
+ document.querySelectorAll('#view button').forEach(b => b.classList.toggle('on', b === v));
748
+ return render();
749
+ }
750
+ if (e.target.closest('#theme')){
751
+ const cur = document.documentElement.dataset.theme === 'light' ? 'dark' : 'light';
752
+ document.documentElement.dataset.theme = cur;
753
+ try { localStorage.entropyMachinesTheme = cur; } catch (err) { /* private mode */ }
754
+ return;
755
+ }
756
+ const node = e.target.closest('[data-id]');
757
+ if (node) return openDet(node.dataset.id);
758
+ if (e.target.id === 'close' || e.target.id === 'scrim') closeDet();
759
+ });
760
+ document.getElementById('q').addEventListener('input', e => {
761
+ q = e.target.value.trim().toLowerCase(); render();
762
+ });
763
+ document.addEventListener('keydown', e => {
764
+ if (e.key === 'Escape') closeDet();
765
+ if (e.key === '/' && e.target.id !== 'q'){
766
+ e.preventDefault(); document.getElementById('q').focus();
767
+ }
768
+ });
769
+ try { if (localStorage.entropyMachinesTheme) document.documentElement.dataset.theme = localStorage.entropyMachinesTheme; }
770
+ catch (err) { /* private mode: keep the default */ }
771
+ render();
772
+ if (location.hash && IS[location.hash.slice(1)]) openDet(location.hash.slice(1));
773
+ // A deep link changed while the page is open must switch the panel.
774
+ window.addEventListener('hashchange', () => {
775
+ const id = location.hash.slice(1);
776
+ if (IS[id]) openDet(id);
777
+ });
778
+ </script>
779
+ </body></html>
780
+ """
781
+
782
+
783
+ if __name__ == "__main__":
784
+ sys.exit(main(sys.argv[1:]))