paperlint 13.1.0 → 13.2.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 (104) hide show
  1. package/CLAUDE.md +9 -1
  2. package/dist/adapters/git/index.d.ts.map +1 -1
  3. package/dist/adapters/git/index.js +13 -0
  4. package/dist/adapters/git/index.js.map +1 -1
  5. package/dist/adapters/study-registry/index.d.ts +142 -1
  6. package/dist/adapters/study-registry/index.d.ts.map +1 -1
  7. package/dist/adapters/study-registry/index.js +160 -1
  8. package/dist/adapters/study-registry/index.js.map +1 -1
  9. package/dist/cli.d.ts.map +1 -1
  10. package/dist/cli.js +3 -0
  11. package/dist/cli.js.map +1 -1
  12. package/dist/domain/decide-first.d.ts +43 -0
  13. package/dist/domain/decide-first.d.ts.map +1 -1
  14. package/dist/domain/decide-first.js +45 -2
  15. package/dist/domain/decide-first.js.map +1 -1
  16. package/dist/domain/study-gates.d.ts +96 -0
  17. package/dist/domain/study-gates.d.ts.map +1 -0
  18. package/dist/domain/study-gates.js +56 -0
  19. package/dist/domain/study-gates.js.map +1 -0
  20. package/dist/domain/study-mode.d.ts +6 -0
  21. package/dist/domain/study-mode.d.ts.map +1 -1
  22. package/dist/domain/study-mode.js.map +1 -1
  23. package/dist/domain/study-registry.d.ts +24 -4
  24. package/dist/domain/study-registry.d.ts.map +1 -1
  25. package/dist/domain/study-registry.js +39 -2
  26. package/dist/domain/study-registry.js.map +1 -1
  27. package/dist/domain/study-result.d.ts +151 -0
  28. package/dist/domain/study-result.d.ts.map +1 -0
  29. package/dist/domain/study-result.js +175 -0
  30. package/dist/domain/study-result.js.map +1 -0
  31. package/dist/outputs-before-decision-rule.d.ts.map +1 -1
  32. package/dist/outputs-before-decision-rule.js +37 -3
  33. package/dist/outputs-before-decision-rule.js.map +1 -1
  34. package/dist/ports/git-history.d.ts +12 -0
  35. package/dist/ports/git-history.d.ts.map +1 -1
  36. package/dist/ports/study-registry-codec.d.ts +3 -0
  37. package/dist/ports/study-registry-codec.d.ts.map +1 -1
  38. package/dist/skills/build-label-page/scripts/label-page-cli.d.ts +6 -0
  39. package/dist/skills/build-label-page/scripts/label-page-commands.d.ts +14 -0
  40. package/dist/skills/build-label-page/scripts/label-page-core.d.ts +149 -0
  41. package/dist/skills/build-label-page/scripts/label-page-html.d.ts +5 -0
  42. package/dist/skills/build-label-page/scripts/label-page.d.ts +2 -0
  43. package/dist/study-modes.d.ts +3 -0
  44. package/dist/study-modes.d.ts.map +1 -1
  45. package/dist/study-modes.js +8 -1
  46. package/dist/study-modes.js.map +1 -1
  47. package/dist/study-registry-rule.d.ts.map +1 -1
  48. package/dist/study-registry-rule.js +1 -0
  49. package/dist/study-registry-rule.js.map +1 -1
  50. package/dist/study-result-rule.d.ts +12 -0
  51. package/dist/study-result-rule.d.ts.map +1 -0
  52. package/dist/study-result-rule.js +87 -0
  53. package/dist/study-result-rule.js.map +1 -0
  54. package/docs/design/study-gates.md +208 -0
  55. package/docs/rules/repro/outputs-before-decision.md +8 -0
  56. package/docs/rules/repro/study-registered.md +38 -15
  57. package/docs/rules/repro/study-result.md +100 -0
  58. package/docs/rules.md +1 -0
  59. package/package.json +2 -1
  60. package/schemas/plan.v2.schema.json +341 -0
  61. package/skills/build-label-page/SKILL.md +85 -54
  62. package/skills/build-label-page/SKILL.md.spec.ts +85 -53
  63. package/skills/build-label-page/build-label-page.test.ts +10 -941
  64. package/skills/build-label-page/references/checklist.md +45 -3
  65. package/skills/build-label-page/references/closed-choice-generator.md +88 -0
  66. package/skills/build-label-page/scripts/label-page-cli.js +72 -0
  67. package/skills/build-label-page/scripts/label-page-cli.ts +85 -0
  68. package/skills/build-label-page/scripts/label-page-commands.js +14 -0
  69. package/skills/build-label-page/scripts/label-page-commands.ts +14 -0
  70. package/skills/build-label-page/scripts/label-page-core.js +850 -0
  71. package/skills/build-label-page/scripts/label-page-core.test.ts +1164 -0
  72. package/skills/build-label-page/scripts/label-page-core.ts +1210 -0
  73. package/skills/build-label-page/scripts/label-page-html.js +33 -0
  74. package/skills/build-label-page/scripts/label-page-html.test.ts +413 -0
  75. package/skills/build-label-page/scripts/label-page-html.ts +43 -0
  76. package/skills/build-label-page/scripts/label-page.js +70 -0
  77. package/skills/build-label-page/scripts/label-page.test.ts +71 -0
  78. package/skills/build-label-page/scripts/label-page.ts +71 -0
  79. package/skills/build-label-page/scripts/page-template.html +513 -0
  80. package/src/adapters/git/index.test.ts +33 -0
  81. package/src/adapters/git/index.ts +19 -0
  82. package/src/adapters/study-registry/gates.test.ts +181 -0
  83. package/src/adapters/study-registry/index.ts +219 -1
  84. package/src/adapters/study-registry/mode.test.ts +6 -0
  85. package/src/cli.ts +3 -0
  86. package/src/domain/decide-first.test.ts +131 -0
  87. package/src/domain/decide-first.ts +92 -2
  88. package/src/domain/study-gates.ts +125 -0
  89. package/src/domain/study-measurements.test.ts +71 -0
  90. package/src/domain/study-mode.ts +6 -0
  91. package/src/domain/study-registry.ts +64 -2
  92. package/src/domain/study-result.test.ts +300 -0
  93. package/src/domain/study-result.ts +358 -0
  94. package/src/outputs-before-decision-rule.test.ts +115 -2
  95. package/src/outputs-before-decision-rule.ts +50 -2
  96. package/src/ports/git-history.ts +12 -0
  97. package/src/ports/study-registry-codec.ts +3 -0
  98. package/src/rule-cases.test.ts +74 -0
  99. package/src/studies.test.ts +3 -0
  100. package/src/study-modes.ts +10 -1
  101. package/src/study-registry-rule.ts +1 -0
  102. package/src/study-result-rule.test.ts +281 -0
  103. package/src/study-result-rule.ts +123 -0
  104. package/skills/build-label-page/references/example-generator.py +0 -803
@@ -0,0 +1,33 @@
1
+ /**
2
+ * The PAGE of build-label-page's generator: the one closed-choice page (`page-template.html`, its
3
+ * style and browser script kept as exact bytes), filled with the core's payload. Another page shape
4
+ * would be another template here, on the same core.
5
+ */
6
+ import { readFileSync, writeFileSync } from "node:fs";
7
+ import { join } from "node:path";
8
+ import { prepareBuild, say } from "./label-page-core.js";
9
+ const TEMPLATE = readFileSync(join(import.meta.dirname, "page-template.html"), "utf8");
10
+ const escapeHtml = (text) => text.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;");
11
+ /** JSON as the page always carried it: `", "` and `": "` between entries, Unicode kept. */
12
+ const compactJson = (v) => {
13
+ if (Array.isArray(v))
14
+ return `[${v.map(compactJson).join(", ")}]`;
15
+ if (typeof v !== "object" || v === null)
16
+ return JSON.stringify(v);
17
+ return `{${Object.entries(v)
18
+ .map(([k, x]) => `${JSON.stringify(k)}: ${compactJson(x)}`)
19
+ .join(", ")}}`;
20
+ };
21
+ /** The page: the template with its title and its data. */
22
+ export function pageHtml(payload) {
23
+ // `</` inside JSON inside a <script> would end the script early.
24
+ const data = compactJson(payload).replaceAll("</", "<\\/");
25
+ return TEMPLATE.replaceAll("__TITLE__", () => escapeHtml(payload.title)).replaceAll("__DATA__", () => data);
26
+ }
27
+ /** items.json → out/page.html and out/key.json. */
28
+ export function build(configPath, outDir) {
29
+ const { payload, dropped } = prepareBuild(configPath, outDir);
30
+ writeFileSync(join(outDir, "page.html"), pageHtml(payload), "utf8");
31
+ say(`built ${join(outDir, "page.html")}: ${String(payload.items.length)} items, ${String(payload.groups.length)} groups${dropped ? `, ${String(dropped)} left out (labeller does not know their group)` : ""}`);
32
+ say(`wrote ${join(outDir, "key.json")}: the only link from G-ids to source ids; it is NOT part of the page`);
33
+ }
@@ -0,0 +1,413 @@
1
+ /**
2
+ * The generator's PAGE: the built page.html opened against a stand-in store in a minimal DOM, and its
3
+ * style read as written. What the labeller sees and what the page saves.
4
+ */
5
+ import assert from "node:assert/strict";
6
+ import { readFileSync, writeFileSync } from "node:fs";
7
+ import { join } from "node:path";
8
+ import { runInNewContext } from "node:vm";
9
+ import { test } from "vitest";
10
+ import { z } from "zod";
11
+ import {
12
+ APPROVAL_PRACTICE,
13
+ Allowed,
14
+ DONE_PRACTICE,
15
+ Exported,
16
+ ITEMS,
17
+ type KeyFile,
18
+ QUESTIONS,
19
+ approval,
20
+ build,
21
+ config,
22
+ escapeRe,
23
+ occurrences,
24
+ openLive,
25
+ run,
26
+ setup,
27
+ showPage,
28
+ showPageWithoutStore,
29
+ } from "../../../test/label-page-kit.ts";
30
+
31
+ test("the page: neutral ids, the source ids nowhere, and the order is fixed by the seed", () => {
32
+ const dir = setup("order", config());
33
+ const { page, key } = build(dir);
34
+ assert.deepEqual(Object.keys(key.ids).slice(0, 3), ["G001", "G002", "G003"]);
35
+ for (const item of ITEMS) {
36
+ assert.ok(
37
+ !page.includes(item.source_id),
38
+ `${item.source_id} is on the page`,
39
+ );
40
+ }
41
+ const again = build(dir);
42
+ assert.equal(again.page, page, "the same input must give the same page");
43
+ const order = (k: KeyFile): string =>
44
+ Object.values(k.ids)
45
+ .map((v) => v.source_id)
46
+ .join(",");
47
+ const reseeded = build(setup("order-seed", config({ seed: 8 })));
48
+ assert.notEqual(
49
+ order(reseeded.key),
50
+ order(key),
51
+ "the seed must change the order",
52
+ );
53
+ assert.notEqual(
54
+ order(key),
55
+ ITEMS.map((i) => i.source_id).join(","),
56
+ "the order must not be the input order",
57
+ );
58
+ });
59
+
60
+ test("🔴 the page and the export share one definition of a valid answer: a stored value outside the options is unanswered in both", async () => {
61
+ const dir = setup("stale", config());
62
+ const { page, key } = build(dir);
63
+ const [a, b, c] = Object.keys(key.ids);
64
+ assert.ok(a !== undefined && b !== undefined && c !== undefined);
65
+ const answers = {
66
+ [a]: { answers: { label: "yes", boundary: "clear" }, source: "page" },
67
+ // options changed between builds: neither value is offered any more
68
+ [b]: { answers: { label: "yes", boundary: "maybe" }, source: "page" },
69
+ [c]: { answers: { label: "yse", boundary: "clear" }, source: "chat" },
70
+ };
71
+ const groups = {
72
+ python: { familiarity: "somewhatt", source: "chat" },
73
+ go: { familiarity: "well", source: "page" },
74
+ };
75
+ const shown = await showPage(page, answers, groups);
76
+ assert.match(shown, /1 of 12 items complete/, shown);
77
+ assert.match(shown, new RegExp(`${b} needs boundary`), shown);
78
+ assert.match(shown, new RegExp(`${c} needs correct\\?`), shown);
79
+ assert.match(shown, /Python: how well you know it/, shown);
80
+ assert.doesNotMatch(shown, /Go: how well you know it/, shown);
81
+
82
+ writeFileSync(join(dir, "dump.json"), JSON.stringify({ answers, groups }));
83
+ const r = run([
84
+ "export",
85
+ join(dir, "dump.json"),
86
+ join(dir, "out", "key.json"),
87
+ join(dir, "data"),
88
+ "--date",
89
+ "2026-01-02",
90
+ ]);
91
+ assert.equal(r.status, 0, r.out);
92
+ const file = Exported.parse(
93
+ JSON.parse(
94
+ readFileSync(
95
+ join(dir, "data", "demo-gold-labels-2026-01-02.json"),
96
+ "utf8",
97
+ ),
98
+ ),
99
+ );
100
+ assert.equal(
101
+ file.complete_items,
102
+ 1,
103
+ "the export counts what the page counts",
104
+ );
105
+ assert.match(r.out, /invalid group python: familiarity='somewhatt'/, r.out);
106
+ const { python, go } = file.groups;
107
+ assert.ok(python !== undefined && go !== undefined);
108
+ assert.equal(python.familiarity, null);
109
+ assert.equal(python.invalid_familiarity, "somewhatt");
110
+ assert.equal(go.familiarity, "well");
111
+ });
112
+
113
+ test("the page's allowed values are the key's: both are written from the same definition", () => {
114
+ const { page, key } = build(setup("one-source", config()));
115
+ const json =
116
+ /<script type="application\/json" id="data">([\s\S]*?)<\/script>/.exec(
117
+ page,
118
+ )?.[1];
119
+ assert.ok(json !== undefined);
120
+ const data = z
121
+ .object({ allowed: Allowed })
122
+ .parse(JSON.parse(json.replace(/<\\\//g, "</")));
123
+ assert.deepEqual(data.allowed, key.allowed);
124
+ assert.deepEqual(key.allowed.familiarity, ["well", "somewhat", "not_really"]);
125
+ });
126
+
127
+ test("the page script parses, saves on click, and has nothing that needs scrolling or a toast", () => {
128
+ const { page } = build(setup("page", config()));
129
+ const scripts = [...page.matchAll(/<script>([\s\S]*?)<\/script>/g)].map(
130
+ (m) => m[1] ?? "",
131
+ );
132
+ assert.equal(scripts.length, 1);
133
+ assert.doesNotThrow(() =>
134
+ runInNewContext(`(function(){${scripts[0] ?? ""}\n})`),
135
+ );
136
+ assert.match(page, /claude\.use\("db"\)/);
137
+ assert.match(page, /\.set\(/, "an answer is saved by a write");
138
+ assert.doesNotMatch(page, /scrollIntoView|position: *fixed|100vh|alert\(/);
139
+ assert.doesNotMatch(
140
+ page,
141
+ /type="submit"|>Submit</i,
142
+ "there is no submit button",
143
+ );
144
+ assert.match(page, /Next unanswered/);
145
+ assert.match(page, /cannot be saved in this view/);
146
+ });
147
+
148
+ test("🔴 the first screen explains each question with an example per answer, and the real items stay closed until the practice is answered", async () => {
149
+ const { page } = build(setup("first-screen", config()));
150
+ const before = await showPage(page, {}, {}, {});
151
+ assert.match(before, /How to answer/);
152
+ for (const q of QUESTIONS) {
153
+ for (const o of q.options)
154
+ assert.match(before, new RegExp(escapeRe(o.example)));
155
+ }
156
+ assert.match(before, /Practice: 0 of 3 answered/);
157
+ assert.match(before, /sorted\(\[3, 1, 2\]\) gives \[1, 2, 3\]/);
158
+ assert.doesNotMatch(
159
+ before,
160
+ /G001/,
161
+ "a real item is open before the practice",
162
+ );
163
+ assert.doesNotMatch(before, /items complete/);
164
+
165
+ const after = await showPage(page, {}, {});
166
+ assert.match(after, /Practice: 3 of 3 matched/);
167
+ assert.match(after, /G001/);
168
+ assert.match(after, /0 of 12 items complete/);
169
+ });
170
+
171
+ test("🔴 a practice answer shows the expected answer and why; a miss says to tell the agent before going on", async () => {
172
+ const { page } = build(setup("practice-miss", config()));
173
+ const practice = {
174
+ ...DONE_PRACTICE,
175
+ "r1-P2": { answers: { label: "yes", boundary: "clear" }, source: "page" },
176
+ };
177
+ const shown = await showPage(page, {}, {}, practice);
178
+ assert.match(shown, /Practice: 2 of 3 matched/);
179
+ assert.match(
180
+ shown,
181
+ /Expected: No · Clear-cut\. The output is the input unchanged\./,
182
+ );
183
+ assert.match(shown, /tell the agent/);
184
+ assert.match(shown, /G001/, "a miss does not lock the real items");
185
+ });
186
+
187
+ test("🔴 a new practice round reopens the practice: answers of an earlier round do not count", async () => {
188
+ const { page } = build(setup("round-2", config({ practice_round: 2 })));
189
+ const shown = await showPage(page, {}, {});
190
+ assert.match(shown, /Practice: 0 of 3 answered/);
191
+ assert.doesNotMatch(shown, /G001/);
192
+ });
193
+
194
+ test("🔴 one question per screen: an item card asks only its first unanswered question", async () => {
195
+ const { page, key } = build(setup("one-question", config()));
196
+ const second = "Clear-cut or a real boundary case?";
197
+ const none = await showPage(page, {}, {});
198
+ assert.equal(
199
+ occurrences(none, second),
200
+ 1,
201
+ "only the first screen's explanation names it",
202
+ );
203
+ const [a] = Object.keys(key.ids);
204
+ assert.ok(a !== undefined);
205
+ const one = await showPage(
206
+ page,
207
+ { [a]: { answers: { label: "yes" }, source: "page" } },
208
+ {},
209
+ );
210
+ assert.equal(
211
+ occurrences(one, second),
212
+ 2,
213
+ "the item whose first question is answered asks the second",
214
+ );
215
+ assert.match(
216
+ one,
217
+ /correct\?: Yes/,
218
+ "the answered question stays visible as a line",
219
+ );
220
+ });
221
+
222
+ test("🔴 the answer buttons are full-width rows with space between them, and the page has one dark palette", () => {
223
+ const { page } = build(setup("look", config()));
224
+ const css = /<style>([\s\S]*?)<\/style>/.exec(page)?.[1] ?? "";
225
+ assert.match(
226
+ css,
227
+ /\.opts \{ display: flex; flex-direction: column; gap: \d+px; \}/,
228
+ );
229
+ assert.match(css, /button \{[^}]*width: 100%;[^}]*\}/);
230
+ assert.match(
231
+ css,
232
+ /min-height: (4[4-9]|[5-9]\d)px/,
233
+ "a tap target at least 44px tall",
234
+ );
235
+ assert.doesNotMatch(
236
+ css,
237
+ /prefers-color-scheme|data-theme/,
238
+ "one look, not a light and a dark one",
239
+ );
240
+ const colors = new Set(css.match(/#[0-9a-f]{6}\b/gi) ?? []);
241
+ assert.ok(
242
+ colors.size <= 10,
243
+ `every color is a palette token: ${[...colors].join(", ")}`,
244
+ );
245
+ assert.doesNotMatch(page, /class="card"|"card"/, "no card around every item");
246
+ });
247
+
248
+ test("🔴 an answer button carries its icon and label, and no button has a coloured side stripe", () => {
249
+ const [first, ...rest] = QUESTIONS;
250
+ assert.ok(first !== undefined);
251
+ const iconed = {
252
+ ...first,
253
+ options: [
254
+ { ...first.options[0], icon: "✅" },
255
+ { ...first.options[1], icon: "❌" },
256
+ ],
257
+ };
258
+ const { page } = build(
259
+ setup("icons", config({ questions: [iconed, ...rest] })),
260
+ );
261
+ assert.match(page, /"icon": ?"✅"/);
262
+ const css = /<style>([\s\S]*?)<\/style>/.exec(page)?.[1] ?? "";
263
+ const buttonRules = [...css.matchAll(/([^{}]*button[^{}]*)\{([^}]*)\}/g)];
264
+ for (const [, selector, body] of buttonRules) {
265
+ assert.doesNotMatch(
266
+ body ?? "",
267
+ /border-left/,
268
+ `a side stripe on ${selector?.trim() ?? ""}`,
269
+ );
270
+ }
271
+ const toned = setup(
272
+ "toned",
273
+ config({
274
+ questions: [
275
+ {
276
+ ...iconed,
277
+ options: [{ ...iconed.options[0], tone: "good" }, iconed.options[1]],
278
+ },
279
+ ...rest,
280
+ ],
281
+ }),
282
+ );
283
+ const r = run(["build", join(toned, "items.json"), join(toned, "out")]);
284
+ assert.notEqual(r.status, 0, "`tone` is not a field");
285
+ assert.match(r.out, /questions\[0\]\.options\[0\]/);
286
+ });
287
+
288
+ test("🔴 a practice button shows its answer's example, a real item's button does not, and backticks are code", async () => {
289
+ const items = [
290
+ {
291
+ source_id: "c1",
292
+ group: "python",
293
+ body: "Rule says `max-len 100`; reading is `max-len 120`",
294
+ },
295
+ ];
296
+ const { page } = build(setup("code-spans", config({ items })));
297
+ const practising = await showPage(page, {}, {}, {});
298
+ assert.equal(
299
+ occurrences(practising, "Yes\nA sort that returns 1, 2, 3."),
300
+ 3,
301
+ "label then example, on each practice item",
302
+ );
303
+ const shown = await showPage(page, {}, {});
304
+ assert.equal(
305
+ occurrences(shown, "Yes\nA sort that returns 1, 2, 3."),
306
+ 0,
307
+ "the real item's buttons are short",
308
+ );
309
+ assert.match(
310
+ shown,
311
+ /Rule says \nmax-len 100\n; reading is \nmax-len 120/,
312
+ "backticks become code spans",
313
+ );
314
+ assert.doesNotMatch(shown, /`/);
315
+ });
316
+
317
+ test("🔴 a code block shows its whitespace and each line's length: indentation is seen, not counted", async () => {
318
+ const items = [
319
+ {
320
+ source_id: "c1",
321
+ group: "python",
322
+ body: "Indented with four spaces?",
323
+ code: "def f():\n return 1\n\tpass ",
324
+ },
325
+ ];
326
+ const { page } = build(setup("whitespace", config({ items })));
327
+ const shown = await showPage(page, {}, {});
328
+ assert.match(
329
+ shown,
330
+ /····\nreturn 1\n12/,
331
+ "four visible spaces, the text, the line's length",
332
+ );
333
+ assert.match(shown, /→\npass\n··\n7/, "a visible tab and trailing spaces");
334
+ assert.match(shown, /def f\(\):\n8/);
335
+ });
336
+
337
+ test("🔴 approval: a drop is not done until it says why; the page asks for the reason under the chosen answer", async () => {
338
+ const { page, key } = build(setup("approval-page", approval()));
339
+ const [a, b, c] = Object.keys(key.ids);
340
+ assert.ok(a !== undefined && b !== undefined && c !== undefined);
341
+ const practice = Object.fromEntries(
342
+ APPROVAL_PRACTICE.map((p, n) => [
343
+ `r1-P${String(n + 1)}`,
344
+ { answers: p.expected, source: "page" },
345
+ ]),
346
+ );
347
+ const answers = {
348
+ [a]: { answers: { keep: "keep" }, source: "page" },
349
+ [b]: {
350
+ answers: { keep: "drop" },
351
+ reasons: { keep: "a process rule" },
352
+ source: "page",
353
+ },
354
+ [c]: { answers: { keep: "drop" }, source: "page" },
355
+ };
356
+ const shown = await showPage(page, answers, {}, practice);
357
+ assert.match(shown, /2 of 12 items complete/, shown);
358
+ assert.match(shown, new RegExp(`${c} needs keep\\? \\(why\\)`), shown);
359
+ assert.match(shown, /Why drop it\?/, "the reason prompt is on the page");
360
+ });
361
+
362
+ test("🔴 approval: a reason typed but not yet saved survives a re-render from the store", async () => {
363
+ const { page, key } = build(setup("approval-draft", approval()));
364
+ const [a] = Object.keys(key.ids);
365
+ assert.ok(a !== undefined);
366
+ const practice = Object.fromEntries(
367
+ APPROVAL_PRACTICE.map((p, n) => [
368
+ `r1-P${String(n + 1)}`,
369
+ { answers: p.expected, source: "page" },
370
+ ]),
371
+ );
372
+ const docs: Record<string, Record<string, unknown>> = {
373
+ answers: { [a]: { answers: { keep: "drop" }, source: "page" } },
374
+ groups: {},
375
+ practice,
376
+ };
377
+ const { app, resend } = await openLive(page, docs);
378
+ const box = () => app.all().find((n) => n.tag === "textarea");
379
+ const typing = box();
380
+ assert.ok(typing !== undefined, "the drop asks for its reason");
381
+ typing.value = "a process rule, not";
382
+ typing.fire("input");
383
+ resend();
384
+ assert.notEqual(box(), typing, "the store's snapshot rebuilt the page");
385
+ assert.equal(box()?.value, "a process rule, not");
386
+ // Once the store holds the typed text the draft is done: a later stored reason shows, not it.
387
+ const now = box();
388
+ assert.ok(now !== undefined);
389
+ now.value = "a process rule";
390
+ now.fire("input");
391
+ const stored = (reason: string) => {
392
+ docs.answers = {
393
+ [a]: {
394
+ answers: { keep: "drop" },
395
+ reasons: { keep: reason },
396
+ source: "page",
397
+ },
398
+ };
399
+ resend();
400
+ return box()?.value;
401
+ };
402
+ assert.deepEqual(
403
+ [stored("a process rule"), stored("edited on another phone")],
404
+ ["a process rule", "edited on another phone"],
405
+ );
406
+ });
407
+
408
+ test("🔴 without the store the page is read-only, and the real items stay readable even though no practice can be answered", async () => {
409
+ const { page } = build(setup("read-only", config()));
410
+ const shown = await showPageWithoutStore(page);
411
+ assert.match(shown, /cannot be saved in this view/);
412
+ assert.match(shown, /G001/, "a real item is shown read-only");
413
+ });
@@ -0,0 +1,43 @@
1
+ /**
2
+ * The PAGE of build-label-page's generator: the one closed-choice page (`page-template.html`, its
3
+ * style and browser script kept as exact bytes), filled with the core's payload. Another page shape
4
+ * would be another template here, on the same core.
5
+ */
6
+ import { readFileSync, writeFileSync } from "node:fs";
7
+ import { join } from "node:path";
8
+ import { prepareBuild, say, type Payload } from "./label-page-core.ts";
9
+
10
+ const TEMPLATE = readFileSync(
11
+ join(import.meta.dirname, "page-template.html"),
12
+ "utf8",
13
+ );
14
+ const escapeHtml = (text: string): string =>
15
+ text.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;");
16
+ /** JSON as the page always carried it: `", "` and `": "` between entries, Unicode kept. */
17
+ const compactJson = (v: unknown): string => {
18
+ if (Array.isArray(v)) return `[${v.map(compactJson).join(", ")}]`;
19
+ if (typeof v !== "object" || v === null) return JSON.stringify(v);
20
+ return `{${Object.entries(v)
21
+ .map(([k, x]) => `${JSON.stringify(k)}: ${compactJson(x)}`)
22
+ .join(", ")}}`;
23
+ };
24
+
25
+ /** The page: the template with its title and its data. */
26
+ export function pageHtml(payload: Payload): string {
27
+ // `</` inside JSON inside a <script> would end the script early.
28
+ const data = compactJson(payload).replaceAll("</", "<\\/");
29
+ return TEMPLATE.replaceAll("__TITLE__", () =>
30
+ escapeHtml(payload.title),
31
+ ).replaceAll("__DATA__", () => data);
32
+ }
33
+ /** items.json → out/page.html and out/key.json. */
34
+ export function build(configPath: string, outDir: string): void {
35
+ const { payload, dropped } = prepareBuild(configPath, outDir);
36
+ writeFileSync(join(outDir, "page.html"), pageHtml(payload), "utf8");
37
+ say(
38
+ `built ${join(outDir, "page.html")}: ${String(payload.items.length)} items, ${String(payload.groups.length)} groups${dropped ? `, ${String(dropped)} left out (labeller does not know their group)` : ""}`,
39
+ );
40
+ say(
41
+ `wrote ${join(outDir, "key.json")}: the only link from G-ids to source ids; it is NOT part of the page`,
42
+ );
43
+ }
@@ -0,0 +1,70 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * label-page — the generator of the build-label-page skill: items.json → a blind, single-page
4
+ * labelling app (HTML) and its key, and the answers back out of the store.
5
+ *
6
+ * build <items.json> <out-dir> → out/page.html, out/key.json
7
+ * export <dump.json> <key.json> <data-dir> [--date D] → data/<study>-labels-<D>.json
8
+ * (+ data/<study>-practice-r<N>-<D>.json)
9
+ * schema → the declared items.json schema
10
+ *
11
+ * Run from the skill, never copied into a study: a fix reaches every page built after it.
12
+ *
13
+ * `build` makes a page that stores each answer in the artifact's `db` store (publish page.html with
14
+ * `capabilities: {db: {}}`). `export` turns what the store holds into one JSON file for the study's
15
+ * data/ folder. Answers and familiarity values outside the offered options are not counted (page and
16
+ * export read the same `allowed` lists): the item or group is marked incomplete and reported
17
+ * (`invalid_answers`, `invalid_familiarity`). `dump.json` is the store's three collections:
18
+ *
19
+ * {"answers": {"G001": {"answers": {"q": "v"}, "reasons": {"q": "why"}, "savedAt": "…", "source": "page"}, …},
20
+ * "groups": {"python": {"familiarity": "well", "savedAt": "…", "source": "page"}, …},
21
+ * "practice": {"r1-P1": {"answers": {"q": "v"}, "savedAt": "…", "source": "page"}, …}}
22
+ *
23
+ * THE COMPREHENSION STEP (rule 8). The first screen shows every question with one real example per
24
+ * answer (`example`, required on every option). Then the practice items: their answers are known
25
+ * (`expected`, one per question), and after each one the page shows the expected answer and `why`.
26
+ * The real items open once every practice item of the current `practice_round` is answered; a miss
27
+ * does not lock them, it tells the labeller to tell the agent, who rewords the question, raises
28
+ * `practice_round` and rebuilds. The page stores the FIRST practice answer only, in its own
29
+ * collection (`practice`, documents `r<round>-P<n>`), so `export` keeps it out of the labels and
30
+ * writes it to `<study>-practice-r<round>-<date>.json`. An item card asks one question at a time.
31
+ *
32
+ * items.json is checked once against the declared CONFIG_SCHEMA (`schema` prints it): every field's
33
+ * type, required or optional, non-empty strings, ids as strings of letters, digits, - and _. A bad
34
+ * field stops the build and is named by its path, e.g. `questions[0].options[1].label`.
35
+ *
36
+ * {
37
+ * "study": "demo-gold", file-name stem
38
+ * "title": "Label these", 2-4 words; the page title
39
+ * "intro": "One or two sentences.", optional
40
+ * "seed": 7, fixes the order; change it and every id changes
41
+ * "groups": {"python": "Python"},
42
+ * "labeller_knows": ["python"], optional: items of other groups are not put on the page
43
+ * "familiarity_question": "How well do you know {group}?", optional
44
+ * "questions": [{"id": "label", "text": "Is this correct?", "short": "correct?",
45
+ * "options": [{"value": "yes", "label": "Yes", "example": "one real case",
46
+ * "icon": "✅"}, icon: optional
47
+ * {"value": "no", "label": "No", "example": "another real case",
48
+ * "icon": "❌", "ask_why": "What is wrong with it?"}]}],
49
+ * ask_why: optional; the answer is incomplete until a
50
+ * reason is given. In any shown text, backticks render
51
+ * as code.
52
+ * "practice": [{"body": "…", "expected": {"label": "yes"}, "why": "one line"}, …], 3 or more
53
+ * "practice_round": 1, optional; raise it after rewording, the practice reopens
54
+ * "items": [{"source_id": "case-0412-b", "group": "python", "body": "…", "code": "…",
55
+ * "context": "…"}]
56
+ * }
57
+ *
58
+ * TypeScript, compiled beside itself to `label-page.js` by `npm run build`: a consumer runs the skill
59
+ * from `node_modules`, where Node does not strip types. The skill names the `.js`. The work is in
60
+ * `label-page-core.ts` (checks, ids, key, export; no page layout) and `label-page-html.ts` (the one
61
+ * closed-choice page, `page-template.html`, filled with the core's payload); the command line is
62
+ * `label-page-cli.ts`.
63
+ *
64
+ * EXIT CODES: 0 built / exported / printed · 1 refused (`REFUSED: <path>: …`) · 2 usage.
65
+ */
66
+ import { cli } from "./label-page-cli.js";
67
+ const run = cli(process.argv.slice(2));
68
+ process.stdout.write(run.stdout);
69
+ process.stderr.write(run.stderr);
70
+ process.exitCode = run.status;
@@ -0,0 +1,71 @@
1
+ /**
2
+ * The generator's command line as a process (label-page.ts): a wrong call exits 2, a refusal 1, an
3
+ * error that is no refusal 1 with its message and no stack. Everything else is tested as functions.
4
+ */
5
+ import assert from "node:assert/strict";
6
+ import { writeFileSync } from "node:fs";
7
+ import { join } from "node:path";
8
+ import { test } from "vitest";
9
+ import { spawnSync } from "node:child_process";
10
+ import { build, config, setup } from "../../../test/label-page-kit.ts";
11
+
12
+ const ENTRY = join(import.meta.dirname, "label-page.ts");
13
+
14
+ /** The command line as a process: what the argv layer and the exit code do outside the functions. */
15
+ function runProcess(args: readonly string[]): {
16
+ status: number | null;
17
+ out: string;
18
+ } {
19
+ const r = spawnSync(process.execPath, [ENTRY, ...args], { encoding: "utf8" });
20
+ assert.equal(r.error, undefined, String(r.error));
21
+ return { status: r.status, out: `${r.stdout}${r.stderr}` };
22
+ }
23
+
24
+ test("🔴 the command line: a wrong call is a usage error (2), a malformed date a refusal (1), today by default", () => {
25
+ for (const args of [
26
+ [],
27
+ ["build", "x"],
28
+ ["build", "a", "b", "--date", "2026-01-01"],
29
+ ["schema", "x"],
30
+ ["--nope"],
31
+ ]) {
32
+ const r = runProcess(args);
33
+ assert.equal(r.status, 2, `${args.join(" ")}\n${r.out}`);
34
+ assert.match(r.out, /^usage: label-page\.js build/);
35
+ }
36
+ const dir = setup("dates", config());
37
+ build(dir);
38
+ writeFileSync(
39
+ join(dir, "dump.json"),
40
+ JSON.stringify({ answers: {}, groups: {} }),
41
+ );
42
+ const bad = runProcess([
43
+ "export",
44
+ join(dir, "dump.json"),
45
+ join(dir, "out", "key.json"),
46
+ join(dir, "data"),
47
+ "--date",
48
+ "1 Jan",
49
+ ]);
50
+ assert.equal(bad.status, 1);
51
+ assert.match(bad.out, /REFUSED: --date must be YYYY-MM-DD/);
52
+ const today = runProcess([
53
+ "export",
54
+ join(dir, "dump.json"),
55
+ join(dir, "out", "key.json"),
56
+ join(dir, "data"),
57
+ ]);
58
+ assert.match(
59
+ today.out,
60
+ /-labels-\d{4}-\d{2}-\d{2}\.json: 0 of 12 items complete/,
61
+ );
62
+ }, 60_000);
63
+
64
+ test("🔴 an error that is no refusal (an output path that is a file) exits 1 with its message, not a stack", () => {
65
+ const dir = setup("out-is-file", config());
66
+ writeFileSync(join(dir, "out"), "a file");
67
+ const r = runProcess(["build", join(dir, "items.json"), join(dir, "out")]);
68
+ assert.equal(r.status, 1);
69
+ assert.match(r.out, /EEXIST|ENOTDIR/);
70
+ assert.doesNotMatch(r.out, /\n\s+at /);
71
+ });