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,807 @@
1
+ <!doctype html>
2
+ <!-- ===================== entropy-machines dialogue doc =====================
3
+ A PRD/plan/report the owner ANSWERS. bin/serve renders it, rewires the save
4
+ button to POST /__save so answers are written back into THIS file on disk,
5
+ and live-reloads the open tab when an agent writes a reply.
6
+
7
+ CONTRACT — bin/serve and the agents both depend on these:
8
+ * every question lives in a div with class `response` and a unique,
9
+ stable `data-resp` attribute, containing a label, a div with class
10
+ `discuss`, and one textarea.
11
+ * data-resp keys are unique per doc and STABLE — they are the identity an
12
+ agent uses to read and write a specific answer. Renaming one orphans it.
13
+ * a script element of type application/json with id `responses-data`,
14
+ near the end of the body, is where saved answers are mirrored.
15
+ * pages are section elements with class `page` and id `p0`, `p1`, ...,
16
+ each with a matching nav anchor carrying the same id in `data-page`.
17
+
18
+ A QUESTION WITHOUT A .response BOX IS INVISIBLE. Only a data-resp is saved,
19
+ counted, or reported as outstanding — a question written as plain prose
20
+ renders with nowhere to answer it and the doc reports itself as answered.
21
+
22
+
23
+ NOTE: this comment deliberately describes the markup in prose instead of
24
+ showing it. It used to contain literal examples, and every regex-based tool
25
+ that reads these docs -- the answer counter, the session-start nag, ad-hoc
26
+ checks -- matched the DOCUMENTATION as though it were a real question. Each
27
+ doc then reported one phantom unanswered question that nobody could answer,
28
+ so "fully answered" was unreachable. The readers strip comments now too, but
29
+ the fix that matters is not putting a live example in a comment.
30
+
31
+ Visual standard (VS Code hc-black / hc-light):
32
+ 1. Separate with BORDERS, never background tints.
33
+ 2. Focus/hover is the focus hue as a ring, never a fill.
34
+ 3. State is never encoded in opacity — weight, case or colour+word.
35
+ 4. Four type steps only (14/17/21/30); mono gets its own step.
36
+ ========================================================================= -->
37
+ <html lang="en"><head>
38
+ <meta charset="utf-8">
39
+ <meta name="viewport" content="width=device-width, initial-scale=1">
40
+ <title>{{TITLE}}</title>
41
+ <style>
42
+ /* High-contrast stylesheet. Palette is VS Code's hc-black / hc-light from
43
+ src/vs/platform/theme/common/colors/baseColors.ts — not hand-picked, so it
44
+ carries their AAA work rather than an invented scheme.
45
+
46
+ Three rules this file exists to hold:
47
+ 1. Separate with BORDERS, never background tints. Backgrounds stay pure
48
+ #000 / #FFF. No grey panel layers, no tinted cards, no filled badges.
49
+ 2. Focus / active / hover is the focus hue as an inset ring, never a
50
+ background change. :focus-visible is global and never suppressed.
51
+ 3. State is never encoded in opacity. No faded "done", no opacity:.6 for
52
+ secondary text — weight, uppercase, or an underline instead. */
53
+
54
+ :root{
55
+ --bg:#FFFFFF; --fg:#292929;
56
+ --border:#0F4A85; /* contrastBorder */
57
+ --focus:#006BBD; /* focusBorder */
58
+ --link:#0F4A85;
59
+ --negative:#A81C0B; --positive:#0A5C21; --caution:#7A4A00; --accent:#6B21A8;
60
+
61
+ /* Four steps. Nothing below 14px, no half-pixels, no em ratios that compute
62
+ to fractions. Monospace gets its OWN step rather than a .9em of body,
63
+ because mono reads larger than sans at equal px.
64
+
65
+ REVISED 2026-08-06 after the first doc shipped and read as flat. The old
66
+ scale was 12/14/16/20: body BELOW the browser's 16px default, and h2 only
67
+ 1.14x body, so a page of prose + inline code came out as one grey size
68
+ with noise in it. The steps are now ~1.25x apart with a bigger jump to
69
+ the title, which is what makes a heading read as a heading. */
70
+ --fs-sm:14px; /* meta, ids, mono, chips, eyebrow labels, badges */
71
+ --fs-base:17px; /* body, list rows, controls */
72
+ --fs-head:21px; /* section headings */
73
+ --fs-title:30px; /* page / panel title */
74
+
75
+ /* Prose measure. Tables and code want the full column; running text does
76
+ not — 17px across a 1040px main is ~105 characters, well past the point
77
+ where the eye loses the next line. */
78
+ --measure:70ch;
79
+
80
+ --font:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;
81
+ --mono:"SF Mono",SFMono-Regular,Menlo,Consolas,monospace;
82
+ --radius:0;
83
+ }
84
+ @media (prefers-color-scheme:dark){
85
+ :root{
86
+ --bg:#000000; --fg:#FFFFFF;
87
+ --border:#6FC3DF; --focus:#F38518; --link:#21A6FF;
88
+ --negative:#F48771; --positive:#23D18B; --caution:#F5F543; --accent:#D670D6;
89
+ }
90
+ }
91
+ /* The toggle stamps data-theme on the root; it must win over the media query
92
+ in BOTH directions, so each theme is restated rather than only the dark one. */
93
+ :root[data-theme="light"]{
94
+ --bg:#FFFFFF; --fg:#292929; --border:#0F4A85; --focus:#006BBD; --link:#0F4A85;
95
+ --negative:#A81C0B; --positive:#0A5C21; --caution:#7A4A00; --accent:#6B21A8;
96
+ }
97
+ :root[data-theme="dark"]{
98
+ --bg:#000000; --fg:#FFFFFF; --border:#6FC3DF; --focus:#F38518; --link:#21A6FF;
99
+ --negative:#F48771; --positive:#23D18B; --caution:#F5F543; --accent:#D670D6;
100
+ }
101
+
102
+ *{box-sizing:border-box}
103
+ html{font-size:16px;}
104
+ body{font-family:var(--font); font-size:var(--fs-base); line-height:1.65;
105
+ color:var(--fg); background:var(--bg); margin:0; display:flex; min-height:100vh;}
106
+
107
+ /* Rule 2, globally. Never suppressed anywhere below. */
108
+ :focus-visible{outline:2px solid var(--focus); outline-offset:1px;}
109
+
110
+ code{font-family:var(--mono); font-size:var(--fs-sm); background:none;
111
+ border:1px solid var(--border); padding:0 .25em;}
112
+ pre{background:none; border:1px solid var(--border); padding:12px 14px; overflow-x:auto;
113
+ font-family:var(--mono); font-size:var(--fs-sm); line-height:1.55;}
114
+ pre code{border:none; padding:0;}
115
+
116
+ nav{width:296px; flex:none; border-right:1px solid var(--border); background:var(--bg);
117
+ padding:22px 14px; position:sticky; top:0; height:100vh; overflow-y:auto;}
118
+ nav .brand{display:flex; align-items:center; gap:8px; font-weight:700;
119
+ font-size:var(--fs-base); margin-bottom:16px; padding:0 6px;}
120
+ nav .brand .logo{width:18px; height:18px; border:1px solid var(--border); background:none;}
121
+ nav a{display:block; padding:6px 10px; color:var(--fg); text-decoration:none;
122
+ font-size:var(--fs-base); margin-bottom:2px; border:1px solid transparent;}
123
+ nav a:hover{box-shadow:inset 0 0 0 2px var(--focus);}
124
+ /* Current page: a border and weight, not a fill. */
125
+ nav a.cur{border-color:var(--border); font-weight:700;}
126
+ nav .grp{font-size:var(--fs-sm); font-weight:700; text-transform:uppercase;
127
+ letter-spacing:.06em; color:var(--fg); margin:16px 6px 6px;}
128
+ nav .foot{font-size:var(--fs-sm); color:var(--fg); margin-top:20px; padding:0 6px;}
129
+
130
+ /* serve.py injects #__navmark-css late in <body>, so it wins on source order
131
+ at equal specificity. These selectors carry one extra type each to beat it.
132
+ Its defaults are off-standard three ways: .72rem (11.52px, a fifth size),
133
+ a hardcoded #fdf6e3 fill, and pill radii — a filled badge is a background
134
+ tint, which rule 1 forbids. It also references --good/--warn-deep/--muted/
135
+ --code-bg, which this palette does not define, so its colours fall back to
136
+ inherit. Do not delete its block from the file: it is re-injected on serve. */
137
+ nav a span.navmark{font-size:var(--fs-sm); font-weight:700; line-height:1.4;
138
+ background:none; border-radius:0; padding:0 .35em; margin-left:.4em;
139
+ border:1px solid currentColor; color:var(--caution);}
140
+ nav a span.navmark.done{color:var(--positive); border-color:var(--positive);}
141
+ nav a span.navmark.todo{color:var(--caution); border-color:var(--caution); background:none;}
142
+ nav div.navsummary{font-size:var(--fs-sm); font-weight:700; color:var(--fg);
143
+ background:none; border:1px solid var(--border); border-radius:0;
144
+ margin:12px 6px 4px; padding:5px 9px;}
145
+ nav div.navsummary .all-done{color:var(--positive);}
146
+
147
+ main{flex:1; min-width:0; padding:38px 42px 120px; max-width:1060px;}
148
+ .page{display:none;} .page.cur{display:block;}
149
+
150
+ h1{font-size:var(--fs-title); font-weight:700; letter-spacing:-.015em;
151
+ line-height:1.2; margin:2px 0 8px;}
152
+ h2{font-size:var(--fs-head); font-weight:700; line-height:1.3; margin:34px 0 8px;}
153
+ /* Same step as h2, distinguished by case rather than a fifth size. */
154
+ h3{font-size:var(--fs-head); font-weight:700; text-transform:uppercase;
155
+ letter-spacing:.03em; line-height:1.3; margin:24px 0 6px;}
156
+ h1 code,h2 code,h3 code{font-size:var(--fs-head);}
157
+ .sub{font-size:var(--fs-base); margin:0 0 20px;}
158
+ p{margin:11px 0;}
159
+ main a{color:var(--link); text-decoration:underline;}
160
+
161
+ /* Measure. Tables, code and the response boxes keep the full column; running
162
+ text is capped so the eye finds the next line. Applied to the block, not
163
+ to `main`, so a wide table is never squeezed to prose width. */
164
+ .page > p, .page > ul, .page > ol, .page > .sub, .callout, blockquote{
165
+ max-width:var(--measure);}
166
+
167
+ /* Rule 1: every variant is the same pure background plus a border. The kind
168
+ is carried by the tag's colour and the left border, not by a wash. */
169
+ .callout{padding:12px 16px; margin:16px 0; border:1px solid var(--border); background:none;}
170
+ .callout.key{border-left:3px solid var(--accent);}
171
+ .callout.good{border-left:3px solid var(--positive);}
172
+ .callout.warn{border-left:3px solid var(--caution);}
173
+ .callout.bad{border-left:3px solid var(--negative);}
174
+ .callout .tag{font-size:var(--fs-sm); font-weight:700; text-transform:uppercase;
175
+ letter-spacing:.05em; display:block; margin-bottom:4px;}
176
+ .callout.key .tag{color:var(--accent);} .callout.good .tag{color:var(--positive);}
177
+ .callout.warn .tag{color:var(--caution);} .callout.bad .tag{color:var(--negative);}
178
+
179
+ .tbl-wrap{overflow-x:auto; margin:18px 0;}
180
+ table{border-collapse:collapse; width:100%; font-size:var(--fs-base);}
181
+ th,td{padding:10px 12px; text-align:left; border:1px solid var(--border); vertical-align:top;}
182
+ thead th{font-size:var(--fs-sm); font-weight:700; text-transform:uppercase;
183
+ letter-spacing:.04em; color:var(--fg); white-space:nowrap;}
184
+ td code{font-size:var(--fs-sm);}
185
+
186
+ ul,ol{margin:10px 0; padding-left:24px;} li{margin:7px 0;}
187
+ .foot{font-size:var(--fs-sm);}
188
+
189
+ .rec{border:1px solid var(--border); border-left:3px solid var(--positive);
190
+ padding:8px 13px; margin:13px 0;}
191
+ .rec .lbl{font-size:var(--fs-sm); font-weight:700; text-transform:uppercase;
192
+ letter-spacing:.05em; color:var(--positive); display:block; margin-bottom:3px;}
193
+
194
+ /* Chips are outlined, never filled — a filled badge is a background tint. */
195
+ .pill{display:inline-block; font-size:var(--fs-sm); font-weight:700; padding:1px 8px;
196
+ border:1px solid var(--border); background:none; color:var(--fg); vertical-align:middle;}
197
+ .pill.dec{color:var(--caution); border-color:var(--caution);}
198
+ .pill.red{color:var(--negative); border-color:var(--negative);}
199
+ .pill.grn{color:var(--positive); border-color:var(--positive);}
200
+
201
+ blockquote{border-left:3px solid var(--border); margin:10px 0; padding-left:13px;
202
+ font-size:var(--fs-base);}
203
+
204
+ .sev{font-weight:700;}
205
+ .sev.r{color:var(--negative);} .sev.o{color:var(--caution);} .sev.y{color:var(--caution);}
206
+ .verdict{font-weight:700; white-space:nowrap;}
207
+ .verdict.clean{color:var(--positive);} .verdict.tangled{color:var(--caution);}
208
+ .verdict.bound{color:var(--negative);}
209
+
210
+ /* ---- response boxes: page-level ----
211
+ min-height:52px and overflow:hidden are load-bearing for the skill's
212
+ auto-grow (it measures scrollHeight); do not "tidy" them away. */
213
+ .response{margin:16px 0 6px; border:1px solid var(--border);
214
+ border-left:3px solid var(--caution); background:none; padding:9px 13px;}
215
+ .response label{display:block; font-size:var(--fs-sm); font-weight:700;
216
+ text-transform:uppercase; letter-spacing:.05em; color:var(--caution); margin-bottom:5px;}
217
+ .response .discuss{margin:0 0 10px; padding:9px 12px; border:1px solid var(--border);
218
+ border-left:2px solid var(--accent); background:none;
219
+ font-size:var(--fs-base); line-height:1.55; color:var(--fg);}
220
+ .response .discuss code{font-size:var(--fs-sm);}
221
+ .response textarea{width:100%; min-height:52px; resize:vertical; font-family:var(--font);
222
+ font-size:var(--fs-base); color:var(--fg); background:var(--bg);
223
+ border:1px solid var(--border); padding:7px 10px; line-height:1.5; overflow:hidden;}
224
+ .response textarea:focus{outline:none; box-shadow:inset 0 0 0 2px var(--focus);}
225
+ /* Answered state: colour + weight on the label, never a fade or a fill. */
226
+ .response.filled{border-left-color:var(--positive);}
227
+ .response.filled label{color:var(--positive);}
228
+
229
+ /* ---- response boxes: row-level (compact) ----
230
+ 1.9em height is the skill's explicit override of the 52px above; keep it. */
231
+ tr.row-note td{padding:3px 9px 9px; border:1px solid var(--border);}
232
+ tr.row-note .response.mini{margin:0; border:none; background:none; padding:0;}
233
+ tr.row-note .response.mini textarea{display:block; width:100%; min-height:0; height:1.9em;
234
+ resize:none; overflow:hidden; font-family:var(--font); font-size:var(--fs-base);
235
+ line-height:1.4; color:var(--fg); background:var(--bg);
236
+ border:1px solid var(--border); padding:4px 9px;}
237
+ tr.row-note .response.mini textarea:focus{color:var(--fg);
238
+ box-shadow:inset 0 0 0 2px var(--focus); border-color:var(--focus);}
239
+ tr.row-note .response.mini.filled textarea{border-color:var(--positive);}
240
+
241
+ .savebar{position:fixed; bottom:14px; right:19px; display:flex; align-items:center;
242
+ gap:11px; z-index:50; background:var(--bg); border:1px solid var(--border);
243
+ padding:7px 9px 7px 16px;}
244
+ .savebar .stat{font-size:var(--fs-sm); color:var(--fg);}
245
+ .savebar button{font-family:var(--font); font-size:var(--fs-base); font-weight:700;
246
+ cursor:pointer; border:1px solid var(--border); background:none; color:var(--fg);
247
+ padding:6px 14px;}
248
+ .savebar button:hover{box-shadow:inset 0 0 0 2px var(--focus);}
249
+ .savebar button.ghost{border-color:var(--border); color:var(--fg); padding:6px 10px;}
250
+ </style>
251
+ </head><body>
252
+
253
+ <nav>
254
+ <div class="brand"><span class="logo"></span> {{DOC_SHORT_NAME}}</div>
255
+ <div class="grp">{{Section name}}</div>
256
+ <a href="#p0" data-page="p0" class="cur">0 · {{page title}}</a>
257
+ <div class="foot">{{How to use this doc, and what happens after it is answered.}}</div>
258
+ </nav>
259
+
260
+ <main>
261
+
262
+ <section class="page cur" id="p0">
263
+ <h1>{{Page heading}}</h1>
264
+ <p class="sub">{{one-line subtitle}}</p>
265
+
266
+ <p>{{Opening context.}}</p>
267
+
268
+ <div class="callout key"><span class="tag">{{TAG}}</span>
269
+ {{The single most important claim on this page.}}
270
+ </div>
271
+
272
+ <!-- The .discuss is not filler. State what is being decided, name the real
273
+ options and their costs, and end on a concrete question. If you cannot
274
+ write a real one, the box has not earned its place on the page. -->
275
+ <div class="response" data-resp="p0-question">
276
+ <label>{{Short label}}</label>
277
+ <div class="discuss">{{What is at stake, the options with one line of cost
278
+ each, which you would take and why, ending in a question.}}</div>
279
+ <textarea placeholder="Type your answer…"></textarea>
280
+ </div>
281
+ </section>
282
+
283
+ </main>
284
+
285
+ </main>
286
+
287
+ <div class="savebar">
288
+ <span class="stat" id="saveStat">Unsaved changes…</span>
289
+ <button class="ghost" id="clearBtn" title="Clear all responses">✕</button>
290
+ <button id="saveBtn">💾 Save</button>
291
+ </div>
292
+ <script type="application/json" id="responses-data">{}</script>
293
+ <script>
294
+ (function(){
295
+ var links=[].slice.call(document.querySelectorAll('nav a[data-page]'));
296
+ function show(id){
297
+ document.querySelectorAll('.page').forEach(function(p){ p.classList.toggle('cur', p.id===id); });
298
+ links.forEach(function(l){ l.classList.toggle('cur', l.dataset.page===id); });
299
+ try{ history.replaceState(null,'','#'+id); }catch(e){}
300
+ window.scrollTo(0,0);
301
+ document.querySelectorAll('#'+id+' .response textarea').forEach(function(t){ autoGrow(t); });
302
+ }
303
+ links.forEach(function(l){ l.addEventListener('click', function(ev){ ev.preventDefault(); show(l.dataset.page); }); });
304
+ if (location.hash && document.getElementById(location.hash.slice(1))) show(location.hash.slice(1));
305
+ // Required: without this, a deep link (#p2) does nothing when the tab is ALREADY
306
+ // open — the URL changes but the reader stays on page 0. show() uses
307
+ // history.replaceState, which does not fire hashchange, so there's no loop.
308
+ window.addEventListener('hashchange', function(){ var h=location.hash.slice(1); if(h && document.getElementById(h)) show(h); });
309
+ var KEY='{{DOC_KEY}}'; // CUSTOMIZE: unique per doc so localStorage never collides across docs
310
+ var boxes=[].slice.call(document.querySelectorAll('.response'));
311
+ var stat=document.getElementById('saveStat');
312
+ var dataEl=document.getElementById('responses-data');
313
+ var fileHandle=null;
314
+ function ta(b){return b.querySelector('textarea');}
315
+ function key(b){return b.getAttribute('data-resp');}
316
+ function markFilled(b){ if(ta(b).value.trim()) b.classList.add('filled'); else b.classList.remove('filled'); }
317
+ function autoGrow(el){ el.style.height='auto'; el.style.height=el.scrollHeight+'px'; }
318
+ function load(){
319
+ var saved={};
320
+ try{ saved=JSON.parse(dataEl.textContent||'{}')||{}; }catch(e){}
321
+ try{ var ls=JSON.parse(localStorage.getItem(KEY)||'{}'); Object.keys(ls).forEach(function(k){ if(ls[k]&&ls[k].trim()) saved[k]=ls[k]; }); }catch(e){}
322
+ boxes.forEach(function(b){ var v=saved[key(b)]; if(typeof v==='string') ta(b).value=v; markFilled(b); autoGrow(ta(b)); });
323
+ }
324
+ function collect(){ var o={}; boxes.forEach(function(b){ o[key(b)]=ta(b).value; }); return o; }
325
+ function autosave(){ try{ localStorage.setItem(KEY,JSON.stringify(collect())); }catch(e){} }
326
+ document.getElementById('saveBtn').addEventListener('click',async function(){
327
+ dataEl.textContent=JSON.stringify(collect(),null,2);
328
+ boxes.forEach(function(b){ ta(b).textContent=ta(b).value; });
329
+ var html='<!doctype html>\n'+document.documentElement.outerHTML;
330
+ if(window.showSaveFilePicker){
331
+ try{
332
+ if(!fileHandle) fileHandle=await window.showSaveFilePicker({suggestedName:'{{DOC_KEY}}.html',types:[{description:'HTML',accept:{'text/html':['.html']}}]});
333
+ var w=await fileHandle.createWritable(); await w.write(html); await w.close();
334
+ stat.textContent='Saved to file'; return;
335
+ }catch(err){ if(err&&err.name==='AbortError'){stat.textContent='Save cancelled';return;} }
336
+ }
337
+ var blob=new Blob([html],{type:'text/html'}); var a=document.createElement('a');
338
+ a.href=URL.createObjectURL(blob); a.download='{{DOC_KEY}}.html';
339
+ document.body.appendChild(a); a.click(); a.remove();
340
+ stat.textContent='Downloaded — overwrite the original';
341
+ });
342
+ document.getElementById('clearBtn').addEventListener('click',function(){
343
+ if(!confirm('Clear all responses?'))return;
344
+ boxes.forEach(function(b){ ta(b).value=''; markFilled(b); autoGrow(ta(b)); });
345
+ try{ localStorage.removeItem(KEY); }catch(e){}
346
+ dataEl.textContent='{}'; stat.textContent='Cleared';
347
+ });
348
+ boxes.forEach(function(b){ ta(b).addEventListener('input',function(){ markFilled(b); autoGrow(ta(b)); autosave(); stat.textContent='Unsaved changes…'; }); });
349
+ load();
350
+ window.addEventListener('resize',function(){ boxes.forEach(function(b){ autoGrow(ta(b)); }); });
351
+ })();
352
+ </script>
353
+ <script id="__disk-save-patch" data-ds="2">
354
+ (function(){
355
+ var btn=document.getElementById('saveBtn');
356
+ var stat=document.getElementById('saveStat');
357
+ if(!btn) return;
358
+ var b=btn.cloneNode(true); btn.parentNode.replaceChild(b, btn);
359
+ function collect(){ var o={}; document.querySelectorAll('.response[data-resp]').forEach(function(el){ var t=el.querySelector('textarea'); if(t) o[el.getAttribute('data-resp')]=t.value; }); return o; }
360
+ var file=(location.pathname.split('/').pop()||'').split('?')[0];
361
+ b.addEventListener('click', async function(){
362
+ var data=collect();
363
+ if(stat) stat.textContent='Saving to disk…';
364
+ try{
365
+ var res=await fetch('/__save?file='+encodeURIComponent(file),{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(data)});
366
+ if(res.ok){
367
+ if(stat) stat.textContent='Saved to disk ✓';
368
+ // Re-baseline the page's idea of what is on disk. Without this,
369
+ // responses-data still holds what the doc was SERVED with, so anything
370
+ // comparing the boxes against it reads every saved answer as an unsaved
371
+ // edit for the rest of the session — which made the live-reload patch
372
+ // refuse to auto-reload and tell the reader to 💾 something they had
373
+ // just saved.
374
+ try{
375
+ var el=document.getElementById('responses-data');
376
+ if(el) el.textContent=JSON.stringify(data,null,2);
377
+ }catch(_){}
378
+ return;
379
+ }
380
+ if(stat) stat.textContent='Save failed ('+res.status+')';
381
+ }catch(e){
382
+ if(stat) stat.textContent='Server offline — downloaded a copy (start serve.py to save in place)';
383
+ try{
384
+ var dataEl=document.getElementById('responses-data');
385
+ if(dataEl) dataEl.textContent=JSON.stringify(data,null,2);
386
+ document.querySelectorAll('.response[data-resp] textarea').forEach(function(t){ t.textContent=t.value; });
387
+ var htmlOut='<!doctype html>\n'+document.documentElement.outerHTML;
388
+ var a=document.createElement('a');
389
+ a.href=URL.createObjectURL(new Blob([htmlOut],{type:'text/html'}));
390
+ a.download=file; document.body.appendChild(a); a.click(); a.remove();
391
+ }catch(_){}
392
+ }
393
+ });
394
+ })();
395
+ </script>
396
+ <style id="__todobanner-css">
397
+ /* Palette-aware. The first version hardcoded #fdf6e3 and pill radii, which
398
+ the 2026-08-07 doc standard forbids (rule 1: separate with BORDERS, never
399
+ background tints; no filled badges). Vars fall back for docs still on the
400
+ old sheet. */
401
+ #__todobar{ position:sticky; top:0; z-index:40; margin:0 0 1.1rem;
402
+ border:2px solid var(--caution,#a16207); background:var(--bg,#fff);
403
+ color:var(--caution,#a16207); padding:.6rem .85rem; font-size:var(--fs-base,.9rem); }
404
+ #__todobar[hidden]{ display:none; }
405
+ #__todobar .hd{ font-weight:700; text-transform:uppercase; letter-spacing:.04em;
406
+ font-size:var(--fs-sm,.72rem); display:block; margin-bottom:.35rem; }
407
+ #__todobar a{ display:inline-block; font-weight:700; color:var(--caution,#a16207);
408
+ background:none; border:1px solid currentColor; padding:.14rem .6rem;
409
+ margin:.16rem .3rem .16rem 0; text-decoration:none; }
410
+ #__todobar a:hover{ outline:2px solid var(--focus,#006BBD); outline-offset:1px; }
411
+ </style>
412
+ <script id="__todobanner-patch" data-tb="2">
413
+ // An in-page "these need you" bar. Same source of truth as the nav marks —
414
+ // __janusBoxes.pendingIn (see __boxstate-patch). Clicking a chip switches to
415
+ // that page via the existing nav link, so it works with the doc's own router.
416
+ //
417
+ // v2: a row-note and a follow-up the conversation ran past are no longer
418
+ // "answers needed". v1 counted every empty textarea, so a doc with per-row
419
+ // note slots showed a permanent demand the owner could never clear.
420
+ (function(){
421
+ var main=document.querySelector('main'); if(!main) return;
422
+ var bar=document.createElement('div'); bar.id='__todobar'; bar.hidden=true;
423
+ main.insertBefore(bar, main.firstChild);
424
+ function label(link){
425
+ // Clone and drop the navmark counter first — reading link.textContent raw
426
+ // concatenates "0/1" onto the title and no amount of regex untangles
427
+ // "0/10 · What is actually open" reliably.
428
+ var c=link.cloneNode(true);
429
+ var m=c.querySelector('.navmark'); if(m) m.remove();
430
+ var t=(c.textContent||'').replace(/\s+/g,' ').trim();
431
+ return t.replace(/^\d+\s*·\s*/,'').replace(/\s*⚑\s*$/,'').trim() || link.dataset.page;
432
+ }
433
+ function refresh(){
434
+ var pending=[];
435
+ [].slice.call(document.querySelectorAll('nav a[data-page]')).forEach(function(link){
436
+ var page=document.getElementById(link.dataset.page); if(!page) return;
437
+ if(!window.__janusBoxes) return; // helper not loaded yet — deferred pass runs
438
+ var n=window.__janusBoxes.pendingIn(page).length;
439
+ if(n) pending.push({id:link.dataset.page, n:n, text:label(link)});
440
+ });
441
+ if(!pending.length){ bar.hidden=true; bar.innerHTML=''; return; }
442
+ var total=pending.reduce(function(a,p){ return a+p.n; },0);
443
+ bar.hidden=false;
444
+ bar.innerHTML='<span class="hd">'+total+(total===1?' answer needed':' answers needed')+'</span>'+
445
+ pending.map(function(p){
446
+ return '<a href="#'+p.id+'" data-goto="'+p.id+'">'+p.text+(p.n>1?' ('+p.n+')':'')+'</a>';
447
+ }).join('');
448
+ }
449
+ bar.addEventListener('click', function(e){
450
+ var a=e.target.closest('a[data-goto]'); if(!a) return;
451
+ e.preventDefault();
452
+ var nav=document.querySelector('nav a[data-page="'+a.dataset.goto+'"]');
453
+ if(nav) nav.click();
454
+ // Focus the first box that actually WANTS an answer — not merely the first
455
+ // empty one, which on a doc with row-notes is a note slot nobody owes.
456
+ var page=document.getElementById(a.dataset.goto);
457
+ var want=(page && window.__janusBoxes) ? window.__janusBoxes.pendingIn(page) : [];
458
+ var target=(want[0] && want[0].querySelector('textarea')) ||
459
+ document.querySelector('#'+a.dataset.goto+' .response[data-resp] textarea');
460
+ if(target){ target.scrollIntoView({block:'center'}); target.focus(); }
461
+ });
462
+ document.querySelectorAll('.response[data-resp] textarea').forEach(function(t){
463
+ t.addEventListener('input', refresh);
464
+ });
465
+ document.addEventListener('click', function(e){
466
+ var id=e.target && e.target.id;
467
+ if(id==='saveBtn'||id==='clearBtn') setTimeout(refresh,60);
468
+ });
469
+ refresh();
470
+ // See the same two hooks in __navmark-patch: on an already-patched doc the
471
+ // boxstate helper is appended AFTER this block, so the parse-time pass above
472
+ // finds nothing and the deferred passes do the work.
473
+ document.addEventListener('DOMContentLoaded', refresh);
474
+ setTimeout(refresh, 0);
475
+ })();
476
+ </script>
477
+ <style id="__navmark-css">
478
+ nav a[data-page]{ overflow:hidden; }
479
+ nav a .navmark{ float:right; font-size:var(--fs-sm,12px); font-weight:700; line-height:1.5; margin-left:.4rem; }
480
+ nav a .navmark.done{ color:var(--good); }
481
+ nav a .navmark.todo{ color:var(--warn-deep); background:#fdf6e3; padding:.02rem .34rem; border-radius:99px; }
482
+ @media (prefers-color-scheme:dark){ nav a .navmark.todo{ background:#241d10; } }
483
+ nav .navsummary{ font-size:var(--fs-sm,12px); font-weight:700; color:var(--muted); margin:.5rem .4rem .2rem; padding:.3rem .55rem; border-radius:6px; background:var(--code-bg); }
484
+ nav .navsummary .all-done{ color:var(--good); }
485
+ </style>
486
+ <script id="__navmark-patch" data-nm="2">
487
+ // Live per-section review state in the sidebar. Independent of the main doc
488
+ // script — listens to input/save/clear.
489
+ //
490
+ // v2: counts come from __janusBoxes (see __boxstate-patch), so a row-note and
491
+ // a follow-up the conversation ran past no longer read as an unanswered
492
+ // question. v1 counted every empty textarea and told the owner a finished doc
493
+ // still needed him.
494
+ (function(){
495
+ var links=[].slice.call(document.querySelectorAll('nav a[data-page]'));
496
+ function ensure(link){ var m=link.querySelector('.navmark'); if(!m){ m=document.createElement('span'); m.className='navmark'; link.insertBefore(m, link.firstChild); } return m; }
497
+ var summary=null;
498
+ var foot=document.querySelector('nav .foot');
499
+ if(foot){ summary=document.createElement('div'); summary.className='navsummary'; foot.parentNode.insertBefore(summary, foot); }
500
+ function refresh(){
501
+ var done=0, withBoxes=0;
502
+ links.forEach(function(link){
503
+ var page=document.getElementById(link.dataset.page);
504
+ if(!page) return;
505
+ var c=(window.__janusBoxes ? window.__janusBoxes.countsIn(page) : null);
506
+ if(!c){ return; } // helper not loaded yet — the deferred pass will run
507
+ var m=link.querySelector('.navmark');
508
+ if(c.total===0){ if(m) m.parentNode.removeChild(m); return; }
509
+ withBoxes++;
510
+ m=ensure(link);
511
+ // A page with nothing PENDING is done even when a tail follow-up sits
512
+ // empty — that box is answered-past, not owed.
513
+ if(c.pending===0){ m.textContent='✓'; m.className='navmark done'; done++; }
514
+ else { m.textContent=c.filled+'/'+c.total; m.className='navmark todo'; }
515
+ });
516
+ if(summary){
517
+ if(!withBoxes){ summary.textContent=''; }
518
+ else if(done===withBoxes){ summary.innerHTML='<span class="all-done">✓ all '+withBoxes+' sections answered</span>'; }
519
+ else { summary.textContent='Answered '+done+'/'+withBoxes+' sections'; }
520
+ }
521
+ }
522
+ document.querySelectorAll('.response[data-resp] textarea').forEach(function(t){ t.addEventListener('input', refresh); });
523
+ document.addEventListener('click', function(e){ var id=e.target && e.target.id; if(id==='saveBtn'||id==='clearBtn'){ setTimeout(refresh,60); } });
524
+ refresh();
525
+ // An ALREADY-PATCHED doc gets __boxstate-patch appended after this block, so
526
+ // the helper does not exist during the parse-time pass above. Both hooks are
527
+ // cheap and idempotent.
528
+ document.addEventListener('DOMContentLoaded', refresh);
529
+ setTimeout(refresh, 0);
530
+ })();
531
+ </script>
532
+ <style id="__trackernav-css">
533
+ /* Borders, never tints (2026-08-07 doc standard). Vars fall back for docs
534
+ still on the old sheet. */
535
+ nav .tracknav{ display:flex; flex-wrap:wrap; gap:.35rem; margin:.45rem .4rem .6rem; }
536
+ nav .tracknav a{ font-size:var(--fs-sm,12px); font-weight:700; text-decoration:none;
537
+ color:var(--muted,#71717a); border:1px solid var(--border,#ddd6fe);
538
+ padding:.1rem .5rem; }
539
+ nav .tracknav a:hover{ color:var(--accent,#7c3aed); border-color:currentColor; }
540
+ </style>
541
+ <script id="__trackernav-patch">
542
+ // Top-of-sidebar links back to the tracker pages, mirroring the header nav
543
+ // those pages carry (docs ↔ issues ↔ questions ↔ plan). Inserted after the
544
+ // nav brand; silently does nothing on a page without one (generated pages
545
+ // have their own header nav).
546
+ (function(){
547
+ var brand=document.querySelector('nav .brand'); if(!brand) return;
548
+ var row=document.createElement('div'); row.className='tracknav';
549
+ row.innerHTML='<a href="DOCS.html">docs</a><a href="TRACKER.html">issues</a>'+
550
+ '<a href="INDEX.html">questions</a><a href="WORKSTREAMS.html">plan</a>';
551
+ brand.parentNode.insertBefore(row, brand.nextSibling);
552
+ })();
553
+ </script>
554
+ <style id="__livereload-css">
555
+ /* Borders, never tints (2026-08-07 doc standard). */
556
+ #__replybar{ position:fixed; left:50%; transform:translateX(-50%); bottom:14px; z-index:60;
557
+ display:flex; align-items:center; gap:.7rem; background:var(--bg,#fff);
558
+ border:2px solid var(--positive,#0A5C21); color:var(--positive,#0A5C21);
559
+ padding:.5rem .8rem; font-size:var(--fs-base,.95rem); font-weight:700; }
560
+ #__replybar[hidden]{ display:none; }
561
+ #__replybar button{ font: inherit; font-weight:700; cursor:pointer; color:inherit;
562
+ background:none; border:1px solid currentColor; padding:.2rem .7rem; }
563
+ #__replybar button:hover{ outline:2px solid var(--focus,#006BBD); outline-offset:1px; }
564
+ #__replybar .dismiss{ border:none; font-weight:400; }
565
+ </style>
566
+ <script id="__livereload-patch" data-lr="2">
567
+ // "I replied" signal. Without it the reader has no way to learn that a fold
568
+ // landed except being told in chat — which was the last manual link in the
569
+ // loop (owner, 2026-08-09: "otherwise I won't know that you've responded").
570
+ //
571
+ // Polls /__docversion for THIS file and watches the REVIEW SIGNATURE — a hash
572
+ // of the folded <aside class="review"> blocks — not the version.
573
+ //
574
+ // It watched the version first, and that was wrong in the most annoying
575
+ // possible way: a 💾 bumps the version, so saving your own answer announced
576
+ // "New reply — reload to read it" and the reload showed nothing new, because
577
+ // nothing was. Reported by the owner 2026-08-11 ("I never actually see replies
578
+ // upon reload"). The signature moves when a reply is added or rewritten and
579
+ // stays put for anything the reader does to their own boxes, which is the
580
+ // question the banner claims to answer.
581
+ //
582
+ // It will not reload over unsaved typing. Clean is defined as: every textarea
583
+ // matches the responses-data baked into the page at load. Clean -> reload on
584
+ // its own. Dirty -> show the bar and let the reader choose, so a half-typed
585
+ // answer is never destroyed by my fold landing at the wrong moment.
586
+ (function(){
587
+ var file=(location.pathname.split('/').pop()||'').split('?')[0];
588
+ if(!file) return;
589
+ var seen=null, stopped=false;
590
+ var bar=document.createElement('div');
591
+ bar.id='__replybar'; bar.hidden=true;
592
+ bar.innerHTML='<span id="__replytext">New reply — reload to read it</span>'+
593
+ '<button id="__replygo">Reload</button>'+
594
+ '<button class="dismiss" id="__replyx" title="Dismiss">✕</button>';
595
+ document.body.appendChild(bar);
596
+ bar.querySelector('#__replygo').addEventListener('click',function(){ location.reload(); });
597
+ bar.querySelector('#__replyx').addEventListener('click',function(){ bar.hidden=true; stopped=true; });
598
+
599
+ function baked(){
600
+ var el=document.getElementById('responses-data');
601
+ try{ return JSON.parse((el&&el.textContent)||'{}')||{}; }catch(e){ return {}; }
602
+ }
603
+ function dirty(){
604
+ var disk=baked(), out=false;
605
+ document.querySelectorAll('.response[data-resp]').forEach(function(el){
606
+ var t=el.querySelector('textarea'); if(!t) return;
607
+ if((t.value||'').trim() !== String(disk[el.getAttribute('data-resp')]||'').trim()) out=true;
608
+ });
609
+ return out;
610
+ }
611
+ function tick(){
612
+ if(stopped) return;
613
+ fetch('/__docversion?file='+encodeURIComponent(file),{cache:'no-store'})
614
+ .then(function(r){ return r.ok ? r.json() : null; })
615
+ .then(function(d){
616
+ if(!d || typeof d.reviews!=='string') return;
617
+ if(seen===null){ seen=d.reviews; return; }
618
+ if(d.reviews===seen) return;
619
+ seen=d.reviews; // announce a given reply once, not every 5s
620
+ // Reload only when nothing would be lost; otherwise hand the reader
621
+ // the choice. Either way the bar reports that a reply exists.
622
+ if(!dirty()){ location.reload(); return; }
623
+ document.getElementById('__replytext').textContent=
624
+ 'New reply on disk — you have unsaved edits. 💾 first, then reload.';
625
+ bar.hidden=false;
626
+ })
627
+ .catch(function(){ /* server gone: stay quiet, the doc still works */ });
628
+ }
629
+ setInterval(tick, 5000); tick();
630
+ })();
631
+ </script>
632
+ <script id="__autosave-patch">
633
+ // Typing survives a closed tab. It did not.
634
+ //
635
+ // The dialogue template autosaves to localStorage as you type;
636
+ // REPORT-TEMPLATE.html never did, so a report collected answers only between
637
+ // the moment you typed them and the moment you pressed 💾 — close the tab
638
+ // first and they were gone with no trace anywhere. Injected rather than
639
+ // templated so every doc already written gets it too.
640
+ //
641
+ // It only ever FILLS AN EMPTY BOX. Disk is the truth for anything saved
642
+ // (__disk-wins-patch), and a stale browser copy overwriting a saved answer is
643
+ // a failure this folder has already had once — four answers on 2026-08-05 came
644
+ // back holding the text of four other boxes.
645
+ (function(){
646
+ var boxes=[].slice.call(document.querySelectorAll('.response[data-resp] textarea'));
647
+ if(!boxes.length) return;
648
+ var file=(location.pathname.split('/').pop()||'doc').split('?')[0];
649
+ // "janus-" prefix is load-bearing: __disk-wins-patch heals the stored map by
650
+ // looking up the first key with it.
651
+ var KEY='janus-'+file;
652
+ function keyOf(t){ return t.closest('.response').getAttribute('data-resp'); }
653
+ try{
654
+ var saved=JSON.parse(localStorage.getItem(KEY)||'{}')||{};
655
+ boxes.forEach(function(t){
656
+ var v=saved[keyOf(t)];
657
+ if(!t.value.trim() && typeof v==='string' && v.trim()){
658
+ t.value=v;
659
+ t.closest('.response').classList.add('filled');
660
+ t.style.height='auto'; t.style.height=t.scrollHeight+'px';
661
+ }
662
+ });
663
+ }catch(e){}
664
+ function dump(){
665
+ var o={}; boxes.forEach(function(t){ o[keyOf(t)]=t.value; });
666
+ try{ localStorage.setItem(KEY, JSON.stringify(o)); }catch(e){}
667
+ }
668
+ boxes.forEach(function(t){ t.addEventListener('input', dump); });
669
+ document.addEventListener('click', function(e){
670
+ if(e.target && e.target.id==='clearBtn') setTimeout(dump, 60);
671
+ });
672
+ })();
673
+ </script>
674
+ <script id="__disk-wins-patch">
675
+ /* Disk beats a stale localStorage for answers that are already SAVED.
676
+ *
677
+ * The template's load() does the opposite: it merges localStorage OVER the
678
+ * on-disk responses-data, unconditionally. That cost us four answers on
679
+ * 2026-08-05 — a1-goal-followup, a7-tools, a8-toolset and a9-render came back
680
+ * holding the text of the LAST four boxes, and the next save wrote the shift
681
+ * to disk as truth. Recovered from history/v015; the browser's localStorage
682
+ * was later confirmed to still hold the corrupted values, so the tab stayed
683
+ * armed to redo the damage on every subsequent save.
684
+ *
685
+ * localStorage exists to protect typing that has NOT been saved yet, and that
686
+ * is all it should win. So: a key with a non-empty value on disk is restored
687
+ * from disk; a key that is empty on disk keeps whatever the browser holds.
688
+ * Then localStorage is rewritten from the corrected DOM, which HEALS the drift
689
+ * instead of masking it — otherwise the bad map survives every reload.
690
+ *
691
+ * Runs after the template's own load() because it is appended at end-of-body.
692
+ */
693
+ (function(){
694
+ var dataEl=document.getElementById('responses-data');
695
+ if(!dataEl) return;
696
+ var disk={};
697
+ try{ disk=JSON.parse(dataEl.textContent||'{}')||{}; }catch(e){ return; }
698
+ var healed=[];
699
+ document.querySelectorAll('.response[data-resp]').forEach(function(el){
700
+ var t=el.querySelector('textarea'); if(!t) return;
701
+ var k=el.getAttribute('data-resp'), v=disk[k];
702
+ if(typeof v==='string' && v.trim() && t.value!==v){
703
+ healed.push(k+' ('+t.value.length+'->'+v.length+' chars)');
704
+ t.value=v;
705
+ }
706
+ if(t.value.trim()) el.classList.add('filled'); else el.classList.remove('filled');
707
+ t.style.height='auto'; t.style.height=t.scrollHeight+'px';
708
+ });
709
+ if(healed.length){
710
+ try{
711
+ // Which stored map belongs to THIS doc. It used to be "the first key
712
+ // starting with janus-", which was a guess: localStorage is per-ORIGIN,
713
+ // so every doc served from 127.0.0.1:8787 shares one store, and the
714
+ // first janus- key is usually some other document's answers — healing
715
+ // would then overwrite THAT doc's unsaved typing with this doc's.
716
+ // Ownership is now proved, not guessed: a map belongs to this doc if it
717
+ // holds at least one of this doc's data-resp ids. __autosave-patch's own
718
+ // key (janus-<file>) is always included.
719
+ var mine={};
720
+ document.querySelectorAll('.response[data-resp]').forEach(function(el){
721
+ var t=el.querySelector('textarea');
722
+ if(t) mine[el.getAttribute('data-resp')]=t.value;
723
+ });
724
+ var file=(location.pathname.split('/').pop()||'doc').split('?')[0];
725
+ var targets=['janus-'+file];
726
+ for(var i=0;i<localStorage.length;i++){
727
+ var k2=localStorage.key(i);
728
+ if(!k2 || k2.indexOf('janus-')!==0 || targets.indexOf(k2)>=0) continue;
729
+ try{
730
+ var m=JSON.parse(localStorage.getItem(k2)||'{}')||{};
731
+ if(Object.keys(m).some(function(kk){ return kk in mine; })) targets.push(k2);
732
+ }catch(_e){}
733
+ }
734
+ targets.forEach(function(k3){
735
+ // Only rewrite ids the map already held, plus this doc's own key in
736
+ // full — a shared-prefix map from another doc keeps its other entries.
737
+ var out;
738
+ if(k3==='janus-'+file){ out=mine; }
739
+ else {
740
+ try{ out=JSON.parse(localStorage.getItem(k3)||'{}')||{}; }catch(_e){ return; }
741
+ Object.keys(out).forEach(function(kk){ if(kk in mine) out[kk]=mine[kk]; });
742
+ }
743
+ localStorage.setItem(k3,JSON.stringify(out));
744
+ });
745
+ }catch(e){}
746
+ console.warn('[dialogue-doc] restored '+healed.length+
747
+ ' answer(s) from disk over a stale localStorage: '+healed.join(', '));
748
+ }
749
+ })();
750
+ </script>
751
+ <script id="__boxstate-patch" data-bs="1">
752
+ // WHICH BOXES ACTUALLY WANT THE READER. One definition, used by the nav marks
753
+ // and the todo bar, because they used to each count "every .response[data-resp]
754
+ // with an empty textarea" and that count lied twice over:
755
+ //
756
+ // 1. A .mini row-note ("note on this row") is an INVITATION, never a demand.
757
+ // Counting them left pages reading 0/7 forever.
758
+ // 2. A follow-up box the conversation already ran PAST is not pending. Chains
759
+ // read question -> answer -> my reply + follow-up -> answer -> ...; a gap
760
+ // in the middle means the reader kept going, a gap at the END means they
761
+ // have not answered yet.
762
+ //
763
+ // Both were reported by the owner against a doc that claimed four open
764
+ // questions when every real question in it was answered ("uh.. everything is
765
+ // checked off. what is open here?", 2026-08-23). Same rule as doctracker.py's
766
+ // qstate/mark_superseded — if you change one, change the other.
767
+ (function(){
768
+ function chainOf(key){
769
+ var stem=key, m;
770
+ while((m=/^(.*)-followup\d*$/.exec(stem))) stem=m[1];
771
+ return stem;
772
+ }
773
+ function all(){
774
+ return [].slice.call(document.querySelectorAll('.response[data-resp]'));
775
+ }
776
+ // A box counts at all only if it is a real question box, not a row-note.
777
+ function countable(box){ return !box.classList.contains('mini'); }
778
+ function filled(box){
779
+ var t=box.querySelector('textarea');
780
+ return !!(t && t.value.trim());
781
+ }
782
+ // Answered later in the SAME chain, anywhere in the doc, in document order.
783
+ function supersededMap(){
784
+ var boxes=all(), seen={}, out={};
785
+ for(var i=boxes.length-1;i>=0;i--){
786
+ var key=boxes[i].getAttribute('data-resp'), ch=chainOf(key);
787
+ out[key]=!!seen[ch];
788
+ if(filled(boxes[i])) seen[ch]=true;
789
+ }
790
+ return out;
791
+ }
792
+ function pendingIn(page){
793
+ var sup=supersededMap();
794
+ return [].slice.call(page.querySelectorAll('.response[data-resp]'))
795
+ .filter(function(b){
796
+ return countable(b) && !filled(b) && !sup[b.getAttribute('data-resp')];
797
+ });
798
+ }
799
+ function countsIn(page){
800
+ var boxes=[].slice.call(page.querySelectorAll('.response[data-resp]')).filter(countable);
801
+ return {total:boxes.length, filled:boxes.filter(filled).length,
802
+ pending:pendingIn(page).length};
803
+ }
804
+ window.__janusBoxes={chainOf:chainOf, pendingIn:pendingIn, countsIn:countsIn};
805
+ })();
806
+ </script>
807
+ </body></html>