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.
- package/LICENSE +93 -0
- package/README.md +68 -0
- package/agents/isolated-worker.md +128 -0
- package/agents/verifier.md +158 -0
- package/bin/dispatch +700 -0
- package/bin/doclint +460 -0
- package/bin/drain +507 -0
- package/bin/drain-pick.py +168 -0
- package/bin/drain-prompt.md +67 -0
- package/bin/drain-run.sh +342 -0
- package/bin/entropy-machines-init +285 -0
- package/bin/handoff +1151 -0
- package/bin/init +232 -0
- package/bin/post-fold-audit +377 -0
- package/bin/serve +724 -0
- package/bin/status +208 -0
- package/bin/tracker +153 -0
- package/docs/AGENT-QUICKSTART.md +86 -0
- package/docs/CONFIG.md +68 -0
- package/docs/NPM.md +91 -0
- package/docs/SERVE.md +74 -0
- package/docs/TRACKER-ADAPTER.md +66 -0
- package/doctrine/HANDOFF-PROMPT.md +63 -0
- package/doctrine/README.md +62 -0
- package/doctrine/ROLES.md +27 -0
- package/doctrine/WORKFLOW.md +87 -0
- package/hooks/commit-msg +24 -0
- package/hooks/post-checkout +354 -0
- package/hooks/pre-commit +33 -0
- package/lib/PRD-001-orientation.html +1180 -0
- package/lib/REPORT-TEMPLATE.html +413 -0
- package/lib/changelog-collate.mjs +328 -0
- package/lib/changelog-guard.sh +157 -0
- package/lib/changelog-new.mjs +70 -0
- package/lib/config.mjs +283 -0
- package/lib/config.py +317 -0
- package/lib/doc-template.html +807 -0
- package/lib/entropy-drain.plist.in +59 -0
- package/lib/entropy-drain.service.in +53 -0
- package/lib/entropy-drain.timer.in +36 -0
- package/lib/fail-first.mjs +901 -0
- package/lib/handoff-guard.sh +623 -0
- package/lib/install-hooks.sh +169 -0
- package/lib/notes.py +675 -0
- package/lib/preflight-tree.mjs +82 -0
- package/lib/roots.sh +212 -0
- package/lib/themes/daylight.css +84 -0
- package/lib/themes/high-contrast.css +36 -0
- package/lib/tracker-file +333 -0
- package/lib/tracker-view.py +784 -0
- 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">◐</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">×</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 => ({'&':'&','<':'<','>':'>','"':'"'}[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 <id> title="…"</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:]))
|