paperlint 13.1.0 → 13.3.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 (141) hide show
  1. package/.github/workflows/ci.yml +10 -0
  2. package/CLAUDE.md +9 -1
  3. package/dist/adapters/git/index.d.ts.map +1 -1
  4. package/dist/adapters/git/index.js +13 -0
  5. package/dist/adapters/git/index.js.map +1 -1
  6. package/dist/adapters/study-registry/index.d.ts +142 -1
  7. package/dist/adapters/study-registry/index.d.ts.map +1 -1
  8. package/dist/adapters/study-registry/index.js +160 -1
  9. package/dist/adapters/study-registry/index.js.map +1 -1
  10. package/dist/cli.d.ts.map +1 -1
  11. package/dist/cli.js +3 -0
  12. package/dist/cli.js.map +1 -1
  13. package/dist/doctor.d.ts.map +1 -1
  14. package/dist/doctor.js +2 -2
  15. package/dist/doctor.js.map +1 -1
  16. package/dist/domain/decide-first.d.ts +43 -0
  17. package/dist/domain/decide-first.d.ts.map +1 -1
  18. package/dist/domain/decide-first.js +45 -2
  19. package/dist/domain/decide-first.js.map +1 -1
  20. package/dist/domain/study-gates.d.ts +96 -0
  21. package/dist/domain/study-gates.d.ts.map +1 -0
  22. package/dist/domain/study-gates.js +56 -0
  23. package/dist/domain/study-gates.js.map +1 -0
  24. package/dist/domain/study-mode.d.ts +6 -0
  25. package/dist/domain/study-mode.d.ts.map +1 -1
  26. package/dist/domain/study-mode.js.map +1 -1
  27. package/dist/domain/study-registry.d.ts +24 -4
  28. package/dist/domain/study-registry.d.ts.map +1 -1
  29. package/dist/domain/study-registry.js +39 -2
  30. package/dist/domain/study-registry.js.map +1 -1
  31. package/dist/domain/study-result.d.ts +151 -0
  32. package/dist/domain/study-result.d.ts.map +1 -0
  33. package/dist/domain/study-result.js +175 -0
  34. package/dist/domain/study-result.js.map +1 -0
  35. package/dist/outputs-before-decision-rule.d.ts.map +1 -1
  36. package/dist/outputs-before-decision-rule.js +37 -3
  37. package/dist/outputs-before-decision-rule.js.map +1 -1
  38. package/dist/ports/git-history.d.ts +12 -0
  39. package/dist/ports/git-history.d.ts.map +1 -1
  40. package/dist/ports/study-registry-codec.d.ts +3 -0
  41. package/dist/ports/study-registry-codec.d.ts.map +1 -1
  42. package/dist/skills/assess-novelty/scripts/candidate_page.d.ts +1 -0
  43. package/dist/skills/assess-novelty/scripts/novelty_reader_brief.d.ts +25 -0
  44. package/dist/skills/build-label-page/scripts/label-page-cli.d.ts +6 -0
  45. package/dist/skills/build-label-page/scripts/label-page-commands.d.ts +14 -0
  46. package/dist/skills/build-label-page/scripts/label-page-core.d.ts +149 -0
  47. package/dist/skills/build-label-page/scripts/label-page-html.d.ts +5 -0
  48. package/dist/skills/build-label-page/scripts/label-page.d.ts +2 -0
  49. package/dist/study-modes.d.ts +3 -0
  50. package/dist/study-modes.d.ts.map +1 -1
  51. package/dist/study-modes.js +8 -1
  52. package/dist/study-modes.js.map +1 -1
  53. package/dist/study-registry-rule.d.ts.map +1 -1
  54. package/dist/study-registry-rule.js +1 -0
  55. package/dist/study-registry-rule.js.map +1 -1
  56. package/dist/study-result-rule.d.ts +12 -0
  57. package/dist/study-result-rule.d.ts.map +1 -0
  58. package/dist/study-result-rule.js +87 -0
  59. package/dist/study-result-rule.js.map +1 -0
  60. package/docs/design/study-gates.md +208 -0
  61. package/docs/rules/repro/outputs-before-decision.md +8 -0
  62. package/docs/rules/repro/study-registered.md +38 -15
  63. package/docs/rules/repro/study-result.md +100 -0
  64. package/docs/rules.md +1 -0
  65. package/docs/toolchain.md +1 -1
  66. package/eslint.config.mjs +52 -0
  67. package/package.json +3 -1
  68. package/presets/agentverify.md +15 -4
  69. package/schemas/plan.v2.schema.json +341 -0
  70. package/skills/assess-novelty/scripts/candidate_page.css +31 -0
  71. package/skills/assess-novelty/scripts/candidate_page.html +934 -0
  72. package/skills/assess-novelty/scripts/candidate_page.js +165 -0
  73. package/skills/assess-novelty/scripts/candidate_page.test.ts +401 -0
  74. package/skills/assess-novelty/scripts/candidate_page.ts +194 -0
  75. package/skills/assess-novelty/scripts/check_novelty.py +230 -0
  76. package/skills/assess-novelty/scripts/check_novelty.test.ts +490 -0
  77. package/skills/assess-novelty/scripts/check_novelty_coverage.test.ts +176 -0
  78. package/skills/assess-novelty/scripts/crop_quote.py +104 -0
  79. package/skills/assess-novelty/scripts/crop_quote.test.ts +130 -0
  80. package/skills/assess-novelty/scripts/fixtures/crop-quote/make_sample.py +42 -0
  81. package/skills/assess-novelty/scripts/fixtures/crop-quote/sample.pdf +106 -0
  82. package/skills/assess-novelty/scripts/fixtures/novelty/CLAIMS.md +11 -0
  83. package/skills/assess-novelty/scripts/fixtures/novelty/paper.tex +10 -0
  84. package/skills/assess-novelty/scripts/fixtures/novelty/siblings/2026-10-27-novelty-refutation.md +8 -0
  85. package/skills/assess-novelty/scripts/fixtures/novelty/siblings/data/doe-preprint.txt +63 -0
  86. package/skills/assess-novelty/scripts/fixtures/novelty/siblings/novelty.json +54 -0
  87. package/skills/assess-novelty/scripts/novelty_candidates.py +136 -0
  88. package/skills/assess-novelty/scripts/novelty_candidates.test.ts +222 -0
  89. package/skills/assess-novelty/scripts/novelty_cells.py +88 -0
  90. package/skills/assess-novelty/scripts/novelty_cells.test.ts +159 -0
  91. package/skills/assess-novelty/scripts/novelty_coverage.py +55 -0
  92. package/skills/assess-novelty/scripts/novelty_reader_brief.js +165 -0
  93. package/skills/assess-novelty/scripts/novelty_reader_brief.test.ts +251 -0
  94. package/skills/assess-novelty/scripts/novelty_reader_brief.ts +215 -0
  95. package/skills/assess-novelty/scripts/quote_match.py +34 -0
  96. package/skills/assess-novelty/scripts/script_edges.test.ts +174 -0
  97. package/skills/build-label-page/SKILL.md +85 -54
  98. package/skills/build-label-page/SKILL.md.spec.ts +85 -53
  99. package/skills/build-label-page/build-label-page.test.ts +10 -941
  100. package/skills/build-label-page/references/checklist.md +45 -3
  101. package/skills/build-label-page/references/closed-choice-generator.md +88 -0
  102. package/skills/build-label-page/scripts/label-page-cli.js +72 -0
  103. package/skills/build-label-page/scripts/label-page-cli.ts +85 -0
  104. package/skills/build-label-page/scripts/label-page-commands.js +14 -0
  105. package/skills/build-label-page/scripts/label-page-commands.ts +14 -0
  106. package/skills/build-label-page/scripts/label-page-core.js +850 -0
  107. package/skills/build-label-page/scripts/label-page-core.test.ts +1164 -0
  108. package/skills/build-label-page/scripts/label-page-core.ts +1210 -0
  109. package/skills/build-label-page/scripts/label-page-html.js +33 -0
  110. package/skills/build-label-page/scripts/label-page-html.test.ts +413 -0
  111. package/skills/build-label-page/scripts/label-page-html.ts +43 -0
  112. package/skills/build-label-page/scripts/label-page.js +70 -0
  113. package/skills/build-label-page/scripts/label-page.test.ts +71 -0
  114. package/skills/build-label-page/scripts/label-page.ts +71 -0
  115. package/skills/build-label-page/scripts/page-template.html +513 -0
  116. package/src/adapters/git/index.test.ts +33 -0
  117. package/src/adapters/git/index.ts +19 -0
  118. package/src/adapters/study-registry/gates.test.ts +181 -0
  119. package/src/adapters/study-registry/index.ts +219 -1
  120. package/src/adapters/study-registry/mode.test.ts +6 -0
  121. package/src/cli.ts +3 -0
  122. package/src/doctor.ts +4 -2
  123. package/src/domain/decide-first.test.ts +131 -0
  124. package/src/domain/decide-first.ts +92 -2
  125. package/src/domain/study-gates.ts +125 -0
  126. package/src/domain/study-measurements.test.ts +71 -0
  127. package/src/domain/study-mode.ts +6 -0
  128. package/src/domain/study-registry.ts +64 -2
  129. package/src/domain/study-result.test.ts +300 -0
  130. package/src/domain/study-result.ts +358 -0
  131. package/src/outputs-before-decision-rule.test.ts +115 -2
  132. package/src/outputs-before-decision-rule.ts +50 -2
  133. package/src/ports/git-history.ts +12 -0
  134. package/src/ports/study-registry-codec.ts +3 -0
  135. package/src/rule-cases.test.ts +74 -0
  136. package/src/studies.test.ts +3 -0
  137. package/src/study-modes.ts +10 -1
  138. package/src/study-registry-rule.ts +1 -0
  139. package/src/study-result-rule.test.ts +281 -0
  140. package/src/study-result-rule.ts +123 -0
  141. package/skills/build-label-page/references/example-generator.py +0 -803
@@ -0,0 +1,71 @@
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.ts";
67
+
68
+ const run = cli(process.argv.slice(2));
69
+ process.stdout.write(run.stdout);
70
+ process.stderr.write(run.stderr);
71
+ process.exitCode = run.status;
@@ -0,0 +1,513 @@
1
+ <title>__TITLE__</title>
2
+ <link rel="preconnect" href="https://fonts.googleapis.com">
3
+ <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Roboto:wght@400;500;700&family=Roboto+Mono&display=swap">
4
+ <style>
5
+ /* One look, dark whatever the viewer's theme (Material Ocean, no purple): every color is a token below.
6
+ One column, sections apart by a hairline and wide space, items flat; the only raised block of an item is
7
+ the question being asked. Answer buttons are full-width rows with space between them, so a thumb cannot
8
+ land between two of them, and the chosen one has a 2px accent border. */
9
+ :root {
10
+ color-scheme: dark;
11
+ --bg: #0f111a; --surface: #1a1c25; --line: #262a3a; --text: #a6accd; --muted: #7e84a3;
12
+ --strong: #eeffff; --accent: #82aaff; --good: #c3e88d; --mark: #ffcb6b; --bad: #f07178;
13
+ --mark-bg: rgba(255, 203, 107, 0.08);
14
+ --sans: "Roboto", system-ui, -apple-system, "Segoe UI", sans-serif;
15
+ --mono: "Roboto Mono", ui-monospace, "SFMono-Regular", Menlo, monospace;
16
+ --gap-section: 48px; --gap-item: 16px; --measure: 68ch;
17
+ }
18
+ * { box-sizing: border-box; }
19
+ html, body { margin: 0; background: var(--bg); color: var(--text); font-family: var(--sans);
20
+ font-size: 16px; line-height: 1.6; }
21
+ main { max-width: 760px; margin: 0 auto; padding: 24px 16px 64px; display: flex; flex-direction: column;
22
+ gap: var(--gap-section); }
23
+ main > * + section, main > section + * { border-top: 1px solid var(--line); padding-top: var(--gap-section); }
24
+ header.top { display: flex; flex-direction: column; gap: 8px; }
25
+ h1 { font-size: 1.5rem; line-height: 1.3; margin: 0; color: var(--strong); font-weight: 700; }
26
+ h2 { font-size: 1.15rem; line-height: 1.4; margin: 0; color: var(--strong); font-weight: 500; }
27
+ p { margin: 0; max-width: var(--measure); }
28
+ .muted { color: var(--muted); }
29
+ .id { font-family: var(--mono); color: var(--muted); }
30
+ section { display: flex; flex-direction: column; gap: var(--gap-item); }
31
+ .item { display: flex; flex-direction: column; gap: 12px; padding-top: var(--gap-item);
32
+ border-top: 1px solid var(--line); }
33
+ .item:first-of-type { border-top: 0; padding-top: 0; }
34
+ .ask { background: var(--surface); border: 1px solid var(--line); border-radius: 12px; padding: 16px;
35
+ display: flex; flex-direction: column; gap: 12px; }
36
+ .q { color: var(--strong); font-weight: 500; }
37
+ .body { color: var(--strong); }
38
+ h3 { font-size: 1rem; margin: 0; color: var(--strong); font-weight: 500; }
39
+ .opts { display: flex; flex-direction: column; gap: 10px; }
40
+ button { font: inherit; color: var(--text); background: transparent; border: 1px solid var(--line);
41
+ border-radius: 10px; padding: 10px 16px; min-height: 48px; cursor: pointer; text-align: left; width: 100%; }
42
+ button[aria-pressed="true"] { border: 2px solid var(--accent); color: var(--strong); padding: 9px 15px; }
43
+ .opts button { display: grid; grid-template-columns: auto 1fr; column-gap: 12px; align-items: start; }
44
+ .opts button .icon { grid-row: span 2; font-size: 1.25rem; line-height: 1.6; }
45
+ .opts button .label { color: var(--strong); font-weight: 500; grid-column: 2; }
46
+ .opts button .ex { color: var(--muted); grid-column: 2; }
47
+ .opts button[aria-pressed="true"] { background: var(--surface); }
48
+ textarea.why { font: inherit; color: var(--strong); background: var(--surface); border: 1px solid var(--line);
49
+ border-radius: 10px; padding: 10px 12px; min-height: 88px; width: 100%; }
50
+ pre.code .line { display: flex; gap: 0; white-space: pre; min-height: 1.4em; }
51
+ pre.code .ws { color: var(--muted); }
52
+ pre.code .len { margin-left: auto; padding-left: 16px; color: var(--muted); font-size: 0.85em; user-select: none; }
53
+ code { font-family: var(--mono); font-size: 0.92em; color: var(--strong); background: var(--surface);
54
+ border: 1px solid var(--line); border-radius: 6px; padding: 1px 6px; }
55
+ button:disabled { opacity: 0.5; cursor: default; }
56
+ .answered { display: flex; align-items: center; justify-content: space-between; gap: 12px; color: var(--muted); }
57
+ .answered button { width: auto; min-height: 44px; border: 0; color: var(--accent); padding: 0 4px; }
58
+ .examples { display: flex; flex-direction: column; gap: 12px; }
59
+ .example b { color: var(--strong); font-weight: 500; display: block; }
60
+ .quote, .banner { background: var(--mark-bg); border-left: 2px solid var(--mark); padding: 10px 14px;
61
+ border-radius: 0 8px 8px 0; color: var(--text); }
62
+ .bar { height: 6px; background: var(--line); border-radius: 3px; overflow: hidden; }
63
+ .bar > div { height: 100%; background: var(--good); }
64
+ .tabs { display: flex; gap: 24px; overflow-x: auto; border-bottom: 1px solid var(--line); }
65
+ .tabs button { width: auto; border: 0; border-radius: 0; padding: 10px 0; min-height: 44px; color: var(--muted);
66
+ border-bottom: 2px solid transparent; white-space: nowrap; }
67
+ .tabs button[aria-pressed="true"] { color: var(--strong); border: 0; border-bottom: 2px solid var(--accent);
68
+ padding: 10px 0; }
69
+ ul { margin: 0; padding-left: 1.2rem; }
70
+ pre { overflow-x: auto; background: var(--surface); border: 1px solid var(--line); border-radius: 8px;
71
+ padding: 10px; font-family: var(--mono); font-size: 0.9rem; white-space: pre-wrap; word-break: break-word;
72
+ margin: 0; }
73
+ .status { color: var(--muted); }
74
+ .status.saved::before { content: ""; display: inline-block; width: 8px; height: 8px; border-radius: 50%;
75
+ background: var(--good); margin-right: 8px; vertical-align: middle; }
76
+ .status.missing { color: var(--mark); }
77
+ .status.failed { color: var(--bad); }
78
+ </style>
79
+ <main id="app"><p class="muted">Loading…</p></main>
80
+ <script type="application/json" id="data">__DATA__</script>
81
+ <script>
82
+ (function () {
83
+ "use strict";
84
+ var DATA = JSON.parse(document.getElementById("data").textContent);
85
+ var app = document.getElementById("app");
86
+ var state = {
87
+ answers: {}, // item id -> {answers, savedAt, source}, from the store
88
+ groups: {}, // group id -> {familiarity, savedAt, source}
89
+ practice: {}, // r<round>-P<n> -> {answers, savedAt, source}; the FIRST answer, kept apart
90
+ editing: {}, // item id -> the question id the labeller tapped "Change" on
91
+ mineWhy: {}, // item id -> the reasons this page has just written (ahead of the store)
92
+ draftWhy: {}, // item id -> {question id: text typed and not yet saved}
93
+ mine: {}, // item/group key -> the answers this page has just written (ahead of the store)
94
+ inflight: {}, // item/group key -> writes not yet confirmed
95
+ failed: {}, // item/group key -> error code of the last refused write
96
+ loaded: { answers: false, groups: false, practice: false },
97
+ store: "loading", // loading | ok | none
98
+ filter: "all"
99
+ };
100
+ var db = null;
101
+ var chains = {};
102
+
103
+ try { if (localStorage.getItem("label-filter") === "next") state.filter = "next"; } catch (e) {}
104
+
105
+ // ── what "done" means ────────────────────────────────────────────────────────────────────
106
+ function answersOf(id) {
107
+ if (state.mine[id]) return state.mine[id];
108
+ var d = state.answers[id];
109
+ return d && d.answers ? d.answers : {};
110
+ }
111
+ // A stored value is an answer only if the build offers it (DATA.allowed, the same lists the
112
+ // export reads from key.json): after a rebuild that removed an option, or a typo merged from chat,
113
+ // the question is unanswered again and says so.
114
+ function valid(allowed, v) { return allowed.indexOf(v) !== -1; }
115
+ function reasonsOf(id) {
116
+ if (state.mineWhy[id]) return state.mineWhy[id];
117
+ var d = state.answers[id];
118
+ return d && d.reasons ? d.reasons : {};
119
+ }
120
+ function keepDraft(id, qid, text) {
121
+ state.draftWhy[id] = Object.assign({}, state.draftWhy[id] || {});
122
+ state.draftWhy[id][qid] = text;
123
+ }
124
+ // The text typed for a reason and not yet in the store; a draft the store now holds is done.
125
+ function draftOf(id, qid) {
126
+ var d = state.draftWhy[id];
127
+ if (!d || !Object.prototype.hasOwnProperty.call(d, qid)) return undefined;
128
+ var stored = state.answers[id] && state.answers[id].reasons;
129
+ if (stored && stored[qid] === d[qid]) { delete d[qid]; return undefined; }
130
+ return d[qid];
131
+ }
132
+ function needsWhy(q, v) { return (DATA.allowed.needs_why[q.id] || []).indexOf(v) !== -1; }
133
+ // What an item still needs: an answer, or the reason its answer asks for (named "<short> (why)").
134
+ function missingFor(id) {
135
+ var a = answersOf(id), r = reasonsOf(id);
136
+ return DATA.questions.map(function (q) {
137
+ if (!valid(DATA.allowed.questions[q.id], a[q.id])) return q;
138
+ if (needsWhy(q, a[q.id]) && !(r[q.id] || "").trim()) {
139
+ return { id: q.id, text: q.text + " (why)", short: (q.short || q.text) + " (why)" };
140
+ }
141
+ return null;
142
+ }).filter(function (q) { return q !== null; });
143
+ }
144
+ function itemDone(id) { return missingFor(id).length === 0; }
145
+ function familiarity(g) {
146
+ var d = state.mine["group:" + g] || state.groups[g];
147
+ return d && valid(DATA.allowed.familiarity, d.familiarity) ? d.familiarity : undefined;
148
+ }
149
+ function groupItems(g) { return DATA.items.filter(function (i) { return i.group === g; }); }
150
+ // ── the practice (rule 8): known answers, before the real items ──────────────────────────
151
+ function practiceDoc(pid) { return "r" + DATA.practice.round + "-" + pid; }
152
+ function practiceAnswers(pid) {
153
+ var key = "practice:" + pid;
154
+ if (state.mine[key]) return state.mine[key];
155
+ var d = state.practice[practiceDoc(pid)];
156
+ return d && d.answers ? d.answers : {};
157
+ }
158
+ function practiceMissing(pid) {
159
+ var a = practiceAnswers(pid);
160
+ return DATA.questions.filter(function (q) { return !valid(DATA.allowed.questions[q.id], a[q.id]); });
161
+ }
162
+ function practiceAnswered(p) { return practiceMissing(p.id).length === 0; }
163
+ function practiceMatched(p) {
164
+ var a = practiceAnswers(p.id);
165
+ return DATA.questions.every(function (q) { return a[q.id] === p.expected[q.id]; });
166
+ }
167
+ function practiceOpen() { return !DATA.practice.items.every(practiceAnswered); }
168
+ function groupDone(g) { return familiarity(g) !== undefined && groupItems(g).every(function (i) { return itemDone(i.id); }); }
169
+
170
+ // ── saving: on click, one write at a time per document ────────────────────────────────────
171
+ function write(key, coll, docId, body) {
172
+ state.inflight[key] = (state.inflight[key] || 0) + 1;
173
+ delete state.failed[key];
174
+ var step = function () { return db.collection(coll).doc(docId).set(body); };
175
+ var run = (chains[key] || Promise.resolve()).then(step, step);
176
+ chains[key] = run.then(function () {
177
+ state.inflight[key] -= 1;
178
+ if (!state.inflight[key]) { delete state.mine[key]; delete state.mineWhy[key]; }
179
+ render();
180
+ }, function (e) {
181
+ state.inflight[key] -= 1;
182
+ if (!state.inflight[key]) { delete state.mine[key]; delete state.mineWhy[key]; } // a refused answer must not count as given
183
+ state.failed[key] = (e && e.code) || "unavailable";
184
+ render();
185
+ });
186
+ }
187
+ function canWrite() {
188
+ return db && state.loaded.answers && state.loaded.groups && state.loaded.practice && state.store === "ok";
189
+ }
190
+ function answer(id, qid, value) {
191
+ if (!canWrite()) return;
192
+ var next = Object.assign({}, answersOf(id));
193
+ next[qid] = value;
194
+ state.mine[id] = next;
195
+ write(id, "answers", id, { answers: next, reasons: reasonsOf(id), savedAt: new Date().toISOString(), source: "page" });
196
+ render();
197
+ }
198
+ // A reason saves when the labeller leaves the box (the text equivalent of a tap).
199
+ function setReason(id, qid, text) {
200
+ if (!canWrite()) return;
201
+ var why = Object.assign({}, reasonsOf(id));
202
+ why[qid] = text;
203
+ state.mineWhy[id] = why;
204
+ state.mine[id] = Object.assign({}, answersOf(id));
205
+ write(id, "answers", id, { answers: answersOf(id), reasons: why, savedAt: new Date().toISOString(), source: "page" });
206
+ render();
207
+ }
208
+ // The first answer to a practice question is the one scored: it cannot be changed afterwards.
209
+ function answerPractice(pid, qid, value) {
210
+ if (!canWrite()) return;
211
+ var cur = practiceAnswers(pid);
212
+ if (valid(DATA.allowed.questions[qid], cur[qid])) return;
213
+ var next = Object.assign({}, cur);
214
+ next[qid] = value;
215
+ state.mine["practice:" + pid] = next;
216
+ write("practice:" + pid, "practice", practiceDoc(pid),
217
+ { answers: next, savedAt: new Date().toISOString(), source: "page" });
218
+ render();
219
+ }
220
+ function setFamiliarity(g, value) {
221
+ if (!canWrite()) return;
222
+ var key = "group:" + g;
223
+ state.mine[key] = { familiarity: value };
224
+ write(key, "groups", g, { familiarity: value, savedAt: new Date().toISOString(), source: "page" });
225
+ render();
226
+ }
227
+
228
+ // ── rendering (everything inline; no scrolling, no toasts) ────────────────────────────────
229
+ function el(tag, cls, text) {
230
+ var n = document.createElement(tag);
231
+ if (cls) n.className = cls;
232
+ if (text !== undefined) n.textContent = text;
233
+ return n;
234
+ }
235
+ // Text with `backticks` shown as code spans; everything stays textContent, so nothing is parsed as HTML.
236
+ function rich(tag, cls, text) {
237
+ var n = el(tag, cls);
238
+ String(text).split("`").forEach(function (part, i) {
239
+ if (part) n.appendChild(el(i % 2 ? "code" : "span", "", part));
240
+ });
241
+ return n;
242
+ }
243
+ // `withExamples`: the practice shows each answer's example on its button; the real items keep the buttons
244
+ // short, the examples stay on the first screen.
245
+ // A code block shows what a labeller would otherwise count by eye: leading and trailing whitespace as
246
+ // faint marks (a space as a middle dot, a tab as an arrow) and each line's length in characters.
247
+ function visible(ws) { return ws.replace(/ /g, "\u00b7").replace(/\t/g, "\u2192"); }
248
+ function codeBlock(text) {
249
+ var pre = el("pre", "code");
250
+ String(text).split("\n").forEach(function (line) {
251
+ var row = el("div", "line");
252
+ var m = /^([ \t]*)([\s\S]*?)([ \t]*)$/.exec(line);
253
+ if (m[1]) row.appendChild(el("span", "ws", visible(m[1])));
254
+ if (m[2]) row.appendChild(el("span", "", m[2]));
255
+ if (m[3]) row.appendChild(el("span", "ws", visible(m[3])));
256
+ row.appendChild(el("span", "len", String(line.length)));
257
+ pre.appendChild(row);
258
+ });
259
+ return pre;
260
+ }
261
+ function options(opts, current, onPick, disabled, withExamples) {
262
+ var row = el("div", "opts");
263
+ opts.forEach(function (o) {
264
+ var b = el("button", "");
265
+ if (o.icon) b.appendChild(el("span", "icon", o.icon));
266
+ b.appendChild(el("span", "label", o.label));
267
+ if (withExamples && o.example) b.appendChild(rich("span", "ex", o.example));
268
+ b.type = "button";
269
+ b.setAttribute("aria-pressed", String(current === o.value));
270
+ b.disabled = disabled;
271
+ b.addEventListener("click", function () { onPick(o.value); });
272
+ row.appendChild(b);
273
+ });
274
+ return row;
275
+ }
276
+ function timeOf(iso) {
277
+ var d = new Date(iso);
278
+ return isNaN(d) ? "" : d.toLocaleTimeString();
279
+ }
280
+ function statusLine(key, missing, savedAt) {
281
+ var s = el("p", "status");
282
+ if (state.failed[key]) {
283
+ s.className = "status failed";
284
+ s.textContent = "Not saved (" + state.failed[key] + "). Tap the answer again.";
285
+ } else if (state.inflight[key]) {
286
+ s.textContent = "Saving…";
287
+ } else if (missing.length) {
288
+ s.className = "status missing";
289
+ s.textContent = "Still needs: " + missing.map(function (q) { return q.short || q.text; }).join("; ") +
290
+ (savedAt ? ". Saved " + timeOf(savedAt) : "");
291
+ } else if (savedAt) {
292
+ s.className = "status saved";
293
+ s.textContent = "Saved " + timeOf(savedAt);
294
+ }
295
+ return s;
296
+ }
297
+ function itemCard(item, off) {
298
+ var done = itemDone(item.id);
299
+ var card = el("article", "item");
300
+ card.appendChild(el("div", "id", item.id));
301
+ if (item.title) card.appendChild(rich("h3", "", item.title));
302
+ card.appendChild(rich("p", "body", item.body));
303
+ if (item.code) card.appendChild(codeBlock(item.code));
304
+ if (item.context) card.appendChild(rich("p", "muted", item.context));
305
+ var a = answersOf(item.id);
306
+ askOne(card, a, state.editing[item.id], function (q, v) {
307
+ delete state.editing[item.id];
308
+ answer(item.id, q.id, v);
309
+ }, function (q) { state.editing[item.id] = q.id; render(); }, off);
310
+ DATA.questions.forEach(function (q) {
311
+ var o = q.options.filter(function (x) { return x.value === a[q.id] && x.ask_why; })[0];
312
+ if (!o) return;
313
+ card.appendChild(el("p", "q", o.ask_why));
314
+ var box = el("textarea", "why");
315
+ var draft = draftOf(item.id, q.id);
316
+ box.value = draft !== undefined ? draft : reasonsOf(item.id)[q.id] || "";
317
+ box.disabled = off;
318
+ // Every keystroke is kept as a draft, so a render from the store never erases unsaved text.
319
+ box.addEventListener("input", function () { keepDraft(item.id, q.id, box.value); });
320
+ box.addEventListener("change", function () { setReason(item.id, q.id, box.value); });
321
+ card.appendChild(box);
322
+ });
323
+ var saved = state.answers[item.id] && state.answers[item.id].savedAt;
324
+ card.appendChild(statusLine(item.id, missingFor(item.id), saved));
325
+ return card;
326
+ }
327
+ function labelOf(q, v) {
328
+ var o = q.options.filter(function (x) { return x.value === v; })[0];
329
+ return o ? o.label : v;
330
+ }
331
+ // One question per screen: the answered ones stay as a line (with "Change"), and only one question
332
+ // is asked: the one being changed, else the first unanswered.
333
+ function askOne(card, a, editing, onPick, onChange, off, withExamples) {
334
+ var open = DATA.questions.filter(function (q) { return !valid(DATA.allowed.questions[q.id], a[q.id]); });
335
+ var asked = DATA.questions.filter(function (q) { return q.id === editing; })[0] || open[0];
336
+ DATA.questions.forEach(function (q) {
337
+ if (q === asked || !valid(DATA.allowed.questions[q.id], a[q.id])) return;
338
+ var line = el("p", "answered", (q.short || q.text) + ": " + labelOf(q, a[q.id]) + " ");
339
+ if (onChange) {
340
+ var b = el("button", "", "Change");
341
+ b.type = "button";
342
+ b.disabled = off;
343
+ b.addEventListener("click", function () { onChange(q); });
344
+ line.appendChild(b);
345
+ }
346
+ card.appendChild(line);
347
+ });
348
+ if (!asked) return;
349
+ var ask = el("div", "ask");
350
+ ask.appendChild(el("p", "q", asked.text));
351
+ ask.appendChild(options(asked.options, a[asked.id], function (v) { onPick(asked, v); }, off, withExamples));
352
+ card.appendChild(ask);
353
+ }
354
+ function howTo() {
355
+ var sec = el("section");
356
+ sec.appendChild(el("h2", "", "📖 How to answer"));
357
+ DATA.questions.forEach(function (q) {
358
+ sec.appendChild(el("p", "q", q.text));
359
+ var list = el("div", "examples");
360
+ q.options.forEach(function (o) {
361
+ var row = el("div", "example");
362
+ row.appendChild(el("b", "", (o.icon ? o.icon + " " : "") + o.label));
363
+ row.appendChild(rich("span", "muted", "For example: " + o.example));
364
+ list.appendChild(row);
365
+ });
366
+ sec.appendChild(list);
367
+ });
368
+ return sec;
369
+ }
370
+ function practiceCard(p, off) {
371
+ var card = el("article", "item");
372
+ card.appendChild(el("div", "id", "Practice " + p.id));
373
+ if (p.title) card.appendChild(rich("h3", "", p.title));
374
+ card.appendChild(rich("p", "body", p.body));
375
+ if (p.code) card.appendChild(codeBlock(p.code));
376
+ if (p.context) card.appendChild(rich("p", "muted", p.context));
377
+ var a = practiceAnswers(p.id);
378
+ askOne(card, a, undefined, function (q, v) { answerPractice(p.id, q.id, v); }, null, off, true);
379
+ if (practiceAnswered(p)) {
380
+ var expected = DATA.questions.map(function (q) { return labelOf(q, p.expected[q.id]); }).join(" · ");
381
+ card.appendChild(rich("p", practiceMatched(p) ? "status saved" : "quote",
382
+ (practiceMatched(p) ? "Matched. " : "Expected: " + expected + ". ") + p.why));
383
+ }
384
+ card.appendChild(statusLine("practice:" + p.id, [], null));
385
+ return card;
386
+ }
387
+ function practiceBlock(off) {
388
+ var items = DATA.practice.items;
389
+ var sec = el("section");
390
+ if (practiceOpen()) {
391
+ var answered = items.filter(practiceAnswered).length;
392
+ sec.appendChild(el("h2", "", "🎯 Practice: " + answered + " of " + items.length + " answered"));
393
+ sec.appendChild(el("p", "muted", "These answers are known. After each one the page shows the expected " +
394
+ "answer. The real items open when all " + items.length + " are answered."));
395
+ items.forEach(function (p) { sec.appendChild(practiceCard(p, off)); });
396
+ return sec;
397
+ }
398
+ var missed = items.filter(function (p) { return !practiceMatched(p); });
399
+ sec.appendChild(el("h2", "", (missed.length ? "⚠️ " : "✅ ") + "Practice: " + (items.length - missed.length) + " of " + items.length + " matched"));
400
+ if (missed.length) {
401
+ sec.appendChild(el("div", "banner", "A practice answer did not match. Before going on, tell the agent " +
402
+ "which one: the question is reworded and you take the practice again."));
403
+ missed.forEach(function (p) { sec.appendChild(practiceCard(p, off)); });
404
+ }
405
+ return sec;
406
+ }
407
+ function groupBlock(g, off, onlyOpen) {
408
+ var sec = el("section");
409
+ sec.appendChild(el("h2", "", g.label));
410
+ var fam = el("div", "ask");
411
+ fam.appendChild(el("p", "q", DATA.familiarity_question.replace("{group}", g.label)));
412
+ fam.appendChild(options(DATA.familiarity, familiarity(g.id), function (v) { setFamiliarity(g.id, v); }, off));
413
+ var gs = state.groups[g.id];
414
+ fam.appendChild(statusLine("group:" + g.id,
415
+ familiarity(g.id) === undefined ? [{ text: "an answer" }] : [], gs && gs.savedAt));
416
+ sec.appendChild(fam);
417
+ groupItems(g.id).forEach(function (i) {
418
+ if (onlyOpen && itemDone(i.id)) return;
419
+ sec.appendChild(itemCard(i, off));
420
+ });
421
+ return sec;
422
+ }
423
+ function render() {
424
+ var off = !canWrite();
425
+ var done = DATA.items.filter(function (i) { return itemDone(i.id); }).length;
426
+ var total = DATA.items.length;
427
+ app.textContent = "";
428
+ var top = el("header", "top");
429
+ app.appendChild(top);
430
+ top.appendChild(el("h1", "", DATA.title));
431
+ if (DATA.intro) top.appendChild(el("p", "muted", DATA.intro));
432
+ if (state.store === "none") {
433
+ top.appendChild(el("div", "banner",
434
+ "Answers cannot be saved in this view, so it is read-only. Open the page from the claude.ai " +
435
+ "artifact link while signed in with access to answer."));
436
+ } else if (state.store === "loading" || !(state.loaded.answers && state.loaded.groups)) {
437
+ top.appendChild(el("p", "muted", "Loading saved answers…"));
438
+ }
439
+ app.appendChild(howTo());
440
+ app.appendChild(practiceBlock(off));
441
+ // Without the store nothing can be answered, practice included: show the real items read-only (rule 7).
442
+ if (practiceOpen() && state.store !== "none") return;
443
+ var progress = el("section");
444
+ app.appendChild(progress);
445
+ progress.appendChild(el("p", "", done + " of " + total + " items complete"));
446
+ var bar = el("div", "bar"), fill = el("div");
447
+ fill.style.width = (total ? (100 * done / total) : 0) + "%";
448
+ bar.appendChild(fill);
449
+ progress.appendChild(bar);
450
+
451
+ var filters = el("div", "tabs");
452
+ [["all", "All items"], ["next", "Next unanswered"]].forEach(function (f) {
453
+ var b = el("button", "", f[1]);
454
+ b.type = "button";
455
+ b.setAttribute("aria-pressed", String(state.filter === f[0]));
456
+ b.addEventListener("click", function () {
457
+ state.filter = f[0];
458
+ try { localStorage.setItem("label-filter", f[0]); } catch (e) {}
459
+ render();
460
+ });
461
+ filters.appendChild(b);
462
+ });
463
+ progress.appendChild(filters);
464
+
465
+ var onlyOpen = state.filter === "next";
466
+ if (onlyOpen) {
467
+ var rest = DATA.items.filter(function (i) { return !itemDone(i.id); });
468
+ var open = DATA.groups.filter(function (g) { return familiarity(g.id) === undefined; });
469
+ var note = el("div", "muted");
470
+ if (!rest.length && !open.length) {
471
+ note.appendChild(el("p", "", "Nothing left. Every item and every group is answered."));
472
+ } else {
473
+ note.appendChild(el("p", "", "Left to do:"));
474
+ var ul = el("ul");
475
+ open.forEach(function (g) { ul.appendChild(el("li", "", g.label + ": how well you know it")); });
476
+ rest.forEach(function (i) {
477
+ ul.appendChild(el("li", "", i.id + " needs " +
478
+ missingFor(i.id).map(function (q) { return q.short || q.text; }).join("; ")));
479
+ });
480
+ note.appendChild(ul);
481
+ }
482
+ progress.appendChild(note);
483
+ }
484
+ DATA.groups.forEach(function (g) {
485
+ if (onlyOpen && groupDone(g.id)) return;
486
+ app.appendChild(groupBlock(g, off, onlyOpen));
487
+ });
488
+ }
489
+
490
+ // ── the store: subscribe once per collection; render without it, light up when it arrives ──
491
+ function take(target, loadedKey) {
492
+ return function (snap) {
493
+ var next = {};
494
+ snap.docs.forEach(function (d) { next[d.id] = d.data(); });
495
+ state[target] = next;
496
+ state.loaded[loadedKey] = true;
497
+ render();
498
+ };
499
+ }
500
+ function broken() { state.store = "none"; render(); }
501
+
502
+ render();
503
+ if (!window.claude || typeof window.claude.use !== "function") { broken(); return; }
504
+ window.claude.use("db").then(function (store) {
505
+ if (!store) { broken(); return; }
506
+ db = store;
507
+ state.store = "ok";
508
+ db.collection("answers").onSnapshot(take("answers", "answers"), broken);
509
+ db.collection("groups").onSnapshot(take("groups", "groups"), broken);
510
+ db.collection("practice").onSnapshot(take("practice", "practice"), broken);
511
+ }, broken);
512
+ })();
513
+ </script>
@@ -275,3 +275,36 @@ it("firstCommitsOf keeps each path's first commit and skips blank lines", () =>
275
275
  ["b", { commit: B, date: DATE }],
276
276
  ]);
277
277
  });
278
+
279
+ it("fileAt: the text at a commit, absent when ls-tree lists nothing, and each failure a reason", () => {
280
+ const fail = {
281
+ kind: "exited",
282
+ status: 128,
283
+ stdout: "",
284
+ stderr: "fatal: bad object\n",
285
+ } as const;
286
+ const { git, calls } = over({
287
+ "ls-tree": exitedWith("plan.json\n"),
288
+ show: exitedWith('{"question": "q"}\n'),
289
+ });
290
+ const text = git.fileAt(STUDY, A, "plan.json");
291
+ const at = (lsTree: ProcessExit, show: ProcessExit = exitedWith("")) =>
292
+ over({ "ls-tree": lsTree, show }).git.fileAt(STUDY, A, "plan.json");
293
+ // Guards: the commands, from the study's folder; and every answer git can give.
294
+ expect([
295
+ calls.map((c) => [c.cwd, ...c.args.slice(2)]),
296
+ text,
297
+ at(exitedWith("")),
298
+ at(fail),
299
+ at(exitedWith("plan.json\n"), fail),
300
+ ]).toEqual([
301
+ [
302
+ [STUDY, "ls-tree", "--name-only", A, "--", "plan.json"],
303
+ [STUDY, "show", `${A}:./plan.json`],
304
+ ],
305
+ { kind: "text", text: '{"question": "q"}\n' },
306
+ { kind: "absent" },
307
+ { kind: "unknown", why: "fatal: bad object" },
308
+ { kind: "unknown", why: "fatal: bad object" },
309
+ ]);
310
+ });
@@ -7,11 +7,13 @@
7
7
  * order whether the clone is shallow; the decision file's first commit; every file's first
8
8
  * commit under the study's folder (`git log --reverse --name-only`); and for each, whether
9
9
  * any commit that touched the decision is its strict ancestor (`git rev-list -- <decision>`)
10
+ * fileAt a file under the study's folder as it stood at a commit (`git ls-tree`, then `git show`)
10
11
  */
11
12
  import type { AbsolutePath } from "../../domain/paths.ts";
12
13
  import type {
13
14
  CommitAt,
14
15
  Committed,
16
+ FileAt,
15
17
  FirstCommit,
16
18
  GitHistory,
17
19
  GitOrder,
@@ -110,9 +112,26 @@ export function gitHistory(
110
112
  : { kind: "changed", commit };
111
113
  },
112
114
  order: (studyDir, decision) => orderIn(git, studyDir, decision),
115
+ fileAt: (studyDir, commit, path) => fileAtIn(git, studyDir, commit, path),
113
116
  };
114
117
  }
115
118
 
119
+ /** `path` (relative to `studyDir`) at `commit`: `ls-tree` says whether it is there, `show` gives its text. */
120
+ const fileAtIn = (
121
+ git: Git,
122
+ studyDir: AbsolutePath,
123
+ commit: string,
124
+ path: string,
125
+ ): FileAt => {
126
+ const listed = git(studyDir, ["ls-tree", "--name-only", commit, "--", path]);
127
+ if ("why" in listed) return { kind: "unknown", why: listed.why };
128
+ if (listed.out.trim() === "") return { kind: "absent" };
129
+ const shown = git(studyDir, ["show", `${commit}:./${path}`]);
130
+ return "why" in shown
131
+ ? { kind: "unknown", why: shown.why }
132
+ : { kind: "text", text: shown.out };
133
+ };
134
+
116
135
  type Git = ReturnType<typeof gitIn>;
117
136
 
118
137
  /** Whether `file` is in HEAD's tree (`git ls-tree`, which takes an absolute path in the worktree). */