proctolog 0.1.0

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 (228) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +23 -0
  3. data/README.md +178 -0
  4. data/app/controllers/proctolog/acceptances_controller.rb +29 -0
  5. data/app/controllers/proctolog/application_controller.rb +32 -0
  6. data/app/controllers/proctolog/bursts_controller.rb +34 -0
  7. data/app/controllers/proctolog/choices_controller.rb +30 -0
  8. data/app/controllers/proctolog/code_reviews_controller.rb +26 -0
  9. data/app/controllers/proctolog/decisions_controller.rb +33 -0
  10. data/app/controllers/proctolog/entries_controller.rb +14 -0
  11. data/app/controllers/proctolog/evidence_controller.rb +23 -0
  12. data/app/controllers/proctolog/freshness_controller.rb +27 -0
  13. data/app/controllers/proctolog/good_to_go_controller.rb +29 -0
  14. data/app/controllers/proctolog/memberships_controller.rb +20 -0
  15. data/app/controllers/proctolog/mentions_controller.rb +16 -0
  16. data/app/controllers/proctolog/moments_controller.rb +26 -0
  17. data/app/controllers/proctolog/note_params.rb +24 -0
  18. data/app/controllers/proctolog/notes_controller.rb +56 -0
  19. data/app/controllers/proctolog/open_pull_requests_controller.rb +59 -0
  20. data/app/controllers/proctolog/pictures_controller.rb +27 -0
  21. data/app/controllers/proctolog/prompts_controller.rb +28 -0
  22. data/app/controllers/proctolog/pull_request_scoped.rb +21 -0
  23. data/app/controllers/proctolog/pull_requests_controller.rb +34 -0
  24. data/app/controllers/proctolog/put_away_controller.rb +19 -0
  25. data/app/controllers/proctolog/recordings_controller.rb +28 -0
  26. data/app/controllers/proctolog/rendered_pieces_controller.rb +11 -0
  27. data/app/controllers/proctolog/review_data_controller.rb +12 -0
  28. data/app/controllers/proctolog/rules_controller.rb +20 -0
  29. data/app/controllers/proctolog/scenario_notes_controller.rb +26 -0
  30. data/app/controllers/proctolog/scenario_scoped.rb +21 -0
  31. data/app/controllers/proctolog/scenarios_controller.rb +67 -0
  32. data/app/controllers/proctolog/sitting_servers_controller.rb +30 -0
  33. data/app/controllers/proctolog/waiting_controller.rb +14 -0
  34. data/app/controllers/proctolog/with_decisions.rb +28 -0
  35. data/app/helpers/proctolog/bursts_helper.rb +52 -0
  36. data/app/helpers/proctolog/decisions_helper.rb +102 -0
  37. data/app/helpers/proctolog/desk_helper.rb +61 -0
  38. data/app/helpers/proctolog/mentions_helper.rb +27 -0
  39. data/app/helpers/proctolog/open_pull_requests_helper.rb +31 -0
  40. data/app/helpers/proctolog/prompts_helper.rb +21 -0
  41. data/app/helpers/proctolog/pull_requests_helper.rb +67 -0
  42. data/app/helpers/proctolog/recordings_helper.rb +30 -0
  43. data/app/helpers/proctolog/rounds_helper.rb +27 -0
  44. data/app/helpers/proctolog/scenarios_helper.rb +123 -0
  45. data/app/helpers/proctolog/standings_helper.rb +80 -0
  46. data/app/jobs/proctolog/application_job.rb +8 -0
  47. data/app/jobs/proctolog/ask_for_release_job.rb +9 -0
  48. data/app/jobs/proctolog/fetch_pictures_ahead_job.rb +36 -0
  49. data/app/jobs/proctolog/prune_fresh_profiles_job.rb +9 -0
  50. data/app/jobs/proctolog/read_mentions_ahead_job.rb +12 -0
  51. data/app/models/proctolog/acceptance.rb +47 -0
  52. data/app/models/proctolog/app.rb +92 -0
  53. data/app/models/proctolog/browser.rb +42 -0
  54. data/app/models/proctolog/burst.rb +268 -0
  55. data/app/models/proctolog/burst_notices.rb +143 -0
  56. data/app/models/proctolog/chapters.rb +42 -0
  57. data/app/models/proctolog/check.rb +122 -0
  58. data/app/models/proctolog/choices.rb +112 -0
  59. data/app/models/proctolog/code_review.rb +251 -0
  60. data/app/models/proctolog/decision_record.rb +12 -0
  61. data/app/models/proctolog/decisions.rb +199 -0
  62. data/app/models/proctolog/description.rb +165 -0
  63. data/app/models/proctolog/difference.rb +138 -0
  64. data/app/models/proctolog/doctor.rb +127 -0
  65. data/app/models/proctolog/evidence.rb +29 -0
  66. data/app/models/proctolog/failure.rb +7 -0
  67. data/app/models/proctolog/focus.rb +48 -0
  68. data/app/models/proctolog/fresh_sitting.rb +35 -0
  69. data/app/models/proctolog/github.rb +353 -0
  70. data/app/models/proctolog/handover/brief.rb +138 -0
  71. data/app/models/proctolog/handover/layers.rb +128 -0
  72. data/app/models/proctolog/handover/places.rb +98 -0
  73. data/app/models/proctolog/handover/taken.rb +41 -0
  74. data/app/models/proctolog/handover/worklist.rb +35 -0
  75. data/app/models/proctolog/handover.rb +123 -0
  76. data/app/models/proctolog/housekeeping.rb +23 -0
  77. data/app/models/proctolog/kit.rb +36 -0
  78. data/app/models/proctolog/marks.rb +130 -0
  79. data/app/models/proctolog/membership.rb +91 -0
  80. data/app/models/proctolog/mentions.rb +117 -0
  81. data/app/models/proctolog/note.rb +94 -0
  82. data/app/models/proctolog/outside.rb +45 -0
  83. data/app/models/proctolog/overlay/brief.rb +52 -0
  84. data/app/models/proctolog/overlay/written.rb +33 -0
  85. data/app/models/proctolog/overlay.rb +87 -0
  86. data/app/models/proctolog/owner.rb +16 -0
  87. data/app/models/proctolog/pick.rb +47 -0
  88. data/app/models/proctolog/picture.rb +138 -0
  89. data/app/models/proctolog/pieces_rendering.rb +40 -0
  90. data/app/models/proctolog/project.rb +118 -0
  91. data/app/models/proctolog/pull_request.rb +89 -0
  92. data/app/models/proctolog/recording.rb +194 -0
  93. data/app/models/proctolog/release.rb +59 -0
  94. data/app/models/proctolog/rendering.rb +303 -0
  95. data/app/models/proctolog/reports.rb +66 -0
  96. data/app/models/proctolog/response_files.rb +55 -0
  97. data/app/models/proctolog/rounds.rb +305 -0
  98. data/app/models/proctolog/route.rb +151 -0
  99. data/app/models/proctolog/rules.rb +121 -0
  100. data/app/models/proctolog/scenario.rb +126 -0
  101. data/app/models/proctolog/server.rb +273 -0
  102. data/app/models/proctolog/since.rb +108 -0
  103. data/app/models/proctolog/size.rb +30 -0
  104. data/app/models/proctolog/standing.rb +132 -0
  105. data/app/models/proctolog/theme.rb +40 -0
  106. data/app/models/proctolog/unopened/numbers.rb +48 -0
  107. data/app/models/proctolog/unopened/opening.rb +88 -0
  108. data/app/models/proctolog/unopened.rb +203 -0
  109. data/app/models/proctolog/visit.rb +26 -0
  110. data/app/views/layouts/proctolog/application.html.erb +58 -0
  111. data/app/views/proctolog/acceptances/new.html.erb +34 -0
  112. data/app/views/proctolog/bursts/_line.html.erb +28 -0
  113. data/app/views/proctolog/bursts/_merged.html.erb +23 -0
  114. data/app/views/proctolog/bursts/_moved.html.erb +16 -0
  115. data/app/views/proctolog/bursts/_notices.html.erb +39 -0
  116. data/app/views/proctolog/bursts/_put_away.html.erb +11 -0
  117. data/app/views/proctolog/bursts/_rules.html.erb +27 -0
  118. data/app/views/proctolog/bursts/_side.html.erb +49 -0
  119. data/app/views/proctolog/bursts/_side_stop.html.erb +10 -0
  120. data/app/views/proctolog/bursts/show.html.erb +54 -0
  121. data/app/views/proctolog/code_reviews/show.html.erb +33 -0
  122. data/app/views/proctolog/decisions/_alternative.html.erb +5 -0
  123. data/app/views/proctolog/decisions/_beneath.html.erb +21 -0
  124. data/app/views/proctolog/decisions/_box.html.erb +27 -0
  125. data/app/views/proctolog/decisions/_choices.html.erb +9 -0
  126. data/app/views/proctolog/decisions/_draft_tools.html.erb +47 -0
  127. data/app/views/proctolog/decisions/_part.html.erb +61 -0
  128. data/app/views/proctolog/decisions/_round_data.html.erb +1 -0
  129. data/app/views/proctolog/decisions/_theme.html.erb +13 -0
  130. data/app/views/proctolog/decisions/index.html.erb +75 -0
  131. data/app/views/proctolog/notes/_card.html.erb +19 -0
  132. data/app/views/proctolog/notes/_template.html.erb +41 -0
  133. data/app/views/proctolog/open_pull_requests/_side.html.erb +30 -0
  134. data/app/views/proctolog/open_pull_requests/index.html.erb +106 -0
  135. data/app/views/proctolog/prompts/show.html.erb +41 -0
  136. data/app/views/proctolog/pull_requests/_connections.html.erb +65 -0
  137. data/app/views/proctolog/pull_requests/_finish.html.erb +42 -0
  138. data/app/views/proctolog/pull_requests/_margin.html.erb +33 -0
  139. data/app/views/proctolog/pull_requests/_note.html.erb +4 -0
  140. data/app/views/proctolog/pull_requests/_outline.html.erb +26 -0
  141. data/app/views/proctolog/pull_requests/_paragraphs.html.erb +9 -0
  142. data/app/views/proctolog/pull_requests/_scenarios.html.erb +16 -0
  143. data/app/views/proctolog/pull_requests/_since.html.erb +42 -0
  144. data/app/views/proctolog/pull_requests/_standing.html.erb +51 -0
  145. data/app/views/proctolog/pull_requests/_status.html.erb +5 -0
  146. data/app/views/proctolog/pull_requests/_try.html.erb +122 -0
  147. data/app/views/proctolog/pull_requests/show.html.erb +145 -0
  148. data/app/views/proctolog/recordings/_dialog.html.erb +26 -0
  149. data/app/views/proctolog/recordings/_list.html.erb +8 -0
  150. data/app/views/proctolog/recordings/_note.html.erb +22 -0
  151. data/app/views/proctolog/recordings/index.html.erb +33 -0
  152. data/app/views/proctolog/recordings/show.html.erb +118 -0
  153. data/app/views/proctolog/scenarios/_dialog.html.erb +22 -0
  154. data/app/views/proctolog/scenarios/_note.html.erb +18 -0
  155. data/app/views/proctolog/scenarios/_open.html.erb +9 -0
  156. data/app/views/proctolog/scenarios/_scenario.html.erb +48 -0
  157. data/app/views/proctolog/scenarios/_server.html.erb +20 -0
  158. data/app/views/proctolog/scenarios/index.html.erb +62 -0
  159. data/bin/proctolog +330 -0
  160. data/completions/_proctolog +143 -0
  161. data/config/application.rb +94 -0
  162. data/config/database.yml +16 -0
  163. data/config/environment.rb +6 -0
  164. data/config/initializers/grouse.rb +113 -0
  165. data/config/initializers/grouse_notes.rb +9 -0
  166. data/config/keys.yml +6 -0
  167. data/config/routes.rb +71 -0
  168. data/config/storage.yml +4 -0
  169. data/db/migrate/20261019213542_create_grouse_tables.grouse.rb +128 -0
  170. data/db/migrate/20261019213550_create_active_storage_tables.active_storage.rb +58 -0
  171. data/docs/manual/app.md +157 -0
  172. data/docs/manual/bursts.md +129 -0
  173. data/docs/manual/deciding.md +89 -0
  174. data/docs/manual/desk.png +0 -0
  175. data/docs/manual/films.md +20 -0
  176. data/docs/manual/handover.md +93 -0
  177. data/docs/manual/pr-format.md +75 -0
  178. data/docs/manual/reading.md +233 -0
  179. data/docs/manual/unopened.md +80 -0
  180. data/docs/manual/updates.md +55 -0
  181. data/lib/proctolog/installation.rb +125 -0
  182. data/lib/proctolog/version.rb +7 -0
  183. data/public/asking.js +50 -0
  184. data/public/code-review.js +40 -0
  185. data/public/cursor.js +292 -0
  186. data/public/decision-screenshots.js +97 -0
  187. data/public/decisions.css +949 -0
  188. data/public/decisions.js +1289 -0
  189. data/public/desk.css +582 -0
  190. data/public/desk.js +126 -0
  191. data/public/favicon.png +0 -0
  192. data/public/keys.js +228 -0
  193. data/public/mentions.js +389 -0
  194. data/public/messages.js +103 -0
  195. data/public/notes.js +731 -0
  196. data/public/pick.css +99 -0
  197. data/public/plaque.png +0 -0
  198. data/public/pr-number.js +11 -0
  199. data/public/prompt.css +46 -0
  200. data/public/prompt.js +16 -0
  201. data/public/pull_request.css +805 -0
  202. data/public/recording.css +379 -0
  203. data/public/recording.js +394 -0
  204. data/public/release.js +25 -0
  205. data/public/respond.css +260 -0
  206. data/public/respond.js +95 -0
  207. data/public/review-drafts.js +949 -0
  208. data/public/review-rounds.js +1447 -0
  209. data/public/scenarios.css +253 -0
  210. data/public/scenarios.js +36 -0
  211. data/public/schemas/proctolog-response-v1.json +101 -0
  212. data/public/side.js +133 -0
  213. data/public/standing.css +478 -0
  214. data/public/terminal-apl.svg +6 -0
  215. data/public/terminal-cuneiform.svg +3 -0
  216. data/public/terminal-grain-dots.svg +6 -0
  217. data/public/terminal-grain-weave.svg +6 -0
  218. data/public/terminal-grain-wedges.svg +6 -0
  219. data/public/terminal-shades.svg +3 -0
  220. data/public/terminal.css +4426 -0
  221. data/public/toast.js +26 -0
  222. data/public/try.js +124 -0
  223. data/public/viewer.js +808 -0
  224. data/public/words.js +81 -0
  225. data/skills/proctolog-handover/SKILL.md +77 -0
  226. data/skills/proctolog-pr-writing/SKILL.md +228 -0
  227. data/skills/proctolog-pr-writing/references/example-description.md +33 -0
  228. metadata +366 -0
@@ -0,0 +1,1447 @@
1
+ import { helping, keyOf } from "./keys.js";
2
+ import { prNumber } from "./pr-number.js";
3
+ import {
4
+ announce,
5
+ handOver,
6
+ draftsState,
7
+ registerRounds,
8
+ stale,
9
+ updateDrafts,
10
+ } from "./review-drafts.js";
11
+ import { toast } from "./toast.js";
12
+ import { viewPictures } from "./viewer.js";
13
+
14
+ const tools = document.querySelector("[data-review-draft]");
15
+ const { repository, burst } = tools.dataset;
16
+ const onPR = document.querySelector("[data-round-results][data-pr]");
17
+ const localPR = onPR ? prNumber(onPR.dataset.pr) : null;
18
+ let context = JSON.parse(
19
+ document.querySelector("#review-round-data").textContent,
20
+ );
21
+ // The rounds as the desk holds them now; on a pull request's page, with its
22
+ // descriptions.
23
+ const reviewData = async () => {
24
+ const response = await fetch(
25
+ `/bursts/${encodeURIComponent(burst)}/review_data${localPR ? `?pr=${localPR}` : ""}`,
26
+ { headers: { Accept: "application/json" } },
27
+ );
28
+ if (!response.ok) throw new Error(`Desk returned ${response.status}`);
29
+ return response.json();
30
+ };
31
+ // What GitHub draws of each piece of markdown, by its words, asked once while
32
+ // the page is open: returned work is laid out again at every change of the
33
+ // drafts, and must not fall back to markdown each time. A piece that cannot
34
+ // be drawn stays as written.
35
+ const drawing = new Map();
36
+ const ask = (texts) => {
37
+ if (!texts.length) return;
38
+ const asked = fetch(`/bursts/${encodeURIComponent(burst)}/rendered_pieces`, {
39
+ method: "POST",
40
+ headers: {
41
+ "Content-Type": "application/json",
42
+ Accept: "application/json",
43
+ "X-CSRF-Token":
44
+ document.querySelector('meta[name="csrf-token"]')?.content ?? "",
45
+ },
46
+ body: JSON.stringify({ markdown: texts }),
47
+ })
48
+ .then((response) => (response.ok ? response.json() : { pieces: [] }))
49
+ .then(({ pieces }) => pieces)
50
+ .catch(() => []);
51
+ for (const [index, text] of texts.entries())
52
+ drawing.set(
53
+ text,
54
+ asked.then((pieces) => pieces[index]),
55
+ );
56
+ };
57
+ // Swaps the markdown pieces under `parent` for what GitHub draws of them,
58
+ // their pictures served from here.
59
+ const drawPieces = (parent) => {
60
+ // What the description says since a handover is drawn only once it is opened.
61
+ const pieces = [...parent.querySelectorAll("pre[data-markdown]")].filter(
62
+ (piece) => !piece.closest("details[data-pass-changes]:not([open])"),
63
+ );
64
+ ask([
65
+ ...new Set(
66
+ pieces
67
+ .map((piece) => piece.textContent)
68
+ .filter((text) => !drawing.has(text)),
69
+ ),
70
+ ]);
71
+ for (const piece of pieces)
72
+ drawing.get(piece.textContent).then((html) => {
73
+ if (!html || !piece.isConnected) return;
74
+ const drawn = element("div", undefined, "markdown-body piece");
75
+ drawn.innerHTML = html;
76
+ piece.replaceWith(drawn);
77
+ });
78
+ };
79
+ // Words written in markdown, by an agent or a description, read as written
80
+ // until GitHub's drawing of them comes.
81
+ const markdown = (text) => {
82
+ const piece = element("pre", text, "as-written");
83
+ piece.dataset.markdown = "";
84
+ return piece;
85
+ };
86
+
87
+ const labels = {
88
+ leave: "Leave as is",
89
+ question: "Question / research",
90
+ change: "Requested change",
91
+ later: "Backlog task",
92
+ remark: "Review note",
93
+ rule: "Rule to record",
94
+ };
95
+ const signature = (item) => JSON.stringify([item.kind, item.text]);
96
+ const choiceItem = (entry) => ({
97
+ id:
98
+ entry.requestId ||
99
+ `choice:${entry.decision.id}:${entry.savedAt || "legacy"}`,
100
+ pr: entry.decision.pr,
101
+ kind: entry.kind,
102
+ text: entry.text || entry.decision.lead,
103
+ source: {
104
+ kind: "decision",
105
+ decision: entry.decision,
106
+ needsReview: stale(entry),
107
+ },
108
+ });
109
+ // A withdrawn rule asks nothing more: the request to take it out does.
110
+ const withdrawn = (id) =>
111
+ (draftsState().requests || []).some(
112
+ (item) => item.id === id && item.withdrawn,
113
+ );
114
+ const currentItems = () => [
115
+ ...context.requests,
116
+ ...draftsState().choices.map(choiceItem),
117
+ ...(draftsState().requests || []).filter((item) => !item.withdrawn),
118
+ ];
119
+ const batches = () => draftsState().batches || [];
120
+ const feedbackFor = (id) => draftsState().feedback?.[id];
121
+
122
+ const latestFor = (item) => {
123
+ const candidates = [];
124
+ for (const report of draftsState().responses || []) {
125
+ const batch = batches().find(
126
+ (candidate) => candidate.id === report.batchId,
127
+ );
128
+ const request = batch?.items.find((candidate) => candidate.id === item.id);
129
+ if (!request || signature(request) !== signature(item)) continue;
130
+ const result = report.results.find((candidate) => candidate.id === item.id);
131
+ if (result)
132
+ candidates.push({
133
+ report,
134
+ result,
135
+ batch,
136
+ request,
137
+ key: `${report.id}:${report.createdAt}`,
138
+ order: batches().indexOf(batch),
139
+ });
140
+ }
141
+ const ordered = candidates
142
+ .sort(
143
+ (a, b) =>
144
+ a.order - b.order ||
145
+ Date.parse(a.report.createdAt) - Date.parse(b.report.createdAt),
146
+ )
147
+ .at(-1);
148
+ const feedback = feedbackFor(item.id);
149
+ if (feedback?.intent === signature(item)) {
150
+ const repliedTo = candidates.find(
151
+ (candidate) => candidate.key === feedback.replyTo,
152
+ );
153
+ if (feedback.resolution === "confirmed") return repliedTo || ordered;
154
+ if (feedback.resolution === "reopened") {
155
+ const newer = candidates.filter((candidate) => {
156
+ const batch = batches().find(
157
+ (value) => value.id === candidate.report.batchId,
158
+ );
159
+ const request = batch.items.find((value) => value.id === item.id);
160
+ return (
161
+ Date.parse(batch.createdAt) >= Date.parse(feedback.at) &&
162
+ request.ownerReply?.text === feedback.text &&
163
+ request.ownerReply?.kind === feedback.kind
164
+ );
165
+ });
166
+ return (
167
+ newer
168
+ .sort(
169
+ (a, b) =>
170
+ a.order - b.order ||
171
+ Date.parse(a.report.createdAt) - Date.parse(b.report.createdAt),
172
+ )
173
+ .at(-1) ||
174
+ repliedTo ||
175
+ ordered
176
+ );
177
+ }
178
+ }
179
+ return ordered;
180
+ };
181
+
182
+ // The batch that last carried a request as it stands, with the owner's
183
+ // reply when they reopened it; and whether anything came back to it.
184
+ const carrier = (item) => {
185
+ const feedback = feedbackFor(item.id);
186
+ const reply =
187
+ feedback?.resolution === "reopened" && feedback.intent === signature(item)
188
+ ? feedback
189
+ : undefined;
190
+ return batches().findLast((batch) =>
191
+ batch.items.some(
192
+ (request) =>
193
+ request.id === item.id &&
194
+ signature(request) === signature(item) &&
195
+ (!reply ||
196
+ (request.ownerReply?.kind === reply.kind &&
197
+ request.ownerReply?.text === reply.text)),
198
+ ),
199
+ );
200
+ };
201
+ const dayAndTime = (at) =>
202
+ new Date(at).toLocaleString("en-GB", {
203
+ day: "numeric",
204
+ month: "short",
205
+ hour: "2-digit",
206
+ minute: "2-digit",
207
+ });
208
+ const handedOverAt = (batch) => dayAndTime(batch.createdAt);
209
+ // An answer named as the owner knows it: by the handover it answers.
210
+ const answerTo = (report) => {
211
+ const batch = batches().find((candidate) => candidate.id === report.batchId);
212
+ return batch
213
+ ? `The agent's answer to the handover of ${handedOverAt(batch)}`
214
+ : "An agent's answer";
215
+ };
216
+ const answered = (batch) =>
217
+ (draftsState().responses || []).some((report) => report.batchId === batch.id);
218
+ // For the next pass, unless an agent took it and has not answered: the desk
219
+ // holds that back (Rounds#state_of in rounds.rb follows the same rules).
220
+ const forAgent = (item) => {
221
+ const batch = carrier(item);
222
+ return batch && !answered(batch) ? "out" : "agent";
223
+ };
224
+
225
+ const statusOf = (item) => {
226
+ if (item.pr && !Object.hasOwn(context.heads, item.pr)) return "archived";
227
+ if (withdrawn(item.id)) return "archived";
228
+ if (
229
+ item.source.kind === "decision" &&
230
+ !draftsState().choices.some((entry) => choiceItem(entry).id === item.id)
231
+ )
232
+ return "archived";
233
+ if (item.kind === "leave" && item.source.decision?.id.startsWith("missing:"))
234
+ return "archived";
235
+ if (item.kind === "leave") return "closed";
236
+ const latest = latestFor(item);
237
+ const feedback = feedbackFor(item.id);
238
+ if (!latest) return forAgent(item);
239
+ if (feedback?.intent === signature(item) && feedback.replyTo === latest.key)
240
+ return feedback.resolution === "confirmed" ? "closed" : forAgent(item);
241
+ return latest.result.status === "needs_owner" ? "question" : "owner";
242
+ };
243
+
244
+ const allItems = () => {
245
+ const items = new Map(currentItems().map((item) => [item.id, item]));
246
+ for (const batch of batches())
247
+ for (const item of batch.items)
248
+ if (!items.has(item.id) && latestFor(item)) items.set(item.id, item);
249
+ return [...items.values()];
250
+ };
251
+
252
+ const validateResponse = (report) => {
253
+ if (
254
+ report?.format !== "proctolog-response/v1" ||
255
+ report.repository !== repository ||
256
+ report.review !== burst ||
257
+ !["id", "batchId", "createdAt", "summary"].every(
258
+ (key) => typeof report[key] === "string" && report[key].trim(),
259
+ ) ||
260
+ !Number.isFinite(Date.parse(report.createdAt)) ||
261
+ !Array.isArray(report.results)
262
+ )
263
+ throw new Error("Not a proctolog-response/v1 response for this burst.");
264
+ const batch = batches().find((candidate) => candidate.id === report.batchId);
265
+ if (!batch)
266
+ throw new Error(
267
+ "This batch is not in this browser. Restore its choices backup, or export a new handover.",
268
+ );
269
+ const allowed = new Set(batch.items.map((item) => item.id));
270
+ const seen = new Set();
271
+ for (const result of report.results) {
272
+ if (!allowed.has(result.id) || seen.has(result.id))
273
+ throw new Error(`Unknown or repeated request: ${result.id}`);
274
+ seen.add(result.id);
275
+ if (
276
+ !["done", "answered", "deferred", "needs_owner", "blocked"].includes(
277
+ result.status,
278
+ ) ||
279
+ !["summary", "details"].every((key) => typeof result[key] === "string") ||
280
+ !result.summary.trim() ||
281
+ !Array.isArray(result.evidence) ||
282
+ !Array.isArray(result.verification)
283
+ )
284
+ throw new Error(`Incomplete result for ${result.id}.`);
285
+ if (
286
+ result.status === "needs_owner" &&
287
+ (!result.question ||
288
+ !["prompt", "recommendation"].every(
289
+ (key) =>
290
+ typeof result.question[key] === "string" &&
291
+ result.question[key].trim(),
292
+ ) ||
293
+ typeof result.question.blocking !== "boolean")
294
+ )
295
+ throw new Error(
296
+ `Question ${result.id} needs a recommendation and a blocking flag.`,
297
+ );
298
+ if (
299
+ result.evidence.some(
300
+ (entry) => !entry || typeof entry.label !== "string",
301
+ ) ||
302
+ result.verification.some(
303
+ (entry) =>
304
+ !entry ||
305
+ typeof entry.command !== "string" ||
306
+ !["passed", "failed", "not_run"].includes(entry.result),
307
+ )
308
+ )
309
+ throw new Error(
310
+ `Evidence or verification has the wrong shape for ${result.id}.`,
311
+ );
312
+ if (
313
+ result.status === "deferred" &&
314
+ (typeof result.backlog !== "string" || !result.backlog.trim())
315
+ )
316
+ throw new Error(
317
+ `Deferred request ${result.id} needs a backlog reference or proposed task.`,
318
+ );
319
+ if (result.head !== undefined && !/^[a-f0-9]{40}$/.test(result.head))
320
+ throw new Error(`Result ${result.id} needs a full verified head hash.`);
321
+ }
322
+ if (
323
+ report.followups !== undefined &&
324
+ (!Array.isArray(report.followups) ||
325
+ report.followups.some((entry) => typeof entry !== "string"))
326
+ )
327
+ throw new Error(
328
+ "Follow-ups must be a list of suggestions; they do not add requests.",
329
+ );
330
+ return report;
331
+ };
332
+
333
+ // An answer is known by its id and the time it was written: one an agent
334
+ // writes again under a new time is a newer version of it, shown in its place,
335
+ // and the one before goes under the earlier responses. The owner's replies
336
+ // name the version they answered, so none moves to words they did not read.
337
+ const versionOf = (report) => `${report.id}:${report.createdAt}`;
338
+ const repliedTo = (version) =>
339
+ Object.values(draftsState().feedback || {}).some(
340
+ (feedback) =>
341
+ feedback?.replyTo === version ||
342
+ feedback?.history?.some((reply) => reply?.replyTo === version),
343
+ );
344
+
345
+ // Takes an answer in, and says whether it was new, a newer version of one
346
+ // already shown, or nothing the page did not have.
347
+ const acceptResponse = (report) => {
348
+ try {
349
+ validateResponse(report);
350
+ } catch (error) {
351
+ throw new Error(`${answerTo(report)} cannot be read: ${error.message}`);
352
+ }
353
+ const kept = draftsState().responses || [];
354
+ const same = kept.find(
355
+ (candidate) => versionOf(candidate) === versionOf(report),
356
+ );
357
+ if (same && JSON.stringify(same) === JSON.stringify(report)) return;
358
+ // Written again without a new time, it can stand in for the old one only
359
+ // while the owner has not replied to it.
360
+ if (same && repliedTo(versionOf(same)))
361
+ throw new Error(
362
+ `${answerTo(report)} was changed after you replied to it, and kept its old time, so the desk cannot tell the two apart. It goes on showing the version you replied to.`,
363
+ );
364
+ const earlier = kept.filter((candidate) => candidate.id === report.id);
365
+ updateDrafts((state) => {
366
+ state.responses = [
367
+ ...(state.responses || []).filter(
368
+ (candidate) => versionOf(candidate) !== versionOf(report),
369
+ ),
370
+ report,
371
+ ];
372
+ });
373
+ return earlier.length ? "rewritten" : "new";
374
+ };
375
+
376
+ const element = (tag, text, className) => {
377
+ const node = document.createElement(tag);
378
+ if (text !== undefined) node.textContent = text;
379
+ if (className) node.className = className;
380
+ return node;
381
+ };
382
+
383
+ const button = (label, action) => {
384
+ const node = element("button", label, "quiet");
385
+ node.type = "button";
386
+ node.addEventListener("click", action);
387
+ return node;
388
+ };
389
+
390
+ const safeLink = (url, label) => {
391
+ try {
392
+ const parsed = new URL(url);
393
+ if (!["https:", "http:"].includes(parsed.protocol))
394
+ return element("span", label);
395
+ const link = element("a", label);
396
+ link.href = parsed.href;
397
+ link.target = "_blank";
398
+ link.rel = "noopener noreferrer";
399
+ return link;
400
+ } catch {
401
+ return element("span", label);
402
+ }
403
+ };
404
+
405
+ // On a pull request's page the reading goes through returned work block by
406
+ // block, as through the description (notes.js), and the cursor stands beside
407
+ // the block it rests on. Each block is named by its part, unique on the page,
408
+ // so that the reading keeps its place when the work is laid out again.
409
+ const stop = (node, part) => {
410
+ node.dataset.part = part;
411
+ if (!onPR) return node;
412
+ node.dataset.reading = "";
413
+ if (node.localName !== "summary") node.tabIndex = -1;
414
+ return node;
415
+ };
416
+ // A block of words, drawn as GitHub draws markdown.
417
+ const said = (text, part, className = "response-prose") => {
418
+ const block = stop(element("div", undefined, className), part);
419
+ block.append(markdown(text));
420
+ return block;
421
+ };
422
+
423
+ // Whether each fold of returned work is open, by its part, so that laying the
424
+ // work out again leaves the folds as the owner left them.
425
+ const folds = new Map();
426
+ const fold = (part, label, open = false) => {
427
+ const details = element("details");
428
+ details.open = folds.get(part) ?? open;
429
+ details.addEventListener("toggle", () => folds.set(part, details.open));
430
+ details.append(stop(element("summary", label), part));
431
+ return details;
432
+ };
433
+
434
+ // What an answer rests on, as the owner would check it: the pictures it names
435
+ // shown, each before beside its after; the files and pages it names; and the
436
+ // checks the agent ran, with how each came out.
437
+ const PICTURE = /\.(png|jpe?g|gif|webp)$/i;
438
+ const evidenceAt = (path) =>
439
+ `/bursts/${encodeURIComponent(burst)}/evidence?path=${encodeURIComponent(path)}`;
440
+ // A before and its after are pictures named alike but for that word.
441
+ const afterOf = (path) => path.replace(/before(?=[^/]*$)/, "after");
442
+ const inPairs = (pictures) => {
443
+ const placed = new Set();
444
+ return pictures.flatMap((entry) => {
445
+ if (placed.has(entry)) return [];
446
+ const twin = (match) =>
447
+ pictures.find(
448
+ (other) => other !== entry && !placed.has(other) && match(other),
449
+ );
450
+ const after = twin((other) => other.path === afterOf(entry.path));
451
+ const before = twin((other) => afterOf(other.path) === entry.path);
452
+ const shots = after ? [entry, after] : before ? [before, entry] : [entry];
453
+ for (const shot of shots) placed.add(shot);
454
+ return [shots];
455
+ });
456
+ };
457
+ // A pair is named as its before is, without the word.
458
+ const nameOf = ([first, second]) =>
459
+ (second && first.label.replace(/[\s,:·–—-]*before\s*$/i, "")) || first.label;
460
+
461
+ const pictureOf = (shots, part) => {
462
+ const figure = stop(element("figure", undefined, "evidence"), part);
463
+ figure.dataset.name = nameOf(shots);
464
+ if (shots.length > 1)
465
+ figure.dataset.compare = `/bursts/${encodeURIComponent(burst)}/evidence/compare?${new URLSearchParams({ before: shots[0].path, after: shots[1].path })}`;
466
+ shots.forEach((entry, index) => {
467
+ const shot = element("span", undefined, "shot");
468
+ const image = element("img");
469
+ Object.assign(image, {
470
+ src: evidenceAt(entry.path),
471
+ alt: entry.label,
472
+ loading: "lazy",
473
+ draggable: false,
474
+ });
475
+ image.addEventListener("error", () =>
476
+ image.replaceWith(
477
+ element("span", `Not on this Mac: ${entry.path}`, "missing"),
478
+ ),
479
+ );
480
+ shot.append(image);
481
+ if (shots.length > 1)
482
+ shot.append(element("span", index ? "after" : "before", "half-said"));
483
+ figure.append(shot);
484
+ });
485
+ figure.append(element("figcaption", figure.dataset.name));
486
+ return figure;
487
+ };
488
+
489
+ const evidenceOf = (result, at) => {
490
+ const pictures = result.evidence.filter((entry) =>
491
+ PICTURE.test(entry.path ?? ""),
492
+ );
493
+ const named = result.evidence.filter((entry) => !pictures.includes(entry));
494
+ const shown = fold(
495
+ at("evidence"),
496
+ `Evidence · ${result.evidence.length}`,
497
+ true,
498
+ );
499
+ for (const [index, shots] of inPairs(pictures).entries())
500
+ shown.append(pictureOf(shots, at(`picture ${index}`)));
501
+ if (named.length) {
502
+ const list = stop(element("ul", undefined, "evidence-named"), at("named"));
503
+ for (const entry of named) {
504
+ const row = element("li");
505
+ row.append(
506
+ entry.url
507
+ ? safeLink(entry.url, entry.label)
508
+ : element("span", entry.label),
509
+ );
510
+ if (entry.path) row.append(element("code", entry.path));
511
+ if (entry.quote) row.append(element("blockquote", entry.quote));
512
+ list.append(row);
513
+ }
514
+ shown.append(list);
515
+ }
516
+ return shown;
517
+ };
518
+
519
+ const RESULTS = { passed: "passed", failed: "failed", not_run: "not run" };
520
+ const verificationOf = (result, at) => {
521
+ const passed = result.verification.filter(
522
+ (check) => check.result === "passed",
523
+ ).length;
524
+ const checks = fold(
525
+ at("verification"),
526
+ `Verification · ${passed} of ${result.verification.length} passed`,
527
+ true,
528
+ );
529
+ const list = stop(element("ul", undefined, "checks"), at("checks"));
530
+ for (const check of result.verification) {
531
+ const row = element("li");
532
+ row.dataset.result = check.result;
533
+ row.append(
534
+ element("span", RESULTS[check.result], "check"),
535
+ element("code", check.command),
536
+ );
537
+ if (check.head) row.append(element("span", ` at ${check.head}`, "reading"));
538
+ list.append(row);
539
+ }
540
+ checks.append(list);
541
+ return checks;
542
+ };
543
+
544
+ // Looks closely at the pictures of a response, from the one given, as the
545
+ // description's are looked at: a pair is compared by its halves.
546
+ const lookAt = (figure) => {
547
+ const all = [
548
+ ...figure.closest("[data-request]").querySelectorAll("figure.evidence"),
549
+ ];
550
+ const shown = (one) => {
551
+ const images = [...one.querySelectorAll("img")];
552
+ const picture = (image) => ({
553
+ src: image.src,
554
+ alt: image.alt,
555
+ name: image.alt,
556
+ });
557
+ return images.length > 1
558
+ ? {
559
+ name: one.dataset.name,
560
+ pair: images.map(picture),
561
+ compare: one.dataset.compare,
562
+ }
563
+ : { ...picture(images[0]), name: one.dataset.name };
564
+ };
565
+ const pictures = all.filter((one) => one.querySelector("img"));
566
+ if (!pictures.includes(figure))
567
+ return toast("That picture is not on this Mac.");
568
+ viewPictures(pictures.map(shown), pictures.indexOf(figure));
569
+ };
570
+
571
+ const mark = (item, resolution, latest, kind, text) => {
572
+ const nodes = visibleResponses();
573
+ const at = nodes.findIndex((node) => node.dataset.request === item.id);
574
+ const next = nodes[at + 1]?.dataset.request || nodes[at - 1]?.dataset.request;
575
+ updateDrafts((state) => {
576
+ const previous = state.feedback?.[item.id];
577
+ state.feedback = {
578
+ ...(state.feedback || {}),
579
+ [item.id]: {
580
+ history: previous
581
+ ? [...(previous.history || []), { ...previous, history: undefined }]
582
+ : [],
583
+ resolution,
584
+ replyTo: latest.key,
585
+ intent: signature(item),
586
+ kind,
587
+ text,
588
+ at: new Date().toISOString(),
589
+ },
590
+ };
591
+ });
592
+ selectResponse(
593
+ visibleResponses().find((node) => node.dataset.request === next) ||
594
+ visibleResponses()[0],
595
+ );
596
+ if (!visibleResponses().length && onPR) {
597
+ document.querySelector("[data-hand-over]").focus();
598
+ announce(
599
+ "Returned work reviewed. Confirmed requests stay closed; corrections join the next handover.",
600
+ );
601
+ }
602
+ };
603
+
604
+ const editor = (slot, label, save, chooseKind = false) => {
605
+ slot.replaceChildren();
606
+ const form = element("form", undefined, "choice-editor");
607
+ const name = element("label", label);
608
+ const textarea = element("textarea");
609
+ textarea.id = `reply-${crypto.randomUUID()}`;
610
+ name.htmlFor = textarea.id;
611
+ textarea.rows = 3;
612
+ textarea.required = true;
613
+ const submit = element("button", "Save reply", "primary");
614
+ submit.type = "submit";
615
+ const help = element(
616
+ "p",
617
+ "Ctrl/⌘ + Enter saves. Escape closes the editor.",
618
+ "reading",
619
+ );
620
+ const kind = element("select");
621
+ if (chooseKind) {
622
+ kind.id = `kind-${crypto.randomUUID()}`;
623
+ const kindLabel = element("label", "Kind of request");
624
+ kindLabel.htmlFor = kind.id;
625
+ for (const value of ["question", "change", "later"]) {
626
+ const option = element("option", labels[value]);
627
+ option.value = value;
628
+ kind.append(option);
629
+ }
630
+ form.append(kindLabel, kind);
631
+ }
632
+ form.append(
633
+ name,
634
+ textarea,
635
+ help,
636
+ submit,
637
+ button("Cancel", () => {
638
+ slot.replaceChildren();
639
+ slot.closest("[data-request]")?.focus();
640
+ }),
641
+ );
642
+ form.addEventListener("submit", (event) => {
643
+ event.preventDefault();
644
+ if (!textarea.value.trim()) return;
645
+ save(textarea.value.trim(), kind.value || "question");
646
+ });
647
+ form.addEventListener("keydown", (event) => {
648
+ if (keyOf(event) === "Enter" && (event.metaKey || event.ctrlKey)) {
649
+ event.preventDefault();
650
+ form.requestSubmit();
651
+ }
652
+ if (keyOf(event) === "Escape") {
653
+ event.preventDefault();
654
+ slot.replaceChildren();
655
+ slot.closest("[data-request]")?.focus();
656
+ }
657
+ });
658
+ slot.append(form);
659
+ textarea.focus();
660
+ };
661
+
662
+ const renderResult = (item) => {
663
+ const latest = latestFor(item);
664
+ const state = statusOf(item);
665
+ const at = (part) => `${item.id} ${part}`;
666
+ const article = element("article", undefined, "round-result");
667
+ article.dataset.request = item.id;
668
+ article.dataset.reviewState = state;
669
+ // Elsewhere the reading rests on a response whole.
670
+ if (!onPR) article.tabIndex = -1;
671
+ const title =
672
+ onPR && item.source.decision?.lead
673
+ ? item.source.decision.lead
674
+ : `${item.pr && !onPR ? `#${item.pr} · ` : ""}${labels[item.kind] || item.kind}`;
675
+ article.append(stop(element("h3", title), at("title")));
676
+ article.append(stop(element("p", item.text, "request-text"), at("request")));
677
+ const feedback = feedbackFor(item.id);
678
+ const ownerReply =
679
+ feedback?.resolution === "reopened" ? feedback : latest?.request.ownerReply;
680
+ if (ownerReply?.text)
681
+ article.append(
682
+ stop(
683
+ element("p", `Your reply: ${ownerReply.text}`, "request-text"),
684
+ at("reply"),
685
+ ),
686
+ );
687
+ if (item.source.decision?.text) {
688
+ const original = fold(at("reviewed"), "What you reviewed");
689
+ original.append(
690
+ said(
691
+ item.source.decision.text.replace(/<!--.*?-->/g, ""),
692
+ at("reviewed words"),
693
+ ),
694
+ );
695
+ article.append(original);
696
+ }
697
+ if (item.source.kind === "code") {
698
+ const anchor = item.source.anchor;
699
+ article.append(
700
+ stop(
701
+ element(
702
+ "p",
703
+ `${anchor.path}${anchor.kind === "line" ? `:${anchor.startLine}-${anchor.endLine}` : " (whole file)"}`,
704
+ "reading",
705
+ ),
706
+ at("place"),
707
+ ),
708
+ );
709
+ if (anchor.quoted)
710
+ article.append(
711
+ stop(element("pre", anchor.quoted, "quoted-code"), at("quoted")),
712
+ );
713
+ }
714
+ const stateText =
715
+ item.kind === "leave"
716
+ ? "Left as is"
717
+ : state === "closed"
718
+ ? latest?.result.status === "deferred"
719
+ ? "Confirmed · filed for later"
720
+ : "Confirmed"
721
+ : state === "agent"
722
+ ? "For the next agent handover"
723
+ : state === "out"
724
+ ? `With an agent since ${handedOverAt(carrier(item))} · no answer yet`
725
+ : state === "question"
726
+ ? "Your answer is needed"
727
+ : "Answered · yours to confirm";
728
+ article.append(stop(element("p", stateText, "response-state"), at("state")));
729
+ if (item.source.needsReview)
730
+ article.append(
731
+ stop(
732
+ element(
733
+ "p",
734
+ "This decision's wording changed. Revisit the decision before continuing this request.",
735
+ "response-warning",
736
+ ),
737
+ at("wording"),
738
+ ),
739
+ );
740
+ if (!latest) return article;
741
+ const { result } = latest;
742
+ const reviewedHead =
743
+ latest.request.source.reviewedHead ||
744
+ latest.request.source.decision?.head ||
745
+ latest.request.source.head ||
746
+ latest.batch.heads?.[item.pr]?.head;
747
+ if (item.pr && result.head) {
748
+ const openCode = (label, base, primary = false) => {
749
+ const form = element("form");
750
+ form.method = "post";
751
+ form.action = `/bursts/${encodeURIComponent(burst)}/pull_requests/${item.pr}/code_review`;
752
+ form.target = "_blank";
753
+ if (primary) form.dataset.returnedCode = "";
754
+ for (const [name, value] of [
755
+ [
756
+ "authenticity_token",
757
+ document.querySelector('meta[name="csrf-token"]').content,
758
+ ],
759
+ ["head", result.head],
760
+ ["base", base],
761
+ ]) {
762
+ const input = element("input");
763
+ input.type = "hidden";
764
+ input.name = name;
765
+ input.value = value;
766
+ form.append(input);
767
+ }
768
+ const open = element("button", label, "quiet");
769
+ open.type = "submit";
770
+ if (primary) open.append(" ", element("kbd", "d"));
771
+ form.append(open);
772
+ article.append(form);
773
+ };
774
+ if (reviewedHead && reviewedHead !== result.head)
775
+ openCode("Review changes", reviewedHead, true);
776
+ else
777
+ article.append(
778
+ stop(
779
+ element(
780
+ "p",
781
+ reviewedHead === result.head
782
+ ? "No code changes since your reviewed head."
783
+ : "Earlier reviewed head unavailable; use the whole PR view.",
784
+ "reading",
785
+ ),
786
+ at("code"),
787
+ ),
788
+ );
789
+ openCode("Whole PR against main", "main", !reviewedHead);
790
+ }
791
+ article.append(said(result.summary, at("summary"), "response-summary"));
792
+ if (result.details.trim())
793
+ article.append(said(result.details, at("details")));
794
+ const earlier = (draftsState().responses || []).filter(
795
+ (report) =>
796
+ versionOf(report) !== versionOf(latest.report) &&
797
+ report.results.some((result) => result.id === item.id),
798
+ );
799
+ if (earlier.length || feedback?.history?.some((reply) => reply.text)) {
800
+ const history = fold(at("earlier"), "Earlier replies and responses");
801
+ (feedback?.history || []).forEach((reply, index) => {
802
+ if (reply.text)
803
+ history.append(
804
+ stop(
805
+ element("p", `You: ${reply.text}`, "request-text"),
806
+ at(`earlier reply ${index}`),
807
+ ),
808
+ );
809
+ });
810
+ for (const report of earlier) {
811
+ const answer = report.results.find((result) => result.id === item.id);
812
+ history.append(
813
+ said(
814
+ answer.summary,
815
+ at(`earlier ${versionOf(report)}`),
816
+ "response-summary",
817
+ ),
818
+ said(answer.details, at(`earlier ${versionOf(report)} details`)),
819
+ );
820
+ }
821
+ article.append(history);
822
+ }
823
+ if (result.head && item.pr && context.heads[item.pr]?.head !== result.head)
824
+ article.append(
825
+ stop(
826
+ element(
827
+ "p",
828
+ "The PR head differs from this response's verification. Your settled intent stays settled; verification must be checked on the current head before merge.",
829
+ "response-warning",
830
+ ),
831
+ at("head"),
832
+ ),
833
+ );
834
+ if (result.question) {
835
+ article.append(
836
+ said(result.question.prompt, at("question"), "owner-question"),
837
+ said(
838
+ `**Recommendation:** ${result.question.recommendation}`,
839
+ at("recommendation"),
840
+ ),
841
+ stop(
842
+ element(
843
+ "p",
844
+ result.question.blocking
845
+ ? "Blocks this request"
846
+ : "Does not block this request",
847
+ "reading",
848
+ ),
849
+ at("blocking"),
850
+ ),
851
+ );
852
+ if (Array.isArray(result.question.options))
853
+ article.append(
854
+ stop(
855
+ element(
856
+ "p",
857
+ `Options: ${result.question.options.join(" · ")}`,
858
+ "response-prose",
859
+ ),
860
+ at("options"),
861
+ ),
862
+ );
863
+ }
864
+ if (result.backlog)
865
+ article.append(
866
+ stop(
867
+ element(
868
+ "p",
869
+ `Backlog: ${typeof result.backlog === "string" ? result.backlog : JSON.stringify(result.backlog)}`,
870
+ "response-prose",
871
+ ),
872
+ at("backlog"),
873
+ ),
874
+ );
875
+ if (result.evidence.length) article.append(evidenceOf(result, at));
876
+ if (result.verification.length) article.append(verificationOf(result, at));
877
+ const actions = element("div", undefined, "actions");
878
+ const slot = element("div");
879
+ if (state === "archived") return article;
880
+ if (
881
+ state !== "closed" &&
882
+ result.status !== "needs_owner" &&
883
+ result.status !== "blocked"
884
+ ) {
885
+ const confirm = button("Confirm and close ", () =>
886
+ mark(item, "confirmed", latest),
887
+ );
888
+ confirm.append(element("kbd", "a"));
889
+ confirm.dataset.responseAction = "a";
890
+ actions.append(confirm);
891
+ }
892
+ const reply = (kind, label) =>
893
+ editor(slot, label, (text) => mark(item, "reopened", latest, kind, text));
894
+ for (const [key, label, kind, prompt] of [
895
+ [
896
+ "q",
897
+ result.status === "needs_owner"
898
+ ? "Answer this question"
899
+ : "Ask a follow-up",
900
+ "question",
901
+ result.status === "needs_owner"
902
+ ? "Your answer to the agent"
903
+ : "Your follow-up question",
904
+ ],
905
+ ["c", "Request a correction", "change", "What still needs changing?"],
906
+ [
907
+ "l",
908
+ "Leave for later",
909
+ "later",
910
+ "What should be recorded as a backlog task?",
911
+ ],
912
+ ]) {
913
+ const action = button(`${label} `, () => reply(kind, prompt));
914
+ action.append(element("kbd", key));
915
+ action.dataset.responseAction = key;
916
+ actions.append(action);
917
+ }
918
+ if (state === "closed")
919
+ actions.append(
920
+ button("Reopen", () =>
921
+ reply("change", "Why does this request need another pass?"),
922
+ ),
923
+ );
924
+ article.append(actions, slot);
925
+ return article;
926
+ };
927
+
928
+ const render = () => {
929
+ const focused =
930
+ document.activeElement.closest("[data-request]")?.dataset.request;
931
+ const place = document.activeElement.closest(
932
+ "[data-round-results] [data-part]",
933
+ )?.dataset.part;
934
+ const items = allItems().filter((item) => !onPR || item.pr === localPR);
935
+ const counts = {
936
+ agent: 0,
937
+ out: 0,
938
+ owner: 0,
939
+ question: 0,
940
+ closed: 0,
941
+ archived: 0,
942
+ };
943
+ for (const item of items) counts[statusOf(item)]++;
944
+ const questions = `${counts.question} ${counts.question === 1 ? "question" : "questions"} for you`;
945
+ tools.querySelector("[data-round-summary]").textContent =
946
+ `${counts.agent} for the agent · ${counts.out} with an agent · ${counts.owner} to confirm · ${questions} · ${counts.closed} closed${counts.archived ? ` · ${counts.archived} archived` : ""}`;
947
+ // Beside the progress, only what asks something of someone.
948
+ const asks = [
949
+ [counts.question, questions],
950
+ [counts.owner, `${counts.owner} returned, to confirm`],
951
+ [counts.agent, `${counts.agent} for the agent's next handover`],
952
+ [counts.out, `${counts.out} with an agent`],
953
+ ]
954
+ .filter(([count]) => count)
955
+ .map(([, text]) => text);
956
+ const roundLine = tools.querySelector("[data-round-line]");
957
+ roundLine.textContent = asks.join(" · ");
958
+ roundLine.hidden = !asks.length;
959
+ const container = document.querySelector("[data-round-results]");
960
+ if (!container) return;
961
+ container.id = "responses";
962
+ // On a pull request's page it shows once something came back or was kept
963
+ // from earlier wording: a decision left as is has nothing to return.
964
+ container.hidden =
965
+ !!onPR &&
966
+ !items.some(
967
+ (item) => latestFor(item) || ["archived", "out"].includes(statusOf(item)),
968
+ );
969
+ container.replaceChildren(stop(element("h2", "Returned work"), "returned"));
970
+ // What asks nothing of the owner folds away here, so that what does comes first.
971
+ const background = fold("background", "Background");
972
+ background.className = "round-background";
973
+ container.append(
974
+ stop(
975
+ element(
976
+ "p",
977
+ asks.length
978
+ ? `Waiting: ${asks.join(" · ")}`
979
+ : "Nothing is waiting. Press e to hand over.",
980
+ "round-status",
981
+ ),
982
+ "status",
983
+ ),
984
+ );
985
+ const waiting = items.some((item) =>
986
+ ["owner", "question"].includes(statusOf(item)),
987
+ );
988
+ const entry = document.querySelector("[data-enter-responses]");
989
+ if (entry) entry.hidden = !waiting;
990
+ const newest = draftsState().responses?.at(-1);
991
+ if (newest && !onPR)
992
+ container.append(element("p", newest.summary, "response-prose"));
993
+ if (!onPR)
994
+ container.append(
995
+ element(
996
+ "p",
997
+ "Confirm an answer to close its request. Reply on the same request to continue it. Closed requests stay out of the next handover. This does not approve merging or release.",
998
+ "response-prose",
999
+ ),
1000
+ );
1001
+ const responses = items.filter(
1002
+ (item) =>
1003
+ latestFor(item) || ["closed", "archived", "out"].includes(statusOf(item)),
1004
+ );
1005
+ if (onPR) {
1006
+ // The desk keeps each description as a handover found it; a batch
1007
+ // from before it did carries its own.
1008
+ const handedOver = (batch) =>
1009
+ context.descriptions?.handedOver[batch.id] ??
1010
+ batch.heads?.[localPR]?.descriptionText;
1011
+ const batch = batches().findLast(
1012
+ (batch) =>
1013
+ handedOver(batch) !== undefined &&
1014
+ responses.some((item) => latestFor(item)?.batch.id === batch.id),
1015
+ );
1016
+ if (batch) {
1017
+ const paragraphs = (text) =>
1018
+ text
1019
+ .replace(/<!-- proctolog:decision [\w.-]+ -->/g, "")
1020
+ .replace(/\r\n/g, "\n")
1021
+ .split(/\n[ \t]*\n/)
1022
+ .map((text) => text.trim())
1023
+ .filter(Boolean);
1024
+ const before = paragraphs(handedOver(batch));
1025
+ const after = paragraphs(context.descriptions.now);
1026
+ const added = after.filter((text) => !before.includes(text));
1027
+ const changes = fold(
1028
+ "changes",
1029
+ "Description changes since this handover",
1030
+ );
1031
+ changes.dataset.passChanges = "";
1032
+ if (!added.length)
1033
+ changes.append(
1034
+ stop(
1035
+ element(
1036
+ "p",
1037
+ "No new or rewritten paragraphs on GitHub since this handover.",
1038
+ "reading",
1039
+ ),
1040
+ "changes none",
1041
+ ),
1042
+ );
1043
+ for (const [index, text] of added.entries())
1044
+ changes.append(said(text, `change ${index}`));
1045
+ // Drawn once it is opened: what it says is the description's, not the agent's.
1046
+ changes.addEventListener("toggle", () => drawPieces(changes));
1047
+ background.append(changes);
1048
+ }
1049
+ }
1050
+ const lostDrafts = draftsState().pending.filter(
1051
+ (entry) =>
1052
+ entry.decision.id.startsWith("missing:") &&
1053
+ (!onPR || entry.decision.pr === localPR),
1054
+ );
1055
+ if (lostDrafts.length) {
1056
+ container.hidden = false;
1057
+ const kept = fold("lost", "Unfinished drafts kept from earlier wording");
1058
+ for (const entry of lostDrafts) {
1059
+ kept.append(
1060
+ stop(
1061
+ element("p", `#${entry.decision.pr} · ${entry.decision.lead}`),
1062
+ `lost ${entry.requestId}`,
1063
+ ),
1064
+ stop(
1065
+ element("p", entry.text, "response-prose"),
1066
+ `lost ${entry.requestId} words`,
1067
+ ),
1068
+ );
1069
+ const recover = button("Recover as a request", () =>
1070
+ updateDrafts((state) => {
1071
+ state.pending = state.pending.filter(
1072
+ (item) => item.requestId !== entry.requestId,
1073
+ );
1074
+ state.requests = [
1075
+ ...(state.requests || []),
1076
+ {
1077
+ id: `request:${crypto.randomUUID()}`,
1078
+ pr: Object.hasOwn(context.heads, entry.decision.pr)
1079
+ ? entry.decision.pr
1080
+ : null,
1081
+ kind: entry.kind,
1082
+ text: entry.text,
1083
+ source: {
1084
+ kind: "recovered_draft",
1085
+ formerDecision: entry.decision,
1086
+ },
1087
+ },
1088
+ ];
1089
+ }),
1090
+ );
1091
+ recover.disabled = !entry.text.trim();
1092
+ kept.append(recover);
1093
+ }
1094
+ background.append(kept);
1095
+ }
1096
+ if (!responses.length)
1097
+ container.append(
1098
+ element(
1099
+ "p",
1100
+ "Hand over with e, then refresh once the agent writes its answer to the response folder, or import that answer.",
1101
+ ),
1102
+ );
1103
+ const asksOfOwner = ["question", "owner", "agent"];
1104
+ for (const state of [
1105
+ "question",
1106
+ "owner",
1107
+ "agent",
1108
+ "out",
1109
+ "closed",
1110
+ "archived",
1111
+ ]) {
1112
+ const group = responses.filter((item) => statusOf(item) === state);
1113
+ if (!group.length) continue;
1114
+ const heading = {
1115
+ question: "Questions for you",
1116
+ owner: "Confirm or push back",
1117
+ agent: "Reopened for the next pass",
1118
+ out: "With an agent",
1119
+ closed: "Closed requests",
1120
+ archived: "Earlier review context",
1121
+ }[state];
1122
+ const section = ["closed", "archived"].includes(state)
1123
+ ? fold(`group ${state}`, heading)
1124
+ : element("section");
1125
+ if (section.localName === "section")
1126
+ section.append(stop(element("h3", heading), `group ${state}`));
1127
+ // What an agent took stays out of the next pass until it answers; one
1128
+ // that never will is let go of here.
1129
+ if (state === "out") {
1130
+ section.append(
1131
+ stop(
1132
+ element(
1133
+ "p",
1134
+ "An agent took these and has not answered. The next handover leaves them out, so that two agents never do the same work.",
1135
+ "response-prose",
1136
+ ),
1137
+ "group out said",
1138
+ ),
1139
+ button("Hand them over again", () => handOver({ again: true })),
1140
+ );
1141
+ }
1142
+ for (const item of group) section.append(renderResult(item));
1143
+ (asksOfOwner.includes(state) ? container : background).append(section);
1144
+ }
1145
+ if (background.children.length > 1) container.append(background);
1146
+ const followups = [
1147
+ ...new Set(
1148
+ (draftsState().responses || []).flatMap(
1149
+ (report) => report.followups || [],
1150
+ ),
1151
+ ),
1152
+ ];
1153
+ if (followups.length && !onPR) {
1154
+ const section = element("details");
1155
+ section.append(
1156
+ element("summary", "Suggested follow-ups · outside this review"),
1157
+ );
1158
+ section.append(
1159
+ element(
1160
+ "p",
1161
+ "These do not add requests or hold up this review. Add one explicitly only if you want to take it on.",
1162
+ ),
1163
+ );
1164
+ const list = element("ul");
1165
+ for (const text of followups) list.append(element("li", text));
1166
+ section.append(list);
1167
+ container.append(section);
1168
+ }
1169
+ const addSlot = element("div");
1170
+ container.append(
1171
+ button("Ask or request something else", () =>
1172
+ editor(
1173
+ addSlot,
1174
+ "A new request outside the existing PR items",
1175
+ (text, kind) => {
1176
+ updateDrafts((state) => {
1177
+ state.requests = [
1178
+ ...(state.requests || []),
1179
+ {
1180
+ id: `request:${crypto.randomUUID()}`,
1181
+ pr: localPR,
1182
+ kind,
1183
+ text,
1184
+ source: { kind: "discussion" },
1185
+ },
1186
+ ];
1187
+ });
1188
+ },
1189
+ true,
1190
+ ),
1191
+ ),
1192
+ addSlot,
1193
+ );
1194
+ drawPieces(container);
1195
+ // The reading stays where it was, whatever moved around it.
1196
+ const again =
1197
+ place && container.querySelector(`[data-part="${CSS.escape(place)}"]`);
1198
+ if (onPR && again?.getClientRects().length)
1199
+ again.focus({ preventScroll: true });
1200
+ else if (focused)
1201
+ selectResponse(
1202
+ visibleResponses().find((node) => node.dataset.request === focused),
1203
+ );
1204
+ };
1205
+
1206
+ // A batch the desk wrote, of a pass `e` or an agent took, becomes this
1207
+ // page's as well, so that the answer to it is known when it comes back.
1208
+ const importServerBatches = () => {
1209
+ const known = new Set(batches().map((batch) => batch.id));
1210
+ const fresh = (context.batches || []).filter((batch) => !known.has(batch.id));
1211
+ if (fresh.length)
1212
+ updateDrafts((state) => {
1213
+ state.batches = [...(state.batches || []), ...fresh];
1214
+ });
1215
+ };
1216
+
1217
+ // What an answer the desk could not take in is wrong with it, said once in
1218
+ // this browser rather than at every page: m shows it again.
1219
+ const SAID = `proctolog:answers-said:${repository}:${burst}`;
1220
+ const unsaid = (failures) => {
1221
+ try {
1222
+ const said = JSON.parse(localStorage.getItem(SAID) ?? "[]");
1223
+ localStorage.setItem(
1224
+ SAID,
1225
+ JSON.stringify([...new Set([...said, ...failures])].slice(-50)),
1226
+ );
1227
+ return failures.filter((failure) => !said.includes(failure));
1228
+ } catch {
1229
+ return failures;
1230
+ }
1231
+ };
1232
+ const importServerResponses = () => {
1233
+ const failures = [...context.errors];
1234
+ const said = [];
1235
+ let taken = 0;
1236
+ for (const report of context.reports) {
1237
+ try {
1238
+ const outcome = acceptResponse(report);
1239
+ if (outcome === "new") taken += 1;
1240
+ if (outcome === "rewritten")
1241
+ said.push(
1242
+ `${answerTo(report)} was written again on ${dayAndTime(report.createdAt)}. The desk shows the new version; the one before is under “Earlier replies and responses”.`,
1243
+ );
1244
+ } catch (error) {
1245
+ failures.push(error.message);
1246
+ }
1247
+ }
1248
+ if (taken)
1249
+ said.unshift(
1250
+ `${taken === 1 ? "An agent's answer came back" : `${taken} agents' answers came back`}: v on a pull request's page reads it, to confirm or reply.`,
1251
+ );
1252
+ said.push(...unsaid(failures));
1253
+ if (said.length) announce(said.join("\n"));
1254
+ render();
1255
+ };
1256
+
1257
+ registerRounds({
1258
+ isActivePR: (pr) => Object.hasOwn(context.heads, pr),
1259
+ isChoiceClosed: (entry) => {
1260
+ const item = {
1261
+ id:
1262
+ entry.requestId ||
1263
+ `choice:${entry.decision.id}:${entry.savedAt || "legacy"}`,
1264
+ kind: entry.kind,
1265
+ text: entry.text || entry.decision.lead,
1266
+ };
1267
+ const feedback = feedbackFor(item.id);
1268
+ return (
1269
+ feedback?.resolution === "confirmed" &&
1270
+ feedback.intent === signature(item) &&
1271
+ latestFor(item)?.key === feedback.replyTo
1272
+ );
1273
+ },
1274
+ refresh: async () => {
1275
+ context = await reviewData();
1276
+ importServerBatches();
1277
+ importServerResponses();
1278
+ },
1279
+ });
1280
+
1281
+ tools
1282
+ .querySelector("[data-import-response]")
1283
+ .addEventListener("change", async (event) => {
1284
+ try {
1285
+ const file = event.target.files[0];
1286
+ if (!file) return;
1287
+ const outcome = acceptResponse(JSON.parse(await file.text()));
1288
+ announce(
1289
+ outcome
1290
+ ? "Agent response imported. Confirm or reply to its existing requests."
1291
+ : "The desk has this answer already.",
1292
+ );
1293
+ } catch (error) {
1294
+ announce(`Response could not be imported: ${error.message}`);
1295
+ } finally {
1296
+ event.target.value = "";
1297
+ }
1298
+ });
1299
+
1300
+ tools
1301
+ .querySelector("[data-refresh-review]")
1302
+ .addEventListener("click", async (event) => {
1303
+ const button = event.target;
1304
+ button.disabled = true;
1305
+ try {
1306
+ context = await reviewData();
1307
+ importServerBatches();
1308
+ importServerResponses();
1309
+ } catch (error) {
1310
+ announce(
1311
+ `Comments and responses could not be refreshed: ${error.message}`,
1312
+ );
1313
+ } finally {
1314
+ button.disabled = false;
1315
+ }
1316
+ });
1317
+
1318
+ document.addEventListener("drafts-changed", render);
1319
+ let activeResponse;
1320
+ const visibleResponses = () =>
1321
+ [...document.querySelectorAll("[data-round-results] [data-request]")].filter(
1322
+ (node) => !node.closest("details:not([open])"),
1323
+ );
1324
+ // The response the reading is in is marked whole, wherever in it the reading rests.
1325
+ const markResponse = (node) => {
1326
+ activeResponse?.removeAttribute("aria-current");
1327
+ node?.setAttribute("aria-current", "true");
1328
+ if (node) activeResponse = node;
1329
+ };
1330
+ // Onto a response: on a pull request's page the reading rests on its title,
1331
+ // elsewhere on the response whole.
1332
+ const selectResponse = (node) => {
1333
+ if (!node) return markResponse(undefined);
1334
+ const target = onPR ? node.querySelector("[data-reading]") : node;
1335
+ target.focus({ preventScroll: true });
1336
+ target.scrollIntoView({ block: "nearest" });
1337
+ markResponse(node);
1338
+ };
1339
+ // Where v was pressed, for Esc to go back to.
1340
+ let cameFrom;
1341
+ const enterResponses = () => {
1342
+ // A page that shows one decision alone shows all of itself again first.
1343
+ delete document.body.dataset.focus;
1344
+ const here = document.activeElement.closest?.("[data-reading]");
1345
+ if (here && !here.closest("[data-round-results]")) cameFrom = here;
1346
+ selectResponse(
1347
+ visibleResponses().find((node) =>
1348
+ ["owner", "question"].includes(node.dataset.reviewState),
1349
+ ) || visibleResponses()[0],
1350
+ );
1351
+ };
1352
+ const leaveResponses = () => {
1353
+ const back =
1354
+ cameFrom?.isConnected && cameFrom.getClientRects().length
1355
+ ? cameFrom
1356
+ : document.querySelector("main.description details.section > summary");
1357
+ back?.focus({ preventScroll: true });
1358
+ back?.scrollIntoView({ block: "nearest" });
1359
+ };
1360
+ document
1361
+ .querySelector("[data-enter-responses]")
1362
+ ?.addEventListener("click", enterResponses);
1363
+ document.addEventListener("focusin", (event) => {
1364
+ // A picture looked at closely leaves the response marked: Esc comes back to it.
1365
+ if (event.target.closest("dialog")) return;
1366
+ const node = event.target.closest("[data-request]");
1367
+ if (node) markResponse(node);
1368
+ else activeResponse?.removeAttribute("aria-current");
1369
+ });
1370
+ // A picture an answer rests on opens at a click, as the description's do.
1371
+ document.addEventListener("click", (event) => {
1372
+ const figure = event.target.closest?.("[data-round-results] figure.evidence");
1373
+ if (!figure || event.target.localName !== "img") return;
1374
+ figure.focus({ preventScroll: true });
1375
+ lookAt(figure);
1376
+ });
1377
+ importServerBatches();
1378
+ importServerResponses();
1379
+ document.addEventListener("keydown", (event) => {
1380
+ if (
1381
+ !document.querySelector("[data-round-results]") ||
1382
+ event.isComposing ||
1383
+ event.metaKey ||
1384
+ event.ctrlKey ||
1385
+ event.altKey ||
1386
+ event.target.closest("textarea, input, select, [contenteditable]") ||
1387
+ helping()
1388
+ )
1389
+ return;
1390
+ const key = keyOf(event);
1391
+ if (onPR && key === "v") {
1392
+ event.preventDefault();
1393
+ if (visibleResponses().length) enterResponses();
1394
+ else
1395
+ toast(
1396
+ "No returned work waits for you on this pull request. An agent's response to a handover shows here.",
1397
+ );
1398
+ return;
1399
+ }
1400
+ // On a pull request's page these keys are the returned work's only there;
1401
+ // the reading moves through it as through the rest of the page (notes.js).
1402
+ if (onPR && !event.target.closest("[data-round-results]")) return;
1403
+ if (onPR && key === "Escape") {
1404
+ event.preventDefault();
1405
+ leaveResponses();
1406
+ return;
1407
+ }
1408
+ if (key === "e") {
1409
+ event.preventDefault();
1410
+ handOver();
1411
+ return;
1412
+ }
1413
+ const figure = event.target.closest("figure.evidence");
1414
+ if (figure && [" ", "Enter"].includes(key)) {
1415
+ event.preventDefault();
1416
+ lookAt(figure);
1417
+ return;
1418
+ }
1419
+ const response = onPR
1420
+ ? event.target.closest("[data-request]")
1421
+ : activeResponse?.isConnected
1422
+ ? activeResponse
1423
+ : visibleResponses()[0];
1424
+ if (response && ["a", "q", "c", "l", "d"].includes(key)) {
1425
+ event.preventDefault();
1426
+ if (!onPR) selectResponse(response);
1427
+ if (key === "d")
1428
+ response.querySelector("form[data-returned-code]")?.requestSubmit();
1429
+ else response.querySelector(`[data-response-action="${key}"]`)?.click();
1430
+ return;
1431
+ }
1432
+ if (onPR) return;
1433
+ const nodes = visibleResponses();
1434
+ if (!nodes.length) return;
1435
+ const at = Math.max(0, nodes.indexOf(activeResponse));
1436
+ const to = {
1437
+ j: at + 1,
1438
+ ArrowDown: at + 1,
1439
+ k: at - 1,
1440
+ ArrowUp: at - 1,
1441
+ Home: 0,
1442
+ End: nodes.length - 1,
1443
+ }[key];
1444
+ if (to === undefined) return;
1445
+ event.preventDefault();
1446
+ selectResponse(nodes[Math.min(Math.max(to, 0), nodes.length - 1)]);
1447
+ });