paperlint 10.0.0 → 10.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/CONTRIBUTING.md +16 -4
  2. package/dist/eslint-rules/js-needs-reason.d.ts +55 -0
  3. package/dist/eslint-rules/js-needs-reason.d.ts.map +1 -0
  4. package/dist/eslint-rules/js-needs-reason.js +35 -0
  5. package/dist/eslint-rules/js-needs-reason.js.map +1 -0
  6. package/dist/package-dirs.d.ts +6 -0
  7. package/dist/package-dirs.d.ts.map +1 -1
  8. package/dist/package-dirs.js +6 -0
  9. package/dist/package-dirs.js.map +1 -1
  10. package/dist/skills/label-agent-runs/scripts/labels-commands.d.ts +13 -0
  11. package/dist/skills/label-agent-runs/scripts/labels.d.ts +2 -0
  12. package/dist/skills/run-agent-grid/scripts/grid-guard-commands.d.ts +20 -0
  13. package/dist/skills/run-agent-grid/scripts/grid-guard.d.ts +2 -0
  14. package/docs/skills.md +2 -0
  15. package/docs/testing.md +13 -0
  16. package/eslint-rules/js-needs-reason.test.ts +141 -0
  17. package/eslint-rules/js-needs-reason.ts +77 -0
  18. package/eslint.config.mjs +22 -2
  19. package/lib/skill-trigger-cases.test.ts +211 -0
  20. package/package.json +8 -4
  21. package/scripts/coverage-config.test.ts +5 -3
  22. package/skills/build-benchmark/SKILL.md +4 -1
  23. package/skills/build-benchmark/SKILL.md.spec.ts +3 -0
  24. package/skills/build-label-page/SKILL.md +3 -1
  25. package/skills/build-label-page/SKILL.md.spec.ts +2 -0
  26. package/skills/label-agent-runs/SKILL.md +103 -0
  27. package/skills/label-agent-runs/SKILL.md.spec.ts +106 -0
  28. package/skills/label-agent-runs/label-agent-runs.eval.ts +55 -0
  29. package/skills/label-agent-runs/label-agent-runs.harness.ts +123 -0
  30. package/skills/label-agent-runs/references/formats.md +64 -0
  31. package/skills/label-agent-runs/scripts/labels-commands.js +13 -0
  32. package/skills/label-agent-runs/scripts/labels-commands.ts +13 -0
  33. package/skills/label-agent-runs/scripts/labels.js +396 -0
  34. package/skills/label-agent-runs/scripts/labels.test.ts +538 -0
  35. package/skills/label-agent-runs/scripts/labels.ts +501 -0
  36. package/skills/paper-pipeline/SKILL.md +8 -1
  37. package/skills/paper-pipeline/SKILL.md.spec.ts +7 -0
  38. package/skills/render-paper/check-render.sh +9 -0
  39. package/skills/render-paper/render-paper.harness.mjs +102 -0
  40. package/skills/run-agent-grid/SKILL.md +181 -0
  41. package/skills/run-agent-grid/SKILL.md.spec.ts +184 -0
  42. package/skills/run-agent-grid/references/failure-catalog.md +36 -0
  43. package/skills/run-agent-grid/references/registry-and-ledger.md +142 -0
  44. package/skills/run-agent-grid/run-agent-grid.eval.ts +59 -0
  45. package/skills/run-agent-grid/run-agent-grid.harness.ts +115 -0
  46. package/skills/run-agent-grid/scripts/grid-guard-commands.js +18 -0
  47. package/skills/run-agent-grid/scripts/grid-guard-commands.ts +21 -0
  48. package/skills/run-agent-grid/scripts/grid-guard.js +700 -0
  49. package/skills/run-agent-grid/scripts/grid-guard.test.ts +1090 -0
  50. package/skills/run-agent-grid/scripts/grid-guard.ts +869 -0
  51. package/src/package-dirs.ts +8 -0
  52. package/tsconfig.base.json +22 -0
  53. package/tsconfig.json +6 -24
  54. package/tsconfig.pkg.json +3 -1
  55. package/tsconfig.skills.json +14 -0
  56. package/tsconfig.src.json +12 -0
@@ -0,0 +1,501 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * labels — the checks of the label-agent-runs skill.
4
+ *
5
+ * validate <labels.tsv> --codebook <codebook.json> [--ledger <attempts.jsonl>]
6
+ * [--analysed-at <git rev> --notes <notes.md>]
7
+ * The label file is well formed, uses only codebook labels, names each cell once, and —
8
+ * with a ledger — covers exactly the completed runs. The codebook is committed, unchanged,
9
+ * and was committed no later than the label file. With --analysed-at: every cell whose
10
+ * label differs from that revision is explained in a dated section of the notes, with a
11
+ * verbatim quote.
12
+ * agree <a.tsv> <b.tsv>
13
+ * Agreement between two label files over the same cells: n, percent agreement with a 95%
14
+ * Wilson interval, Cohen's kappa, and every disagreement. Use it for the automatic pre-sort
15
+ * against the hand labels, and for one rater against another.
16
+ *
17
+ * A label file is TSV with exactly this header: agent, model, cell, label. `cell` is
18
+ * `<unit>/<arm>/<seed>`, the cell of the run-agent-grid ledger.
19
+ *
20
+ * TypeScript, compiled beside itself to `labels.js` by `npm run build`: a consumer runs the skill
21
+ * from `node_modules`, where Node does not strip types. The skill names the `.js`.
22
+ *
23
+ * EXIT CODES: 0 valid / reported · 1 a finding · 2 usage.
24
+ */
25
+ import { spawnSync } from "node:child_process";
26
+ import { readFileSync } from "node:fs";
27
+ import { dirname, relative, resolve, sep } from "node:path";
28
+ import { parseArgs } from "node:util";
29
+ import { z } from "zod";
30
+ import { COMMANDS } from "./labels-commands.ts";
31
+
32
+ class Refusal extends Error {
33
+ readonly exitCode: number;
34
+ constructor(exitCode: number, message: string) {
35
+ super(message);
36
+ this.exitCode = exitCode;
37
+ }
38
+ }
39
+ const refuse = (exitCode: number, message: string): never => {
40
+ throw new Refusal(exitCode, message);
41
+ };
42
+ const say = (line: string): void => {
43
+ process.stdout.write(`${line}\n`);
44
+ };
45
+ const prop = (e: unknown, key: string): unknown => Reflect.get(Object(e), key);
46
+
47
+ const HEADER = ["agent", "model", "cell", "label"];
48
+ interface Row {
49
+ readonly agent: string;
50
+ readonly model: string;
51
+ readonly cell: string;
52
+ readonly label: string;
53
+ readonly n: number;
54
+ }
55
+ type CellId = Pick<Row, "agent" | "model" | "cell">;
56
+ const keyOf = (r: CellId): string => `${r.agent}\t${r.model}\t${r.cell}`;
57
+ const show = (r: CellId): string => `${r.agent} ${r.model} ${r.cell}`;
58
+
59
+ interface Line {
60
+ readonly fields: readonly string[];
61
+ readonly n: number;
62
+ }
63
+
64
+ /** A line with exactly four fields as a row; anything else is not one. */
65
+ const asRow = ({ fields, n }: Line): Row[] => {
66
+ const [agent, model, cell, label] = fields;
67
+ return fields.length === 4
68
+ ? [
69
+ {
70
+ agent: String(agent),
71
+ model: String(model),
72
+ cell: String(cell),
73
+ label: String(label),
74
+ n,
75
+ },
76
+ ]
77
+ : [];
78
+ };
79
+
80
+ /** Rows of a label file, and the problems that stop a line being one. */
81
+ function readLabels(file: string): { rows: Row[]; problems: string[] } {
82
+ const [first, ...body] = readFileSync(file, "utf8").split("\n");
83
+ const lines: Line[] = body
84
+ .map((line, i) => ({ line, fields: line.split("\t"), n: i + 2 }))
85
+ .filter(({ line }) => line.trim() !== "");
86
+ const headerOk = String(first).replace(/\r$/, "") === HEADER.join("\t");
87
+ return {
88
+ rows: lines.flatMap(asRow),
89
+ problems: [
90
+ ...(headerOk
91
+ ? []
92
+ : [`${file}:1: the header must be "${HEADER.join("\\t")}"`]),
93
+ ...lines
94
+ .filter(({ fields }) => fields.length !== 4)
95
+ .map(({ n }) => `${file}:${String(n)}: not four tab-separated fields`),
96
+ ],
97
+ };
98
+ }
99
+
100
+ const git = (cwd: string, ...args: string[]) =>
101
+ spawnSync("git", ["-C", cwd, ...args], { encoding: "utf8" });
102
+ /** Where `abs` sits in its git repository, or null outside one. */
103
+ const repoPath = (abs: string): { top: string; rel: string } | null => {
104
+ const top = git(dirname(abs), "rev-parse", "--show-toplevel");
105
+ return top.status === 0
106
+ ? {
107
+ top: top.stdout.trim(),
108
+ rel: relative(top.stdout.trim(), abs).split(sep).join("/"),
109
+ }
110
+ : null;
111
+ };
112
+ /** Unix times of the commits that touched `abs` — only the ones that ADDED it, with `added`. */
113
+ const commitTimes = (abs: string, added: boolean): number[] => {
114
+ const at = repoPath(abs);
115
+ const filter = added ? ["--diff-filter=A"] : [];
116
+ return at
117
+ ? git(at.top, "log", ...filter, "--format=%ct", "--", at.rel)
118
+ .stdout.trim()
119
+ .split("\n")
120
+ .filter(Boolean)
121
+ .map(Number)
122
+ : [];
123
+ };
124
+
125
+ const Codebook = z.looseObject({
126
+ labels: z
127
+ .array(
128
+ z.looseObject({
129
+ id: z.unknown().optional(),
130
+ definition: z.unknown().optional(),
131
+ borderline: z.unknown().optional(),
132
+ }),
133
+ )
134
+ .default([]),
135
+ });
136
+ const filled = (v: unknown): boolean =>
137
+ typeof v === "string" && v.trim() !== "";
138
+
139
+ /** Is the committed copy of `abs` the one on disk? A message when it is not. */
140
+ function committedProblem(file: string, abs: string, text: string): string[] {
141
+ const at = repoPath(abs);
142
+ const head = at ? git(at.top, "show", `HEAD:${at.rel}`) : null;
143
+ if (!head || head.status !== 0)
144
+ return [
145
+ `${file} is not committed: commit the codebook before the first label`,
146
+ ];
147
+ return head.stdout === text
148
+ ? []
149
+ : [
150
+ `${file} differs from its committed version: the codebook is fixed before labelling`,
151
+ ];
152
+ }
153
+
154
+ type CodebookLabel = z.infer<typeof Codebook>["labels"][number];
155
+
156
+ /** Labels that lack an id, a definition or a borderline example. */
157
+ const incomplete = (file: string, labels: readonly CodebookLabel[]): string[] =>
158
+ labels
159
+ .filter(
160
+ (l) => !(filled(l.id) && filled(l.definition) && filled(l.borderline)),
161
+ )
162
+ .map(
163
+ (l) =>
164
+ `${file}: label "${String(l.id)}" needs an id, a definition and a borderline example`,
165
+ );
166
+
167
+ function codebookProblems(
168
+ file: string,
169
+ labelsFile: string,
170
+ ): { known: Set<unknown>; problems: string[] } {
171
+ const abs = resolve(file);
172
+ const text = readFileSync(abs, "utf8");
173
+ const { labels } = Codebook.parse(JSON.parse(text));
174
+ // EVERY commit of the codebook, not only the one that added it: an edit after labelling began
175
+ // changes what the labels already given mean.
176
+ const bookLast = Math.max(-Infinity, ...commitTimes(abs, false));
177
+ const labelsFirst = Math.min(
178
+ Infinity,
179
+ ...commitTimes(resolve(labelsFile), true),
180
+ );
181
+ const late = bookLast > labelsFirst;
182
+ return {
183
+ known: new Set(labels.map((l) => l.id)),
184
+ problems: [
185
+ ...committedProblem(file, abs, text),
186
+ ...(labels.length ? [] : [`${file} defines no labels`]),
187
+ ...incomplete(file, labels),
188
+ ...(late
189
+ ? [
190
+ `${file} was committed after ${labelsFile} was first committed: the codebook is fixed before labelling; a changed codebook needs a new label file`,
191
+ ]
192
+ : []),
193
+ ],
194
+ };
195
+ }
196
+
197
+ const Attempt = z.looseObject({
198
+ attempt: z.string(),
199
+ agent: z.string().optional(),
200
+ model: z.string().optional(),
201
+ unit: z.string().optional(),
202
+ arm: z.string().optional(),
203
+ seed: z.union([z.string(), z.number()]).optional(),
204
+ status: z.string().optional(),
205
+ });
206
+ type Attempt = z.infer<typeof Attempt>;
207
+
208
+ /** The cells of the completed attempts in a run-agent-grid ledger. */
209
+ function ledgerCells(file: string): CellId[] {
210
+ const byId = new Map<string, Attempt>();
211
+ readFileSync(file, "utf8")
212
+ .split("\n")
213
+ .filter((l) => l.trim())
214
+ .map((l) => Attempt.parse(JSON.parse(l)))
215
+ .forEach((r) => {
216
+ byId.set(r.attempt, { ...byId.get(r.attempt), ...r });
217
+ });
218
+ return [...byId.values()]
219
+ .filter((r) => r.status === "complete")
220
+ .map((r) => ({
221
+ agent: String(r.agent),
222
+ model: String(r.model),
223
+ cell: `${String(r.unit)}/${String(r.arm)}/${String(r.seed)}`,
224
+ }));
225
+ }
226
+
227
+ /** The labels a revision of the label file held, by cell; null when it did not exist there. */
228
+ function labelsAt(labelsFile: string, rev: string): Map<string, string> | null {
229
+ const at = repoPath(resolve(labelsFile));
230
+ const then = at ? git(at.top, "show", `${rev}:${at.rel}`) : null;
231
+ if (!then || then.status !== 0) return null;
232
+ return new Map(
233
+ then.stdout
234
+ .split("\n")
235
+ .slice(1)
236
+ .filter((l) => l.trim())
237
+ .map((l) => l.split("\t"))
238
+ .map(([agent, model, cell, label]) => [
239
+ keyOf({
240
+ agent: String(agent),
241
+ model: String(model),
242
+ cell: String(cell),
243
+ }),
244
+ String(label),
245
+ ]),
246
+ );
247
+ }
248
+
249
+ /** The sections of the notes under a "## YYYY-MM-DD" heading, each up to the next "## ". */
250
+ const datedSections = (notesFile: string): string[] =>
251
+ readFileSync(notesFile, "utf8")
252
+ .split(/^(?=## )/m)
253
+ .filter((s) => /^## \d{4}-\d{2}-\d{2}\b/.test(s));
254
+
255
+ /** A cell key back as the three fields `show` prints. */
256
+ const fromKey = (key: string): CellId => {
257
+ const [agent, model, cell] = key.split("\t");
258
+ return { agent: String(agent), model: String(model), cell: String(cell) };
259
+ };
260
+
261
+ const WHY = " with no dated note quoting the run";
262
+
263
+ /** Rows whose label is new or different since the analysis, and not explained. */
264
+ const touchedSince = (
265
+ rows: readonly Row[],
266
+ before: ReadonlyMap<string, string>,
267
+ noted: (cell: string) => boolean,
268
+ ): string[] =>
269
+ rows
270
+ .filter((r) => before.get(keyOf(r)) !== r.label && !noted(r.cell))
271
+ .map((r) => {
272
+ const was = before.get(keyOf(r));
273
+ return was === undefined
274
+ ? `${show(r)}: added after the analysis (${r.label}),${WHY}`
275
+ : `${show(r)}: relabelled ${was} → ${r.label} after the analysis,${WHY}`;
276
+ });
277
+
278
+ /** Cells the analysis read that are gone now, and not explained. */
279
+ const removedSince = (
280
+ rows: readonly Row[],
281
+ before: ReadonlyMap<string, string>,
282
+ noted: (cell: string) => boolean,
283
+ ): string[] => {
284
+ const now = new Set(rows.map(keyOf));
285
+ return [...before]
286
+ .filter(([key]) => !now.has(key) && !noted(fromKey(key).cell))
287
+ .map(
288
+ ([key, was]) =>
289
+ `${show(fromKey(key))}: removed after the analysis (was ${was}),${WHY}`,
290
+ );
291
+ };
292
+
293
+ /**
294
+ * Every cell whose label differs from what the analysis read at `rev` — changed, ADDED after it,
295
+ * or REMOVED since — and is not explained in a dated, quoted note naming the cell.
296
+ */
297
+ function relabelProblems(
298
+ labelsFile: string,
299
+ rev: string,
300
+ notesFile: string,
301
+ rows: readonly Row[],
302
+ ): string[] {
303
+ const before = labelsAt(labelsFile, rev);
304
+ if (!before)
305
+ return [
306
+ `${labelsFile} does not exist at ${rev}: name the revision the analysis read`,
307
+ ];
308
+ const sections = datedSections(notesFile);
309
+ const noted = (cell: string): boolean =>
310
+ sections.some((s) => s.includes(cell) && /^> \S/m.test(s));
311
+ return [
312
+ ...touchedSince(rows, before, noted),
313
+ ...removedSince(rows, before, noted),
314
+ ];
315
+ }
316
+
317
+ /** Cells a file labels more than once, with the lines. */
318
+ function duplicates(file: string, rows: readonly Row[]): string[] {
319
+ const seen = new Map<string, number[]>();
320
+ rows.forEach((r) => seen.set(keyOf(r), [...(seen.get(keyOf(r)) ?? []), r.n]));
321
+ return [...seen]
322
+ .filter(([, ns]) => ns.length > 1)
323
+ .map(
324
+ ([k, ns]) =>
325
+ `${file}: ${show(fromKey(k))} is labelled ${String(ns.length)} times (lines ${ns.join(", ")})`,
326
+ );
327
+ }
328
+
329
+ /** Rows naming a cell twice, and coverage against the ledger. */
330
+ function rowProblems(file: string, rows: readonly Row[], ledger?: string) {
331
+ const runs = ledger === undefined ? null : ledgerCells(ledger);
332
+ const ran = new Set((runs ?? []).map(keyOf));
333
+ const labelled = new Set(rows.map(keyOf));
334
+ return [
335
+ ...duplicates(file, rows),
336
+ ...(runs
337
+ ? [
338
+ ...rows
339
+ .filter((r) => !ran.has(keyOf(r)))
340
+ .map(
341
+ (r) =>
342
+ `${file}:${String(r.n)}: ${show(r)} has no completed run in the ledger`,
343
+ ),
344
+ ...runs
345
+ .filter((r) => !labelled.has(keyOf(r)))
346
+ .map((r) => `${show(r)}: a completed run with no label`),
347
+ ]
348
+ : []),
349
+ ];
350
+ }
351
+
352
+ function validate(file: string, o: Values): void {
353
+ const { rows, problems } = readLabels(file);
354
+ const book = codebookProblems(String(o.codebook), file);
355
+ const all = [
356
+ ...problems,
357
+ ...book.problems,
358
+ ...rows
359
+ .filter((r) => !book.known.has(r.label))
360
+ .map(
361
+ (r) => `${file}:${String(r.n)}: "${r.label}" is not a codebook label`,
362
+ ),
363
+ ...rowProblems(file, rows, o.ledger),
364
+ ...(o["analysed-at"] === undefined
365
+ ? []
366
+ : relabelProblems(file, o["analysed-at"], String(o.notes), rows)),
367
+ ];
368
+ if (all.length) refuse(1, all.join("\n"));
369
+ say(`${file}: ${String(rows.length)} labels, valid`);
370
+ }
371
+
372
+ // ── agreement ───────────────────────────────────────────────────────────────────────────────
373
+ const Z = 1.959963984540054;
374
+ /** 95% Wilson score interval for k successes in n. */
375
+ const wilson = (k: number, n: number): readonly [number, number] => {
376
+ const p = k / n;
377
+ const denom = 1 + (Z * Z) / n;
378
+ const centre = (p + (Z * Z) / (2 * n)) / denom;
379
+ const half =
380
+ (Z * Math.sqrt((p * (1 - p)) / n + (Z * Z) / (4 * n * n))) / denom;
381
+ return [centre - half, centre + half];
382
+ };
383
+
384
+ /** Cohen's kappa over paired labels; null when chance agreement is 1 (one label only). */
385
+ function cohenKappa(
386
+ pairs: readonly (readonly [string, string])[],
387
+ ): number | null {
388
+ const n = pairs.length;
389
+ const po = pairs.filter(([a, b]) => a === b).length / n;
390
+ const labels = [...new Set(pairs.flat())];
391
+ const share = (i: 0 | 1, l: string): number =>
392
+ pairs.filter((p) => p[i] === l).length / n;
393
+ const pe = labels.reduce((s, l) => s + share(0, l) * share(1, l), 0);
394
+ return pe === 1 ? null : (po - pe) / (1 - pe);
395
+ }
396
+
397
+ /** Cells of either file that the other does not label. */
398
+ const onlyIn = (
399
+ fileA: string,
400
+ a: readonly Row[],
401
+ b: readonly Row[],
402
+ fileB: string,
403
+ ): string[] => {
404
+ const inA = new Set(a.map(keyOf));
405
+ const inB = new Set(b.map(keyOf));
406
+ return [
407
+ ...a
408
+ .filter((r) => !inB.has(keyOf(r)))
409
+ .map((r) => `${show(r)}: only in ${fileA}`),
410
+ ...b
411
+ .filter((r) => !inA.has(keyOf(r)))
412
+ .map((r) => `${show(r)}: only in ${fileB}`),
413
+ ];
414
+ };
415
+
416
+ /** The second file's label for each cell of the first; a refusal when the cells differ. */
417
+ function matched(
418
+ fileA: string,
419
+ fileB: string,
420
+ ): readonly (readonly [Row, string])[] {
421
+ const a = readLabels(fileA);
422
+ const b = readLabels(fileB);
423
+ // A cell labelled twice has no single label to compare: refused before any statistic.
424
+ const bad = [
425
+ ...a.problems,
426
+ ...b.problems,
427
+ ...duplicates(fileA, a.rows),
428
+ ...duplicates(fileB, b.rows),
429
+ ];
430
+ if (bad.length) refuse(1, bad.join("\n"));
431
+ const inB = new Map(b.rows.map((r) => [keyOf(r), r.label]));
432
+ const unmatched = onlyIn(fileA, a.rows, b.rows, fileB);
433
+ if (unmatched.length)
434
+ refuse(
435
+ 1,
436
+ `the two files label different cells — rate the same runs:\n${unmatched.join("\n")}`,
437
+ );
438
+ if (a.rows.length === 0) refuse(1, "no labels to compare");
439
+ return a.rows.map((r) => [r, String(inB.get(keyOf(r)))] as const);
440
+ }
441
+
442
+ function agree(fileA: string, fileB: string): void {
443
+ const rows = matched(fileA, fileB);
444
+ const pairs = rows.map(([r, other]) => [r.label, other] as const);
445
+ const n = pairs.length;
446
+ const k = pairs.filter(([x, y]) => x === y).length;
447
+ const [lo, hi] = wilson(k, n);
448
+ const kappa = cohenKappa(pairs);
449
+ const f = (x: number): string => x.toFixed(3);
450
+ say(
451
+ [
452
+ `n ${String(n)}`,
453
+ `agreement ${String(k)}/${String(n)} = ${f(k / n)} (95% Wilson ${f(lo)}–${f(hi)})`,
454
+ `cohen kappa ${kappa === null ? "undefined (one label only)" : f(kappa)}`,
455
+ ...rows
456
+ .filter(([r, other]) => r.label !== other)
457
+ .map(([r, other]) => `disagree ${show(r)}: ${r.label} | ${other}`),
458
+ ].join("\n"),
459
+ );
460
+ }
461
+
462
+ // ── command line ────────────────────────────────────────────────────────────────────────────
463
+ const OPTIONS = {
464
+ codebook: { type: "string" },
465
+ ledger: { type: "string" },
466
+ "analysed-at": { type: "string" },
467
+ notes: { type: "string" },
468
+ } as const;
469
+ type Values = ReturnType<
470
+ typeof parseArgs<{ options: typeof OPTIONS; allowPositionals: true }>
471
+ >["values"];
472
+
473
+ const USAGE =
474
+ "usage: labels.js validate <labels.tsv> --codebook <f> [--ledger <f>] [--analysed-at <rev> --notes <f>] | agree <a.tsv> <b.tsv>";
475
+
476
+ function main(): void {
477
+ const { values, positionals } = (() => {
478
+ try {
479
+ return parseArgs({ options: OPTIONS, allowPositionals: true });
480
+ } catch (e) {
481
+ return refuse(2, `${String(prop(e, "message"))}\n${USAGE}`);
482
+ }
483
+ })();
484
+ const [cmd, ...paths] = positionals;
485
+ const validateOk =
486
+ cmd === COMMANDS.validate &&
487
+ paths.length === 1 &&
488
+ values.codebook !== undefined &&
489
+ (values["analysed-at"] === undefined || values.notes !== undefined);
490
+ if (validateOk) validate(String(paths[0]), values);
491
+ else if (cmd === COMMANDS.agree && paths.length === 2)
492
+ agree(String(paths[0]), String(paths[1]));
493
+ else refuse(2, USAGE);
494
+ }
495
+
496
+ try {
497
+ main();
498
+ } catch (e) {
499
+ process.stderr.write(`labels: ${String(prop(e, "message"))}\n`);
500
+ process.exitCode = e instanceof Refusal ? e.exitCode : 1;
501
+ }
@@ -4,7 +4,7 @@ description: The orchestrator for writing a research paper end-to-end, from idea
4
4
  allowed-tools: [Read, Write, Edit, Grep, Glob, Bash, WebSearch, WebFetch, Agent, Skill]
5
5
  ---
6
6
 
7
- <!-- vigiles:sha256:cb2eecdb81ba766d compiled from skills/paper-pipeline/SKILL.md.spec.ts -->
7
+ <!-- vigiles:sha256:da7c692384fb79aa compiled from skills/paper-pipeline/SKILL.md.spec.ts -->
8
8
 
9
9
  # paper-pipeline — the conductor for the whole organism
10
10
 
@@ -170,6 +170,13 @@ No numbers. Nothing here happens once, and nothing here is "next".
170
170
 
171
171
  - **`build-benchmark`** — a design that inverts the pitfall; honest stats; a self-checking artifact
172
172
  (`references/artifact-checklist.md`).
173
+ - **`run-agent-grid`** — when the study is a grid of agent runs (units × arms × seeds, one agent CLI
174
+ run per cell): the registry and the author's approval gate every counted run, a sandbox and secret
175
+ probe, a smoke per model, a locked and resumable driver, an overlap audit, finished runs committed.
176
+ Between the design and the analysis.
177
+ - **`label-agent-runs`** — when the runs are in and before any outcome number: a codebook fixed
178
+ first, the automatic classifier only a pre-sort, every uncertain run read and labelled by hand, a
179
+ blind second rater with its agreement, labels frozen after the analysis.
173
180
  - **`build-label-page`** — when the study needs a person to label items (gold labels, a blind review):
174
181
  a phone page with a shared answer store, the answers exported into the study's `data/`.
175
182
  - **`draft-paper`** — section skeleton + the accept-and-get-cited properties
@@ -187,6 +187,13 @@ No numbers. Nothing here happens once, and nothing here is "next".
187
187
 
188
188
  - **\`build-benchmark\`** — a design that inverts the pitfall; honest stats; a self-checking artifact
189
189
  (\`references/artifact-checklist.md\`).
190
+ - **\`run-agent-grid\`** — when the study is a grid of agent runs (units × arms × seeds, one agent CLI
191
+ run per cell): the registry and the author's approval gate every counted run, a sandbox and secret
192
+ probe, a smoke per model, a locked and resumable driver, an overlap audit, finished runs committed.
193
+ Between the design and the analysis.
194
+ - **\`label-agent-runs\`** — when the runs are in and before any outcome number: a codebook fixed
195
+ first, the automatic classifier only a pre-sort, every uncertain run read and labelled by hand, a
196
+ blind second rater with its agreement, labels frozen after the analysis.
190
197
  - **\`build-label-page\`** — when the study needs a person to label items (gold labels, a blind review):
191
198
  a phone page with a shared answer store, the answers exported into the study's \`data/\`.
192
199
  - **\`draft-paper\`** — section skeleton + the accept-and-get-cited properties
@@ -184,10 +184,19 @@ if [ -f "$BLG" ]; then
184
184
  grep -E "^(You're|I'm skipping|Repeated entry|I couldn't open)" "$BLG" | sed 's/^/ /' | head -12
185
185
  fi
186
186
  # A cited key that never made it into the .bbl is the same failure seen from the other side.
187
+ # An IEEEtran BST control entry (`@IEEEtranBSTCTL`, activated by `\bstctlcite{key}`) is cited
188
+ # in the .aux like any key, but IEEEtran reads it as configuration and never prints it as a
189
+ # \bibitem — so it is not a missing reference. A key is exempt only when IEEEtran itself reports
190
+ # reading it as a control entry: the .blg line `** IEEEtran BST control entry "key" detected.`,
191
+ # which every IEEEtran*.bst prints. BibTeX wrote that line after parsing the build's own databases,
192
+ # so a record in a .bib the build does not read, inside `@comment{…}`, or misspelled exempts
193
+ # nothing, and every layout BibTeX accepts is recognised without parsing .bib files here.
187
194
  if [ -f "$BASE.aux" ] && [ -f "$BASE.bbl" ]; then
188
195
  missing=0
196
+ ctl=$(sed -n 's/^\*\* IEEEtran BST control entry "\(.*\)" detected\.$/\1/p' "$BASE.blg" 2>/dev/null | sort -u)
189
197
  while read -r key; do
190
198
  [ -z "$key" ] && continue
199
+ printf '%s\n' "$ctl" | grep -qxF -- "$key" && continue
191
200
  grep -qF "{$key}" "$BASE.bbl" || { echo " cited but NOT in the bibliography: $key"; missing=$((missing+1)); }
192
201
  done < <(grep -oE '\\citation\{[^}]*\}' "$BASE.aux" | sed 's/.*{//;s/}//' | tr ',' '\n' | sort -u)
193
202
  [ "$missing" -gt 0 ] && { echo " CITED-BUT-ABSENT references: $missing"; blgerr=$((blgerr+missing)); }
@@ -295,3 +295,105 @@ await checkSkill("render-paper");
295
295
  : ` under ${root}/`),
296
296
  );
297
297
  }
298
+
299
+ // ── AN IEEEtran BST CONTROL ENTRY IS NOT A MISSING REFERENCE ─────────────────
300
+ // `\\bstctlcite{key}` cites an `@IEEEtranBSTCTL{key, …}` entry, so the key lands in the .aux like any
301
+ // citation, but IEEEtran reads it as configuration and never prints a \\bibitem for it. The
302
+ // "cited but NOT in the bibliography" check failed a real IEEE paper on exactly that key (an
303
+ // "et al. after six authors" setting). The exemption comes only from IEEEtran's own .blg line
304
+ // `** IEEEtran BST control entry "key" detected.`: BibTeX wrote it after parsing the build's
305
+ // databases, so a record BibTeX never read (a .bib the build does not use, a record inside
306
+ // `@comment{…}`) or a misspelled key exempts nothing, and a genuinely missing key is still reported.
307
+ {
308
+ const HERE = dirname(fileURLToPath(import.meta.url));
309
+ const CHECK = join(HERE, "check-render.sh");
310
+ // `detected` = the keys IEEEtran reported in the .blg as control entries it read.
311
+ const paper = (citations, detected = ["BSTcontrol"]) => {
312
+ const dir = realpathSync(mkdtempSync(join(tmpdir(), "bstctl-")));
313
+ writeFileSync(
314
+ join(dir, "paper.tex"),
315
+ "\\documentclass{IEEEtran}\n\\begin{document}\n\\bstctlcite{BSTcontrol}\nText \\cite{real}.\n\\bibliographystyle{IEEEtran}\n\\bibliography{refs}\n\\end{document}\n",
316
+ );
317
+ writeFileSync(
318
+ join(dir, "paper.aux"),
319
+ citations.map((k) => `\\citation{${k}}`).join("\n") +
320
+ "\n\\bibstyle{IEEEtran}\n\\bibdata{refs}\n",
321
+ );
322
+ writeFileSync(
323
+ join(dir, "paper.bbl"),
324
+ "\\begin{thebibliography}{1}\n\\bibitem{real}\nA real reference, 2026.\n\\end{thebibliography}\n",
325
+ );
326
+ writeFileSync(
327
+ join(dir, "paper.blg"),
328
+ "This is BibTeX, Version 0.99e\nDatabase file #1: refs.bib\n-- IEEEtran.bst version 1.14 (2015/08/26) by Michael Shell.\n" +
329
+ detected
330
+ .map((k) => `** IEEEtran BST control entry "${k}" detected.\n`)
331
+ .join("") +
332
+ "\nDone.\n",
333
+ );
334
+ writeFileSync(
335
+ join(dir, "paper.log"),
336
+ "This is pdfTeX\nOutput written on paper.pdf (1 page, 1000 bytes).\n",
337
+ );
338
+ const r = spawnSync("bash", [CHECK, dir, "paper"], {
339
+ encoding: "utf8",
340
+ env: { ...process.env, NO_COMPILE: "1" },
341
+ });
342
+ // The script must run to its verdict: a run that stopped early prints no complaint either, and
343
+ // "no complaint" is what the exemption case asserts. stderr and the bash version name the cause.
344
+ assert.match(
345
+ r.stdout,
346
+ /^(PASS|FAIL)\b/m,
347
+ `check-render.sh stopped before its verdict (exit ${r.status}, ${BASH_VERSION}):\n` +
348
+ `--- stdout\n${r.stdout}--- stderr\n${r.stderr}`,
349
+ );
350
+ return r;
351
+ };
352
+ const BASH_VERSION = spawnSync("bash", ["--version"], {
353
+ encoding: "utf8",
354
+ }).stdout.split("\n")[0];
355
+
356
+ const ctlOnly = paper(["BSTcontrol", "real"]);
357
+ assert.doesNotMatch(
358
+ ctlOnly.stdout,
359
+ /CITED-BUT-ABSENT/,
360
+ `a control key IEEEtran reported reading was flagged as a missing reference:\n${ctlOnly.stdout}`,
361
+ );
362
+
363
+ // A key that starts with "-" is still a key, not a grep option.
364
+ const dashKey = paper(["-ctl", "real"], ["-ctl"]);
365
+ assert.doesNotMatch(
366
+ dashKey.stdout,
367
+ /CITED-BUT-ABSENT/,
368
+ `a control key starting with "-" was flagged:\n${dashKey.stdout}`,
369
+ );
370
+
371
+ // A misspelled \\bstctlcite key: IEEEtran detected a different key, so this one is not exempt.
372
+ const typo = paper(["BSTcontrl", "real"]);
373
+ assert.match(
374
+ typo.stdout,
375
+ /cited but NOT in the bibliography: BSTcontrl/,
376
+ `a \\bstctlcite key IEEEtran did not report must be flagged:\n${typo.stdout}`,
377
+ );
378
+
379
+ // A record BibTeX never read — in a .bib the build does not use, or inside @comment{…} — leaves
380
+ // no "detected" line, so the key is not exempt.
381
+ const unread = paper(["BSTcontrol", "real"], []);
382
+ assert.match(
383
+ unread.stdout,
384
+ /cited but NOT in the bibliography: BSTcontrol/,
385
+ `a control key IEEEtran never reported reading must be flagged:\n${unread.stdout}`,
386
+ );
387
+
388
+ const withGap = paper(["BSTcontrol", "real", "gone"]);
389
+ assert.match(
390
+ withGap.stdout,
391
+ /cited but NOT in the bibliography: gone/,
392
+ `a key with no \\bibitem must still be reported:\n${withGap.stdout}`,
393
+ );
394
+ assert.doesNotMatch(
395
+ withGap.stdout,
396
+ /bibliography: BSTcontrol/,
397
+ withGap.stdout,
398
+ );
399
+ }