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,413 @@
1
+ <!doctype html>
2
+ <!-- ==================== entropy-machines report template ====================
3
+ A sprint report, plan, or audit the owner READS AND ANSWERS. Copy this file
4
+ into the project's docs directory (docs.dir in config.json, default
5
+ entropy-machines-docs/), replace the placeholders, and serve it with bin/serve. The
6
+ owner answers in the browser and the save button writes those answers back
7
+ into this file on disk.
8
+
9
+ ------------------------------------------------------------------ THE RULE
10
+ A REPORT IS A LOCAL FILE. It is never published to, and never depends on,
11
+ an external website. Everything it needs is inside it: no stylesheet link,
12
+ no script tag, no font, no image loaded from an off-machine origin. A doc
13
+ that phones out stops working the moment it leaves this machine, or the
14
+ moment the machine is offline, and it turns a private sprint report into a
15
+ request to somebody else's server.
16
+
17
+ Enforced by bin/doclint, which refuses any reference (an href, a src, a CSS
18
+ url(), a string literal in a script) pointing at http://, https://, or a
19
+ protocol-relative //host/path. Prose that merely MENTIONS a URL is fine —
20
+ text does not fetch anything. Data URIs, relative paths and same-document
21
+ anchors are fine; embed images as data URIs rather than linking them.
22
+
23
+ ------------------------------------------------------ THE OTHER HALF: ANSWERS
24
+ A report with nowhere to answer is a broadcast, not a dialogue. bin/doclint
25
+ also refuses a doc that:
26
+ * has no answer box under some h2 — every section the owner is expected to
27
+ respond to gets one;
28
+ * gives two boxes the same key — the second silently overwrites the first
29
+ on save;
30
+ * has an answer box with no textarea in it;
31
+ * is missing the save button or the saved-answers JSON block, either of
32
+ which means the owner's typing dies in the browser.
33
+
34
+ An answer box is a div with class `response` carrying a unique, stable key
35
+ attribute, and containing a label, a div with class `discuss` posing an
36
+ actual question, and one textarea. The key is the identity bin/serve uses to
37
+ write an answer back into this file — renaming one orphans the answer that
38
+ was already saved under the old name. The saved answers are mirrored into a
39
+ JSON script block near the end of the body.
40
+
41
+ DO NOT PASTE A LIVE ANSWER-BOX ATTRIBUTE INTO A COMMENT. That is why the
42
+ paragraph above describes the markup instead of showing it. Every tool that
43
+ reads these docs counts those keys with a regex — bin/serve's dashboard,
44
+ bin/status, bin/doclint — and a documented example got counted as a real
45
+ question. Each doc built from a template with a literal example reported one
46
+ phantom unanswered question nobody could ever answer, so "fully answered"
47
+ was an unreachable state. The readers strip comments now; not writing the
48
+ example is still the fix that matters.
49
+
50
+ ------------------------------------------------------------------ THE SPINE
51
+ Landed, then Verified, then Still open, in that order. Put anything you like
52
+ between them — a decision the owner has to make, a table, a war story. Those
53
+ three are the ones a reader knows how to look for. Nothing checks the order;
54
+ it is the convention, and a reader who cannot find "Still open" will assume
55
+ there is nothing open.
56
+
57
+ The citation band under the header carries one link per issue this report
58
+ DELIVERS and one per issue it OPENS, so a reader can get from the report to
59
+ the tracker without reading the prose.
60
+
61
+ --------------------------------------------------------------- THE LOOK
62
+ The palette below is VS Code High Contrast (hc-black, with an hc-light
63
+ override on the theme toggle), the fonts are the system stack, and there are
64
+ four type steps: 12 / 14 / 16 / 20. That is this project's house style and a
65
+ reasonable default — it renders identically offline on any machine and needs
66
+ no font download. IT IS NOT A CONFORMANCE TARGET. Restyle it however you
67
+ like; bin/doclint does not look at colours, fonts or sizes. Only local-only
68
+ and answerable are enforced.
69
+
70
+ YOU DO NOT HAVE TO EDIT THIS FILE TO CHANGE THE LOOK. The token block below
71
+ sits between two markers, and bin/serve swaps that span for
72
+ lib/themes/<name>.css when config.json says docs.theme: "<name>" — ships
73
+ with "high-contrast" (this one, the default) and "daylight", a softer
74
+ light-first theme for long reading. Everything below the end marker is
75
+ structure and is shared by every theme, so a layout fix is made once. Adding
76
+ a third theme is a new file in lib/themes/ defining the same token names; see
77
+ docs/CONFIG.md.
78
+
79
+ If you keep the house style, it holds together on three rules: separate with
80
+ borders, never background tints; focus and hover are the focus hue as a
81
+ border, never a fill; state is never carried by opacity alone — use weight,
82
+ case, or a colour plus a word.
83
+
84
+ There is no live-reload poller in here. Add the one from
85
+ lib/doc-template.html if you want an open tab to pick up an agent's reply.
86
+ ========================================================================= -->
87
+ <html lang="en"><head>
88
+ <meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
89
+ <title>{{TITLE}}</title>
90
+ <style>
91
+ /* THEME TOKENS. Everything between the two markers below is exactly
92
+ lib/themes/high-contrast.css, inlined so this file still looks right when
93
+ it is opened straight off disk. bin/serve REPLACES that span with
94
+ lib/themes/<docs.theme>.css when docs.theme names another theme (see
95
+ docs/CONFIG.md) — the markers are what it looks for, so keep them, keep
96
+ the block between them tokens-only, and put no layout in there. Nothing
97
+ below the end marker is themed: structural CSS is shared by every theme,
98
+ which is the point of having themes instead of a second template. */
99
+ /* entropy-machines-theme:begin */
100
+ /* high-contrast — the house style, and the default.
101
+ VS Code High Contrast: hc-black as the bare :root, hc-light on the toggle.
102
+ Lifted verbatim from what lib/REPORT-TEMPLATE.html has always shipped; the
103
+ values are established house style, not up for improvement here.
104
+
105
+ A THEME FILE IS TOKENS ONLY. No layout, no component rules, no @font-face
106
+ and no remote URL of any kind — the structural CSS stays in the templates
107
+ so both themes get every structural fix for free, and bin/doclint refuses
108
+ any doc that references something off this machine.
109
+
110
+ Selected by docs.theme in config.json (see docs/CONFIG.md). bin/serve
111
+ INLINES this file between a doc's entropy-machines-theme:begin and entropy-machines-theme:end
112
+ markers, and into its own dashboard. It is never linked to as a stylesheet:
113
+ a doc is a standalone local file that must still look right when it is
114
+ opened straight off disk or moved somewhere else. */
115
+ :root{
116
+ --bg:#000; --panel:#000;
117
+ --ink:#fff; --dim:rgba(255,255,255,.7);
118
+ --line:#6FC3DF; /* contrastBorder */
119
+ --focus:#F38518; /* focusBorder / activeContrastBorder */
120
+ --accent:#21A6FF; /* textLink.foreground */
121
+ --ok:#23D18B; --warn:#F5F543; --bad:#F48771; --alt:#D670D6;
122
+ --fs-sm:12px; --fs-base:14px; --fs-head:16px; --fs-title:20px;
123
+ --mono:ui-monospace,SFMono-Regular,Menlo,monospace;
124
+ --positive:var(--ok); --good:var(--ok); --muted:var(--dim); --border:var(--line);
125
+ --caution:var(--warn); --warn-deep:var(--warn); --code-bg:var(--panel);
126
+ }
127
+ /* The toggle stamps data-theme on the root element. hc-black is the bare
128
+ :root, so only the light state needs restating; [data-theme=dark] matches
129
+ nothing here and correctly falls through to the default. */
130
+ :root[data-theme=light]{
131
+ --bg:#fff; --panel:#fff;
132
+ --ink:#292929; --dim:rgba(41,41,41,.75);
133
+ --line:#0F4A85; --focus:#006BBD; --accent:#0F4A85;
134
+ --ok:#0A5C21; --warn:#7A4A00; --bad:#A81C0B; --alt:#6B21A8;
135
+ }
136
+ /* entropy-machines-theme:end */
137
+ *{box-sizing:border-box}
138
+ body{margin:0;background:var(--bg);color:var(--ink);
139
+ font:var(--fs-base)/1.6 ui-sans-serif,-apple-system,"Segoe UI",Roboto,sans-serif}
140
+ main{max-width:860px;margin:0 auto;padding:24px 20px 80px}
141
+ a{color:var(--accent)}
142
+ :focus-visible{outline:2px solid var(--focus);outline-offset:1px}
143
+
144
+ h1{font-size:var(--fs-title);font-weight:700;margin:0 0 4px;line-height:1.3}
145
+ h2{font-size:var(--fs-head);font-weight:700;margin:32px 0 8px;letter-spacing:.2px}
146
+ h3{font-size:var(--fs-base);font-weight:700;margin:20px 0 6px}
147
+ p,li,td,th{font-size:var(--fs-base)}
148
+ .sub,.meta,figcaption{font-size:var(--fs-sm);color:var(--ink)}
149
+ .eyebrow{font-size:var(--fs-sm);font-weight:700;text-transform:uppercase;
150
+ letter-spacing:.09em;margin:0 0 6px}
151
+ code{font-family:var(--mono);font-size:var(--fs-sm);border:1px solid var(--line);
152
+ border-radius:2px;padding:0 4px}
153
+ h1 code{font-size:var(--fs-head)}
154
+ pre{font-family:var(--mono);font-size:var(--fs-sm);border:1px solid var(--line);
155
+ border-radius:4px;padding:10px 12px;overflow-x:auto;line-height:1.5}
156
+ pre code{border:0;padding:0;font-size:inherit}
157
+
158
+ header{border-bottom:1px solid var(--line);padding-bottom:12px;margin-bottom:8px;
159
+ display:flex;gap:12px;align-items:baseline;flex-wrap:wrap}
160
+ header .sub{margin:0}
161
+ #theme{margin-left:auto;background:var(--panel);border:1px solid var(--line);
162
+ color:var(--ink);border-radius:4px;padding:6px 10px;cursor:pointer;
163
+ font-family:inherit;font-size:var(--fs-sm);font-weight:600}
164
+ #theme:hover{box-shadow:inset 0 0 0 2px var(--focus)}
165
+
166
+ .pill{display:inline-block;font-size:var(--fs-sm);font-weight:700;padding:1px 8px;
167
+ border-radius:10px;border:1px solid var(--line);color:var(--ink)}
168
+ .pill.ok{color:var(--ok);border-color:var(--ok)}
169
+ .pill.warn{color:var(--warn);border-color:var(--warn)}
170
+ .pill.bad{color:var(--bad);border-color:var(--bad)}
171
+ .pill.alt{color:var(--alt);border-color:var(--alt)}
172
+
173
+ .card{border:1px solid var(--line);border-radius:4px;padding:12px 14px;margin:12px 0}
174
+ .card > :first-child{margin-top:0}
175
+ .card > :last-child{margin-bottom:0}
176
+ .card.flag{border-color:var(--focus)}
177
+ table{width:100%;border-collapse:collapse;margin:12px 0;display:block;overflow-x:auto}
178
+ th,td{border:1px solid var(--line);padding:6px 10px;text-align:left;vertical-align:top}
179
+ th{font-weight:700}
180
+ td.num,th.num{text-align:right;font-variant-numeric:tabular-nums}
181
+ ul,ol{padding-left:20px}
182
+ li{margin:3px 0}
183
+ hr{border:0;border-top:1px solid var(--line);margin:28px 0}
184
+
185
+ nav{border:1px solid var(--line);padding:10px 12px;margin:0 0 22px;
186
+ display:flex;flex-wrap:wrap;align-items:center;gap:10px 16px}
187
+ nav .brand{font-size:var(--fs-sm);font-weight:700;text-transform:uppercase;
188
+ letter-spacing:.09em}
189
+ nav a{font-size:var(--fs-sm);text-decoration:none;border-bottom:1px solid var(--line)}
190
+ nav a:hover{border-bottom-color:var(--focus)}
191
+
192
+ .links{border:1px solid var(--line);padding:8px 12px;margin:0 0 18px;
193
+ display:flex;flex-wrap:wrap;gap:6px 14px;align-items:baseline}
194
+ .links .k{font-size:var(--fs-sm);font-weight:700;text-transform:uppercase;
195
+ letter-spacing:.09em;color:var(--dim)}
196
+ .links a{font-family:var(--mono);font-size:var(--fs-sm);text-decoration:none;
197
+ border:1px solid var(--line);padding:1px 6px}
198
+ .links a:hover{border-color:currentColor}
199
+ .links .grp{display:flex;flex-wrap:wrap;gap:4px;align-items:baseline}
200
+
201
+ .response{border:1px solid var(--line);padding:12px 14px;margin:16px 0}
202
+ .response label{display:block;font-size:var(--fs-sm);font-weight:700;
203
+ text-transform:uppercase;letter-spacing:.09em;margin:0 0 6px}
204
+ .response .discuss{font-size:var(--fs-base);margin:0 0 10px}
205
+ .response textarea{display:block;width:100%;min-height:76px;padding:8px;
206
+ background:var(--bg);color:var(--ink);border:1px solid var(--line);
207
+ font:var(--fs-base)/1.5 var(--mono);resize:vertical}
208
+ .response textarea:focus-visible{outline:none;border-color:var(--focus);
209
+ box-shadow:inset 0 0 0 1px var(--focus)}
210
+ .savebar{position:fixed;bottom:14px;right:19px;display:flex;align-items:center;
211
+ gap:11px;z-index:50;background:var(--bg);border:1px solid var(--line);
212
+ padding:7px 9px 7px 16px}
213
+ .savebar .stat{font-size:var(--fs-sm)}
214
+ .savebar button{font:700 var(--fs-base) inherit;cursor:pointer;
215
+ border:1px solid var(--line);background:none;color:var(--ink);padding:6px 14px}
216
+ .savebar button:hover{box-shadow:inset 0 0 0 2px var(--focus)}
217
+ .savebar button.ghost{padding:6px 10px}
218
+ </style></head>
219
+ <body>
220
+ <main>
221
+
222
+ <nav aria-label="document">
223
+ <div class="brand">{{KIND}} · {{DATE}}</div>
224
+ <a href="#landed">Landed</a>
225
+ <a href="#verified">Verified</a>
226
+ <a href="#call">Your call</a>
227
+ <a href="#open">Still open</a>
228
+ </nav>
229
+
230
+ <header>
231
+ <div>
232
+ <h1>{{HEADLINE — what this sprint was for, in the owner's words}}</h1>
233
+ <p class="sub">{{N}} commits on <code>{{BRANCH}}</code> · verified by re-running, not by relaying</p>
234
+ </div>
235
+ <button id="theme">◐ theme</button>
236
+ </header>
237
+
238
+ <!-- Citation band: one link per issue DELIVERED, one per issue OPENED. The
239
+ delivered ones point at the tracker ids; the opened ones point at the
240
+ anchors in "Still open" below, so a reader can jump straight to them. -->
241
+ <div class="links">
242
+ <span class="k">delivers</span>
243
+ <span class="grp">
244
+ <a href="#i-delivered-one">{{i-delivered-one}}</a>
245
+ <a href="#i-delivered-two">{{i-delivered-two}}</a>
246
+ </span>
247
+ <span class="k">opens</span>
248
+ <span class="grp">
249
+ <a href="#i-opened-one">{{i-opened-one}}</a>
250
+ </span>
251
+ <span class="k">rulings</span>
252
+ <span class="grp"><a href="{{SOME-OTHER-DOC}}.html">{{that doc}}</a></span>
253
+ </div>
254
+
255
+ <p><span class="pill ok">{{one-word verdict}}</span> {{One paragraph: what a
256
+ reader gets out of this sprint, and the single biggest risk in it. Say the risk
257
+ plainly — it does not shrink by being phrased carefully.}}</p>
258
+
259
+ <h2 id="landed">Landed</h2>
260
+
261
+ <p>{{What changed and why it was worth doing. Lead with the thing that would
262
+ surprise the owner, not with the chronology.}}</p>
263
+
264
+ <ul>
265
+ <li id="i-delivered-one"><code>{{i-delivered-one}}</code> — {{what it does now}}</li>
266
+ <li id="i-delivered-two"><code>{{i-delivered-two}}</code> — {{what it does now}}</li>
267
+ </ul>
268
+
269
+ <div class="response" data-resp="s-landed">
270
+ <label>On what landed</label>
271
+ <div class="discuss">{{Ask an actual question about a trade you made — not
272
+ "any thoughts?". A box under a question the owner cannot answer is the same as
273
+ no box.}}</div>
274
+ <textarea placeholder="Type your answer…"></textarea>
275
+ </div>
276
+
277
+ <h2 id="verified">Verified</h2>
278
+
279
+ <table>
280
+ <thead><tr><th>Check</th><th>Command</th><th class="num">Result</th></tr></thead>
281
+ <tbody>
282
+ <tr><td>{{what was checked}}</td><td><code>{{the command you ran}}</code></td><td class="num">{{result}}</td></tr>
283
+ <tr><td>{{what was checked}}</td><td><code>{{the command you ran}}</code></td><td class="num">{{result}}</td></tr>
284
+ </tbody>
285
+ </table>
286
+
287
+ <p class="meta"><strong>Not run:</strong> {{Name the gaps. An unmentioned gap
288
+ reads as a passing check.}}</p>
289
+
290
+ <div class="response" data-resp="s-verified">
291
+ <label>On the verification</label>
292
+ <div class="discuss">{{What would you have wanted run that was not?}}</div>
293
+ <textarea placeholder="Type your answer…"></textarea>
294
+ </div>
295
+
296
+ <h2 id="call">Your call</h2>
297
+
298
+ <div class="card flag">
299
+ <p class="eyebrow">Decision needed</p>
300
+ <p><strong>{{The fork, as a question with two named options.}}</strong></p>
301
+ <p>{{Which one you would take, and the one-line cost of each. Recommend one
302
+ unless both are bad.}}</p>
303
+ </div>
304
+
305
+ <div class="response" data-resp="s-call">
306
+ <label>On the decision</label>
307
+ <div class="discuss">{{Option A or option B? Say which and I'll do it.}}</div>
308
+ <textarea placeholder="Type your answer…"></textarea>
309
+ </div>
310
+
311
+ <h2 id="open">Still open</h2>
312
+
313
+ <ul>
314
+ <li id="i-opened-one"><span class="pill bad">open</span>
315
+ <code>{{i-opened-one}}</code> — {{what is unresolved, and what it blocks}}
316
+ <span class="meta">{{status · effort · gate if any}}</span></li>
317
+ </ul>
318
+
319
+ <div class="response" data-resp="s-open">
320
+ <label>On what is still open</label>
321
+ <div class="discuss">{{Which of these should jump the queue before anyone
322
+ else depends on this?}}</div>
323
+ <textarea placeholder="Type your answer…"></textarea>
324
+ </div>
325
+
326
+ <hr>
327
+ <pre><code>{{Paste the real output of the suite run and the commit list.
328
+ Numbers, not adjectives.}}</code></pre>
329
+
330
+ </main>
331
+
332
+ <div class="savebar">
333
+ <span class="stat" id="saveStat">Unsaved changes…</span>
334
+ <button class="ghost" id="clearBtn" title="Clear">✕</button>
335
+ <button id="saveBtn">💾 Save</button>
336
+ </div>
337
+
338
+ <!-- Mirrors what has been written to disk. bin/serve rewrites this block on
339
+ save; it is also what the answer counters read. Leave it as {} in a new
340
+ doc. -->
341
+ <script type="application/json" id="responses-data">{}</script>
342
+
343
+ <script>
344
+ // Autosave to localStorage between saves, so typing survives a reload. The
345
+ // browser copy is a convenience only — nothing outside this tab can read it,
346
+ // so the answer is not real until 💾 writes it into the file.
347
+ (function(){
348
+ var file=(location.pathname.split('/').pop()||'report').split('?')[0];
349
+ var KEY='entropy-machines-'+file;
350
+ var boxes=[].slice.call(document.querySelectorAll('.response[data-resp] textarea'));
351
+ try{
352
+ var disk=JSON.parse(document.getElementById('responses-data').textContent||'{}');
353
+ var saved=JSON.parse(localStorage.getItem(KEY)||'{}');
354
+ boxes.forEach(function(t){
355
+ var k=t.closest('.response').getAttribute('data-resp');
356
+ if(!t.value.trim()) t.value = disk[k] || saved[k] || '';
357
+ });
358
+ }catch(e){}
359
+ function dump(){
360
+ var o={}; boxes.forEach(function(t){ o[t.closest('.response').getAttribute('data-resp')]=t.value; });
361
+ try{ localStorage.setItem(KEY, JSON.stringify(o)); }catch(e){}
362
+ }
363
+ boxes.forEach(function(t){ t.addEventListener('input', dump); });
364
+ var clear=document.getElementById('clearBtn');
365
+ if(clear) clear.addEventListener('click', function(){
366
+ boxes.forEach(function(t){ t.value=''; }); dump();
367
+ var st=document.getElementById('saveStat'); if(st) st.textContent='Cleared — press 💾 to write it';
368
+ });
369
+ })();
370
+ </script>
371
+
372
+ <script>
373
+ const r = document.documentElement;
374
+ try { if (localStorage.reportTheme) r.dataset.theme = localStorage.reportTheme; } catch(e){}
375
+ document.getElementById('theme').onclick = () => {
376
+ const next = r.dataset.theme === 'light' ? 'dark' : 'light';
377
+ r.dataset.theme = next;
378
+ try { localStorage.reportTheme = next; } catch(e){}
379
+ };
380
+ </script>
381
+
382
+ <script>
383
+ // 💾 posts to bin/serve, which merges the answers into THIS file on disk.
384
+ // The endpoint takes the filename in the query string and a flat
385
+ // {key: answer} body — see docs/SERVE.md. bin/serve also injects its own
386
+ // copy of this handler on every GET and wins the click, so a doc can never
387
+ // be served with a Downloads-folder save as the only way to save; this one
388
+ // is here so the contract is visible in the file the author is reading.
389
+ (function(){
390
+ var btn=document.getElementById('saveBtn'), st=document.getElementById('saveStat');
391
+ if(!btn) return;
392
+ btn.addEventListener('click', function(){
393
+ var o={};
394
+ document.querySelectorAll('.response[data-resp]').forEach(function(el){
395
+ var t=el.querySelector('textarea'); if(!t) return;
396
+ var v=(t.value||''); if(v.trim()) o[el.getAttribute('data-resp')]=v;
397
+ });
398
+ var file=(location.pathname.split('/').pop()||'').split('?')[0];
399
+ if(st) st.textContent='Saving…';
400
+ fetch('/__save?file='+encodeURIComponent(file),
401
+ {method:'POST',headers:{'Content-Type':'application/json'},
402
+ body:JSON.stringify(o)})
403
+ .then(function(r){ if(!r.ok) throw new Error('HTTP '+r.status); return r.json(); })
404
+ .then(function(j){
405
+ if(st) st.textContent='Saved '+j.count+' answer(s) to '+file;
406
+ var el=document.getElementById('responses-data');
407
+ if(el) el.textContent=JSON.stringify(o,null,1);
408
+ })
409
+ .catch(function(e){ if(st) st.textContent='SAVE FAILED: '+e.message+' — open it through bin/serve'; });
410
+ });
411
+ })();
412
+ </script>
413
+ </body></html>