paperlint 11.1.2 → 12.0.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 (38) hide show
  1. package/dist/build.d.ts.map +1 -1
  2. package/dist/build.js +18 -8
  3. package/dist/build.js.map +1 -1
  4. package/dist/cli.d.ts +1 -1
  5. package/dist/cli.js +2 -2
  6. package/dist/cli.js.map +1 -1
  7. package/dist/latex-log.d.ts +61 -26
  8. package/dist/latex-log.d.ts.map +1 -1
  9. package/dist/latex-log.js +59 -6
  10. package/dist/latex-log.js.map +1 -1
  11. package/dist/latex-loop.d.ts +21 -6
  12. package/dist/latex-loop.d.ts.map +1 -1
  13. package/dist/latex-loop.js +32 -12
  14. package/dist/latex-loop.js.map +1 -1
  15. package/dist/submission-confirm-hook.d.ts +27 -19
  16. package/dist/submission-confirm-hook.d.ts.map +1 -1
  17. package/dist/submission-confirm-hook.js +302 -95
  18. package/dist/submission-confirm-hook.js.map +1 -1
  19. package/docs/configuration.md +30 -1
  20. package/docs/e2e.md +1 -0
  21. package/docs/layout.md +22 -22
  22. package/docs/submission.md +32 -8
  23. package/fixtures/build-e2e/lost-character/PIPELINE-STATUS.md +3 -0
  24. package/fixtures/build-e2e/lost-character/expect.json +15 -0
  25. package/fixtures/build-e2e/lost-character/paper.tex +8 -0
  26. package/package.json +1 -1
  27. package/skills/submission-portal/SKILL.md +18 -12
  28. package/skills/submission-portal/SKILL.md.spec.ts +17 -11
  29. package/src/build.test.ts +46 -3
  30. package/src/build.ts +20 -8
  31. package/src/cli.test.ts +3 -3
  32. package/src/cli.ts +2 -2
  33. package/src/latex-log.harness.ts +145 -14
  34. package/src/latex-log.ts +105 -16
  35. package/src/latex-loop.harness.ts +70 -34
  36. package/src/latex-loop.ts +51 -30
  37. package/src/submission-confirm-hook.test.ts +493 -60
  38. package/src/submission-confirm-hook.ts +448 -116
@@ -1,46 +1,65 @@
1
1
  /**
2
- * The `submission-confirm` hook — no send to a venue's portal without the person's own yes.
2
+ * The `submission-confirm` hook — no send to a venue's portal without the person's own yes to it.
3
3
  * PreToolUse on `Bash` (`plugin/hooks/hooks.json`): a command that sends a submission — `paperlint
4
- * submission new|update` with `--save` or `--submit`, or a request straight at a HotCRP site — gets
5
- * `"permissionDecision": "ask"`, and Claude Code puts the question to the person, with the reason
6
- * below, in its permission prompt. Anything else: no output.
4
+ * submission new|update` with `--save` or `--submit` — runs only when the person has said yes, in an
5
+ * `AskUserQuestion` asked since the last send, to a question naming the paper folder (its full path)
6
+ * and the sha256 of the PDF it sends.
7
+ * A command that names a portal's host runs only after a yes to a question naming that host and the
8
+ * command's first 60 characters. Without the yes the command is refused (`"permissionDecision":
9
+ * "deny"`), and the reason tells the agent exactly what to ask. Anything else: no output.
7
10
  *
8
- * ── WHY "ask", AND WHY NOTHING TURNS IT OFF ──────────────────────────────────────
9
- * The hooks reference (code.claude.com/docs/en/hooks): "`ask` prompts the user to confirm"; "A
10
- * hook's `ask` also forces a permission prompt in auto mode"; the reason is "shown to the user in
11
- * the permission prompt"; and decisions combine deny > defer > ask > allow, so an `allow` rule in
12
- * the settings does not outvote it. An agent cannot answer the prompt, and no flag or variable of
13
- * this package skips it — that is the point: the send is the person's decision, taken on the dry
14
- * run's venue check (paperlint#264). The hook never denies (the person may send over a limit) and
15
- * never allows (that would skip the person).
11
+ * ── WHY A QUESTION, NOT CLAUDE CODE'S PERMISSION PROMPT ──────────────────────────
12
+ * It used to answer `ask` (paperlint#283). The Claude mobile app's permission card shows only the
13
+ * command line, not the hook's reason, so the person approved a shell line without seeing which
14
+ * paper or PDF it sends (paperlint#291). `AskUserQuestion` shows the whole question on every
15
+ * client, and a yes to a question that names the folder and the sha256 is a yes to those bytes in that
16
+ * folder: a stale build or a wrong PDF has another sha, and identical bytes in another folder (the
17
+ * same manuscript for another venue) have another path, so their yes does not count.
18
+ *
19
+ * ── WHERE THE YES IS READ ─────────────────────────────────────────────────────────
20
+ * The session's transcript (`transcript_path` in the hook's input, one JSON entry per line). A
21
+ * question is the assistant's `tool_use` named `AskUserQuestion`; its answer is the user entry that
22
+ * carries the matching `tool_result`, whose `toolUseResult.answers` maps each question's text to the
23
+ * option chosen. An answer is a yes when its first word is "yes". Every earlier `Bash` call that
24
+ * sends or names a portal — refused or not, but not this one (`tool_use_id`) — uses up the questions
25
+ * before it, so one yes lets one command through. Within one command each send takes an answer of
26
+ * its own: a yes that names two shas, or the same PDF sent twice, covers one send. When several
27
+ * questions name the same thing, the latest decides.
16
28
  *
17
29
  * ── WHAT IT SEES, AND WHAT IT IS NOT ──────────────────────────────────────────────
18
30
  * A send is judged on the command as the shell reads it (`shellLeaves`: each simple command across
19
31
  * `&&`, `;`, `|`, words unquoted, the commands inside a `bash -c "…"` string too), never on the raw
20
- * text: `submission`, `new|update` and `--save|--submit` in one command's words, or a portal's host
21
- * in any word (a HotCRP-hosted site, or a host a venue preset declares), whatever program it runs.
22
- * A false ask costs one click; a missed send cannot be taken back. NOT SEEN: an upload from a script
23
- * file that names the host only inside the file, or words built at run time (`$(…)`).
24
- *
25
- * The hook catches an agent that runs a send without asking; it is not a security boundary against
26
- * an agent that deliberately disguises a send.
32
+ * text: `submission`, `new|update` and `--save|--submit` in one command's words; or a portal's host
33
+ * in any word (a HotCRP-hosted site, or a host a venue preset declares), whatever program it runs —
34
+ * such a command may send around paperlint's venue check, and has no PDF to name, so the question
35
+ * names the host and the command instead. When the presets cannot be read, only HotCRP-hosted sites
36
+ * and paperlint sends are recognised, and a refusal says so: one bad preset does not stop the shell.
37
+ * NOT SEEN: an upload from a script file that names the host only inside the file, or words built
38
+ * at run time (`$(…)`).
27
39
  *
28
- * The reason is built from the same parse and the CLI's own argument parser (`readSend`), one block
29
- * per send; a send whose PDF cannot be told for certain is still asked about, and says so.
40
+ * The agent writes the question, so a deliberately misleading question still passes. The hook
41
+ * catches an agent that sends without asking, or asks about other bytes; it is not a security
42
+ * boundary against an agent that deliberately disguises a send.
30
43
  *
31
- * ── IT FAILS TOWARD THE QUESTION ─────────────────────────────────────────────────
32
- * The reason is built from files (the PDF, the build's facts); whatever goes wrong there, the ask
33
- * still goes out, saying what could not be read. A payload that is not JSON is asked about.
44
+ * ── IT FAILS CLOSED ───────────────────────────────────────────────────────────────
45
+ * A send whose PDF cannot be told for certain, a PDF that is not there, no transcript, a payload
46
+ * that is not JSON, a crash: refused, with what went wrong. A transcript line that is not JSON is
47
+ * not a reason to refuse every send for the rest of the session — real transcripts have them (an
48
+ * entry cut short and run into the next, measured in a live one) — so it counts as a send when its
49
+ * text may hold one, and a yes before it no longer counts. Only lines that may hold a question, an
50
+ * answer or a send are parsed, and the shell parser runs only on commands that say `submission` or
51
+ * a portal's host:
52
+ * a transcript of hundreds of megabytes is read in seconds.
34
53
  *
35
54
  * ── WHY IN-PROCESS, NOT A VIGILES PROGRAM ────────────────────────────────────────
36
55
  * It reads the build's facts with this package's own parser and hashes the PDF; a shipped vigiles
37
56
  * hook may import nothing but `vigiles/hook` (`src/folder-layout-hook.ts` says the rest). So
38
57
  * `paperlint hook submission-confirm` runs it in-process (`runHook`, src/cli.ts).
39
58
  */
40
- import { basename, isAbsolute, join, relative, resolve } from "node:path";
59
+ import { isAbsolute, join, relative, resolve } from "node:path";
41
60
  import { callerPath } from "./caller-path.ts";
42
61
  import { bodyEnd } from "./domain/body-pages.ts";
43
- import { fieldOf } from "./domain/record.ts";
62
+ import { fieldOf, isRecord } from "./domain/record.ts";
44
63
  import { sha256Hex } from "./domain/sha256.ts";
45
64
  import { messageOf } from "./domain/text.ts";
46
65
  import { factsPath, parseFactsText, type ReadFacts } from "./facts-file.ts";
@@ -73,25 +92,23 @@ export type ConfirmDeps = PresetDeps & {
73
92
  readonly readSend: (words: readonly string[]) => Send | null;
74
93
  };
75
94
 
76
- /** What Claude Code reads from a PreToolUse hook's stdout to ask the person first, with the reason. */
77
- export interface AskOutput {
95
+ /** What Claude Code reads from a PreToolUse hook's stdout to refuse the command; the agent sees the reason. */
96
+ export interface DenyOutput {
78
97
  readonly hookSpecificOutput: {
79
98
  readonly hookEventName: "PreToolUse";
80
- readonly permissionDecision: "ask";
99
+ readonly permissionDecision: "deny";
81
100
  readonly permissionDecisionReason: string;
82
101
  };
83
102
  }
84
103
 
85
- const ask = (reason: string): AskOutput => ({
104
+ const deny = (reason: string): DenyOutput => ({
86
105
  hookSpecificOutput: {
87
106
  hookEventName: "PreToolUse",
88
- permissionDecision: "ask",
107
+ permissionDecision: "deny",
89
108
  permissionDecisionReason: reason,
90
109
  },
91
110
  });
92
111
 
93
- const LAST = "Allow only if you have read the dry run's venue check.";
94
-
95
112
  /** The domain of HotCRP's hosted sites; a self-hosted portal is known by the host its preset declares. */
96
113
  const HOTCRP_HOSTED = "hotcrp.com";
97
114
 
@@ -153,26 +170,30 @@ function sendsIn(
153
170
  });
154
171
  }
155
172
 
156
- /**
157
- * What the commands send: a submission through paperlint, anything that names a venue's portal — a
158
- * HotCRP-hosted site, or a host a venue preset declares (`hosts`, read only when a word has a dot),
159
- * whatever program it runs, as a plain case-insensitive substring of a word — or nothing. Judged on
160
- * the shell's words only, never on the raw text.
161
- */
162
- export function sendKind(
163
- leaves: readonly ShellLeaf[],
164
- hosts: () => readonly string[],
165
- ): "paperlint" | "direct" | null {
166
- if (leaves.some(isSend)) return "paperlint";
167
- const words = leaves
173
+ /** The words of the commands that may name a host: those with a dot, lowercased. */
174
+ const dottedWords = (leaves: readonly ShellLeaf[]): readonly string[] =>
175
+ leaves
168
176
  .flatMap((l) => [l.head, ...l.args])
169
177
  .map((w) => w.toLowerCase())
170
178
  .filter((w) => w.includes("."));
171
- if (words.length === 0) return null;
172
- const known = [HOTCRP_HOSTED, ...hosts()]
173
- .map((h) => h.toLowerCase())
174
- .filter((h) => h !== "");
175
- return words.some((w) => known.some((h) => w.includes(h))) ? "direct" : null;
179
+
180
+ /** The portal hosts known: HotCRP's hosted sites always, then those `hosts` names; lowercased. */
181
+ const knownHosts = (hosts: readonly string[]): readonly string[] =>
182
+ [HOTCRP_HOSTED, ...hosts].map((h) => h.toLowerCase()).filter((h) => h !== "");
183
+
184
+ /**
185
+ * The portal host a command names — a HotCRP-hosted site, or a host a venue preset declares
186
+ * (`hosts`), whatever program it runs, as a plain case-insensitive substring of a word — or null.
187
+ * Judged on the shell's words only, never on the raw text.
188
+ */
189
+ function portalHost(
190
+ leaves: readonly ShellLeaf[],
191
+ hosts: readonly string[],
192
+ ): string | null {
193
+ const words = dottedWords(leaves);
194
+ return (
195
+ knownHosts(hosts).find((h) => words.some((w) => w.includes(h))) ?? null
196
+ );
176
197
  }
177
198
 
178
199
  /** The body pages the build counted: up to the references or appendix, or banal's count. */
@@ -213,12 +234,20 @@ function factsLines(
213
234
  return [...stale, bodyPages(f.value)];
214
235
  }
215
236
 
237
+ /** What a yes to one send must name: the paper folder as the hook resolves it, and the PDF's sha256 prefix. */
238
+ interface Named {
239
+ readonly folder: string;
240
+ readonly sha: string;
241
+ }
242
+
243
+ /** One send, described: what a yes must name (null when the PDF is not found), and the lines for the person. */
244
+ interface Described {
245
+ readonly named: Named | null;
246
+ readonly lines: readonly string[];
247
+ }
248
+
216
249
  /** One send, described: the paper, the PDF as the command resolves it, what the build saw. */
217
- function sendLines(
218
- send: Send,
219
- cwd: string,
220
- deps: PresetDeps,
221
- ): readonly string[] {
250
+ function describe(send: Send, cwd: string, deps: PresetDeps): Described {
222
251
  const paperDir = resolve(cwd, send.paper ?? ".");
223
252
  const p = paperPreset(paperDir, deps);
224
253
  const own = paperPdf(
@@ -228,52 +257,356 @@ function sendLines(
228
257
  const pdf = send.pdf === null ? own : resolve(cwd, send.pdf);
229
258
  const bytes = deps.files.readBytes(callerPath(pdf));
230
259
  const sha = bytes === null ? null : sha256Hex(bytes);
231
- return [
232
- `paper folder: ${send.paper ?? `${basename(paperDir)} (the current folder)`}`,
233
- `PDF: ${send.pdf ?? join(send.paper ?? ".", relative(paperDir, own))}${sha === null ? " — NOT FOUND" : `, sha256 ${sha.slice(0, 12)}`}`,
234
- ...factsLines(paperDir, sha, deps.files),
235
- ];
260
+ return {
261
+ named:
262
+ sha === null ? null : { folder: paperDir, sha: sha.slice(0, SHA_SHOWN) },
263
+ lines: [
264
+ `paper folder: ${paperDir}`,
265
+ `PDF: ${send.pdf ?? join(send.paper ?? ".", relative(paperDir, own))}${sha === null ? " — NOT FOUND" : `, sha256 ${sha.slice(0, SHA_SHOWN)}`}`,
266
+ ...factsLines(paperDir, sha, deps.files),
267
+ ],
268
+ };
236
269
  }
237
270
 
271
+ /** How much of the sha256 the reason shows, and the question must contain. */
272
+ const SHA_SHOWN = 12;
273
+
238
274
  /** A send whose PDF cannot be told for certain. */
239
275
  const UNRESOLVED =
240
276
  "could not resolve which PDF this send uploads — check the dry run";
241
277
 
278
+ const NOT_SENT = "Not run: this sends the paper to the venue's portal";
279
+ const NAMES_PORTAL = "Not run: this command names a venue's submission portal";
280
+
281
+ /** What to ask, once every send's PDF is known. */
282
+ const HOW_TO_ASK = [
283
+ "Ask the person with AskUserQuestion first, then run this command again. In the question, give:",
284
+ "- the paper folder (the full path, as written above), the PDF and its sha256 as written above (the folder path and the sha256 must both be in the question text),",
285
+ "- what the lines above say about the build (the body pages, or that it is stale or missing),",
286
+ "- the dry run's venue check, quoted word for word (its `venue rules at their default options` line and every error under it).",
287
+ 'Options: "Yes, send it" and "No". One yes lets one send through: ask one question per send, each naming its own folder and sha256.',
288
+ ].join("\n");
289
+
290
+ /** How much of a command naming a portal the question must quote. */
291
+ const COMMAND_SHOWN = 60;
292
+
293
+ /** What to ask before a command that names a portal. */
294
+ const HOW_TO_ASK_PORTAL = [
295
+ "It may send to the portal around paperlint's venue check. Ask the person with AskUserQuestion first, then run this command again: the question must contain the portal host and the command start above, exactly as written, and say what the command does.",
296
+ 'Options: "Yes, run it" and "No". One yes lets one command through. To send a paper, use `paperlint submission new|update --save` instead.',
297
+ ].join("\n");
298
+
299
+ /** Each send's block: alone, or numbered when the command has several. */
300
+ function blocks(described: readonly Described[]): readonly string[] {
301
+ return described.length === 1
302
+ ? described.flatMap((d) => d.lines)
303
+ : described.flatMap((d, n) => [
304
+ `send ${String(n + 1)} of ${String(described.length)}:`,
305
+ ...d.lines,
306
+ ]);
307
+ }
308
+
309
+ /** One question the person answered: its text, and the option they chose. */
310
+ interface Answer {
311
+ readonly question: string;
312
+ readonly answer: string;
313
+ }
314
+
315
+ /** The questions asked and answered since the last send, while reading the transcript. */
316
+ interface Since {
317
+ readonly asked: ReadonlySet<string>;
318
+ readonly answers: readonly Answer[];
319
+ }
320
+
321
+ /** A transcript line that may hold a question, an answer or a send; no other line is parsed. */
322
+ const MAY_MATTER = /"tool_use"|"toolUseResult"/u;
323
+
324
+ /** What tells an earlier send in the transcript. */
325
+ interface EarlierSend {
326
+ /** This call's own `tool_use_id`: no earlier send. */
327
+ readonly current: string | null;
328
+ /** Whether text may hold a send — cheap, so the shell parser runs only where it may matter. */
329
+ readonly mayHold: (text: string) => boolean;
330
+ /** Whether a command sends: a paperlint send, or a portal named. */
331
+ readonly sends: (command: string) => boolean;
332
+ }
333
+
334
+ /** Nothing asked or answered: the start, and the state right after a send. */
335
+ const CLEARED: Since = { asked: new Set(), answers: [] };
336
+
337
+ /** A transcript line that is not JSON. */
338
+ const BROKEN = Symbol("broken");
339
+
340
+ /**
341
+ * The person's answers to `AskUserQuestion` since the last send in the transcript, oldest first.
342
+ * A line that may matter and is not JSON (real transcripts have them: an entry cut short, run into
343
+ * the next) counts as a send when it may hold one, so a yes before it is used up — never wrongly
344
+ * kept; otherwise it is skipped, and a yes lost in it only means asking again.
345
+ */
346
+ export function answersSinceLastSend(
347
+ transcript: string,
348
+ earlier: EarlierSend,
349
+ ): readonly Answer[] {
350
+ return transcript.split("\n").reduce((since: Since, line): Since => {
351
+ if (!MAY_MATTER.test(line)) return since;
352
+ const entry = parsedLine(line);
353
+ if (entry === BROKEN) return earlier.mayHold(line) ? CLEARED : since;
354
+ const content = fieldOf(fieldOf(entry, "message"), "content");
355
+ return (Array.isArray(content) ? content : []).reduce(
356
+ (s: Since, block: unknown): Since =>
357
+ fieldOf(block, "type") === "tool_result"
358
+ ? toolResult(s, block, entry)
359
+ : toolUse(s, block, earlier),
360
+ since,
361
+ );
362
+ }, CLEARED).answers;
363
+ }
364
+
365
+ /** One transcript line, parsed; BROKEN when it is not JSON. */
366
+ function parsedLine(line: string): unknown {
367
+ try {
368
+ return JSON.parse(line);
369
+ } catch {
370
+ return BROKEN;
371
+ }
372
+ }
373
+
374
+ /** A block the assistant wrote: a question is noted; an earlier send clears what was asked before it. */
375
+ function toolUse(s: Since, block: unknown, earlier: EarlierSend): Since {
376
+ const id = fieldOf(block, "id");
377
+ if (fieldOf(block, "type") !== "tool_use" || typeof id !== "string") return s;
378
+ const name = fieldOf(block, "name");
379
+ if (name === "AskUserQuestion")
380
+ return { ...s, asked: new Set([...s.asked, id]) };
381
+ const command = fieldOf(fieldOf(block, "input"), "command");
382
+ const send =
383
+ name === "Bash" &&
384
+ id !== earlier.current &&
385
+ typeof command === "string" &&
386
+ earlier.mayHold(command) &&
387
+ earlier.sends(command);
388
+ return send ? CLEARED : s;
389
+ }
390
+
391
+ /** The person's reply to a question asked since the last send: its answers, kept in order. */
392
+ function toolResult(s: Since, block: unknown, entry: unknown): Since {
393
+ const id = fieldOf(block, "tool_use_id");
394
+ if (typeof id !== "string" || !s.asked.has(id)) return s;
395
+ const answers = fieldOf(fieldOf(entry, "toolUseResult"), "answers");
396
+ const given = Object.entries(isRecord(answers) ? answers : {}).flatMap(
397
+ ([question, answer]): readonly Answer[] =>
398
+ typeof answer === "string" ? [{ question, answer }] : [],
399
+ );
400
+ return { ...s, answers: [...s.answers, ...given] };
401
+ }
402
+
403
+ /** Whether an answer is a yes: its first word, in any case. */
404
+ const isYes = (a: Answer): boolean => /^yes\b/iu.test(a.answer.trim());
405
+
406
+ /** Whether `text` contains every one of `keys`, in any case. */
407
+ const hasAll = (text: string, keys: readonly string[]): boolean =>
408
+ keys.every((k) => text.toLowerCase().includes(k.toLowerCase()));
409
+
410
+ /** `text` as a literal inside a regular expression. */
411
+ const literal = (text: string): string =>
412
+ text.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
413
+
414
+ /**
415
+ * Whether a question names the folder `folder` and not a longer path that starts with it
416
+ * (`/w/paper` is not `/w/paper2` or `/w/paper.old`), nor one it ends: the whole path, case-sensitive.
417
+ */
418
+ const namesFolder = (question: string, folder: string): boolean =>
419
+ new RegExp(`(?<![\\w.-])${literal(folder)}(?![\\w.-])`, "u").test(question);
420
+
421
+ /** A question a yes must have been given to: what a send or a portal command needs it to name. */
422
+ type Needs = (question: string) => boolean;
423
+
424
+ /** The answers already used up by an earlier need of the same command, by index; null once one lacked its yes. */
425
+ type Taken = ReadonlySet<number> | null;
426
+
242
427
  /**
243
- * Why the person is asked, for `paperlint submission` sends: each send's paper, PDF and build — every
244
- * send in the command, one block each. Words the shell would expand (`$…`, backticks) are not
245
- * guessed at: such a command is described as unresolved.
428
+ * The answers used up after one more need: it takes the latest answer not yet taken whose question
429
+ * `names` it, and that answer must be a yes; null when there is none, or it is not.
246
430
  */
247
- export function paperlintReason(
431
+ function takeYes(
432
+ answers: readonly Answer[],
433
+ taken: Taken,
434
+ names: Needs,
435
+ ): Taken {
436
+ if (taken === null) return null;
437
+ const at = answers.findLastIndex(
438
+ (a, n) => !taken.has(n) && names(a.question),
439
+ );
440
+ const found = answers[at];
441
+ return found !== undefined && isYes(found) ? new Set([...taken, at]) : null;
442
+ }
443
+
444
+ /** What a yes to one send must name: its sha256 (any case) and its folder (the whole path, as written). */
445
+ const namesSend =
446
+ (s: Named): Needs =>
447
+ (question) =>
448
+ hasAll(question, [s.sha]) && namesFolder(question, s.folder);
449
+
450
+ /** What a yes to a command that names a portal must name: the host (any case) and the command's start, literally. */
451
+ const namesPortal =
452
+ (host: string, start: string): Needs =>
453
+ (question) =>
454
+ hasAll(question, [host]) && question.includes(start);
455
+
456
+ /**
457
+ * Whether every send and the portal command (null for none) has a yes of its own. Each takes the
458
+ * latest answer not yet taken, in turn, from one shared set: a yes used up by a send cannot also
459
+ * approve the portal, one that names two shas covers one send, and the same PDF sent twice needs
460
+ * two. (A yes that two needs could share is given to the first; the second is refused and the
461
+ * person is asked again: it fails closed.)
462
+ */
463
+ const everyNeedHasItsYes = (
464
+ answers: readonly Answer[],
465
+ sends: readonly Named[],
466
+ portal: Needs | null,
467
+ ): boolean =>
468
+ [...sends.map(namesSend), ...(portal === null ? [] : [portal])].reduce(
469
+ (taken: Taken, names) => takeYes(answers, taken, names),
470
+ new Set<number>(),
471
+ ) !== null;
472
+
473
+ /** Every send in the command, described; an unresolved one has no sha. */
474
+ function describedSends(
248
475
  command: string,
249
- leaves: readonly ShellLeaf[],
250
476
  cwd: string,
251
477
  deps: ConfirmDeps,
252
- ): string {
253
- const sends = /[`$]/u.test(command) ? [] : sendsIn(leaves, deps.readSend);
254
- const one = (x: Send | null): readonly string[] =>
255
- x === null ? [UNRESOLVED] : sendLines(x, cwd, deps);
256
- const blocks =
257
- sends.length === 0
258
- ? [UNRESOLVED]
259
- : sends.length === 1
260
- ? sends.flatMap(one)
261
- : sends.flatMap((x, n) => [
262
- `send ${String(n + 1)} of ${String(sends.length)}:`,
263
- ...one(x),
264
- ]);
265
- return [SENDS, ...blocks, LAST].join("\n");
266
- }
267
-
268
- /** The ask when the hook's input cannot be read at all: it cannot tell, so the person decides. */
269
- export const UNREADABLE_INPUT: AskOutput = ask(
270
- `paperlint could not read this command, so it cannot tell whether it sends a paper to a venue's portal. ${LAST}`,
271
- );
478
+ ): readonly Described[] {
479
+ const sends = /[`$]/u.test(command)
480
+ ? []
481
+ : sendsIn(deps.shellLeaves(command), deps.readSend);
482
+ return (sends.length === 0 ? [null] : sends).map((x): Described =>
483
+ x === null ? { named: null, lines: [UNRESOLVED] } : describe(x, cwd, deps),
484
+ );
485
+ }
486
+
487
+ /** The refusal of a send whose PDF is not known or not there: there is no sha256 to ask about. */
488
+ const noPdf = (described: readonly Described[]): readonly string[] => [
489
+ `${NOT_SENT}, and paperlint cannot find the PDF it uploads, so there is no sha256 to ask the person about.`,
490
+ ...blocks(described),
491
+ "Name the paper folder and the PDF plainly (no cd, no $…), build the PDF, run the dry run, and ask then.",
492
+ ];
493
+
494
+ /** The portal hosts the venue presets declare, or — when they cannot be read — none, and why. */
495
+ interface Hosts {
496
+ readonly hosts: readonly string[];
497
+ readonly failed: string | null;
498
+ }
499
+
500
+ /** The presets' hosts; when they cannot be read, none, and why. */
501
+ function readHosts(cwd: string, deps: ConfirmDeps): Hosts {
502
+ try {
503
+ return { hosts: deps.portalHosts(cwd), failed: null };
504
+ } catch (e) {
505
+ return { hosts: [], failed: messageOf(e) };
506
+ }
507
+ }
508
+
509
+ /** What one command is: whether it sends through paperlint, the portal host it names, the hosts known. */
510
+ interface Command {
511
+ readonly text: string;
512
+ readonly cwd: string;
513
+ readonly paperlint: boolean;
514
+ readonly host: string | null;
515
+ readonly hosts: Hosts;
516
+ }
517
+
518
+ /** The line a refusal ends with when the presets could not be read; none when they could. */
519
+ const fallbackLines = (h: Hosts): readonly string[] =>
520
+ h.failed === null
521
+ ? []
522
+ : [
523
+ `The venue presets could not be read (${h.failed}), so only HotCRP-hosted sites and paperlint sends are recognised until they are fixed.`,
524
+ ];
525
+
526
+ /**
527
+ * A command that sends: let through (null) when the person said yes to it since the last send —
528
+ * each paperlint send by its paper folder and its PDF's sha256, a yes of its own for each, a portal
529
+ * named by its host and the command's start —
530
+ * else the lines of the refusal, with what to ask.
531
+ */
532
+ function decide(
533
+ c: Command,
534
+ payload: unknown,
535
+ deps: ConfirmDeps,
536
+ ): readonly string[] | null {
537
+ const described = c.paperlint ? describedSends(c.text, c.cwd, deps) : [];
538
+ const named = described.flatMap((d) => (d.named === null ? [] : [d.named]));
539
+ if (named.length < described.length) return noPdf(described);
540
+ const base = c.paperlint ? NOT_SENT : NAMES_PORTAL;
541
+ const read = transcriptOf(payload, deps.files);
542
+ if (typeof read !== "string")
543
+ return [
544
+ `${base}, and ${read.failed}, so paperlint cannot see the person's yes.`,
545
+ ];
546
+ const start = c.text.trim().slice(0, COMMAND_SHOWN);
547
+ const answers = answersSinceLastSend(read, earlierSend(c, payload, deps));
548
+ const portal = c.host === null ? null : namesPortal(c.host, start);
549
+ if (everyNeedHasItsYes(answers, named, portal)) return null;
550
+ return [
551
+ `${base}, and the person has not said yes to it since the last send.`,
552
+ ...blocks(described),
553
+ ...(c.paperlint ? [HOW_TO_ASK] : []),
554
+ ...portalLines(c.host, start),
555
+ ];
556
+ }
272
557
 
273
- const SENDS =
274
- "This sends the paper to the venue's portal (paperlint submission with --save or --submit).";
558
+ /** What a refusal says about a portal the command names: the host, the start, what to ask; none for none. */
559
+ const portalLines = (host: string | null, start: string): readonly string[] =>
560
+ host === null
561
+ ? []
562
+ : [`portal host: ${host}`, `command start: ${start}`, HOW_TO_ASK_PORTAL];
275
563
 
276
- const DIRECT = `This command names a venue's submission portal, so it may send to it outside paperlint and its venue check. ${LAST}`;
564
+ /** How an earlier send is told in the transcript: by the same matching as this command. */
565
+ function earlierSend(
566
+ c: Command,
567
+ payload: unknown,
568
+ deps: ConfirmDeps,
569
+ ): EarlierSend {
570
+ const known = knownHosts(c.hosts.hosts);
571
+ return {
572
+ current: stringField(payload, "tool_use_id"),
573
+ mayHold: (text) => {
574
+ const plain = text.replace(/["'\\]/gu, "").toLowerCase();
575
+ return (
576
+ plain.includes("submission") || known.some((h) => plain.includes(h))
577
+ );
578
+ },
579
+ sends: (command) => {
580
+ const leaves = allLeaves(command, deps.shellLeaves);
581
+ return leaves.some(isSend) || portalHost(leaves, known) !== null;
582
+ },
583
+ };
584
+ }
585
+
586
+ /** A string field of the payload; null for none. */
587
+ function stringField(payload: unknown, key: string): string | null {
588
+ const v = fieldOf(payload, key);
589
+ return typeof v === "string" ? v : null;
590
+ }
591
+
592
+ /** The session's transcript as text, or why it could not be read. */
593
+ function transcriptOf(
594
+ payload: unknown,
595
+ files: Files,
596
+ ): string | { readonly failed: string } {
597
+ const path = stringField(payload, "transcript_path");
598
+ if (path === null || !isAbsolute(path))
599
+ return { failed: "the hook was given no transcript_path" };
600
+ const bytes = files.readBytes(callerPath(path));
601
+ return bytes === null
602
+ ? { failed: `the transcript ${path} could not be read` }
603
+ : new TextDecoder().decode(bytes);
604
+ }
605
+
606
+ /** The refusal when the hook's input cannot be read at all: it cannot tell, so it does not let it run. */
607
+ export const UNREADABLE_INPUT: DenyOutput = deny(
608
+ "Not run: paperlint could not read this command, so it cannot tell whether it sends a paper to a venue's portal.",
609
+ );
277
610
 
278
611
  /** The payload, parsed; null when it is not JSON. */
279
612
  function parsed(stdin: string): unknown {
@@ -284,47 +617,46 @@ function parsed(stdin: string): unknown {
284
617
  }
285
618
  }
286
619
 
620
+ /** One command, judged: null to let it run, or the refusal. */
621
+ function judge(
622
+ text: string,
623
+ cwd: string,
624
+ payload: unknown,
625
+ deps: ConfirmDeps,
626
+ ): DenyOutput | null {
627
+ const leaves = allLeaves(text, deps.shellLeaves);
628
+ const paperlint = leaves.some(isSend);
629
+ // The presets are read only for a command that may send: anything else pays nothing.
630
+ if (!paperlint && dottedWords(leaves).length === 0) return null;
631
+ const hosts = readHosts(cwd, deps);
632
+ const host = portalHost(leaves, hosts.hosts);
633
+ if (!paperlint && host === null) return null;
634
+ const lines = decide({ text, cwd, paperlint, host, hosts }, payload, deps);
635
+ return lines === null
636
+ ? null
637
+ : deny([...lines, ...fallbackLines(hosts)].join("\n"));
638
+ }
639
+
287
640
  /**
288
- * The hook on one payload, as Claude Code sends it on stdin: the ask, or null for a command that
289
- * sends nothing. Never throws: what it cannot read, it asks about.
641
+ * The hook on one payload, as Claude Code sends it on stdin: a refusal, or null to let the command
642
+ * run (it sends nothing, or every send in it has the person's yes). Never throws: a crash refuses.
290
643
  */
291
644
  export function submissionConfirm(
292
645
  stdin: string,
293
646
  deps: ConfirmDeps,
294
- ): AskOutput | null {
647
+ ): DenyOutput | null {
295
648
  const payload = parsed(stdin);
296
649
  if (payload === null) return UNREADABLE_INPUT;
297
650
  const raw = fieldOf(fieldOf(payload, "tool_input"), "command");
298
- const command = typeof raw === "string" ? raw : "";
651
+ const text = typeof raw === "string" ? raw : "";
299
652
  const given = fieldOf(payload, "cwd");
300
653
  const cwd =
301
654
  typeof given === "string" && isAbsolute(given) ? given : resolve(".");
302
- const kind = kindOf(command, cwd, deps);
303
- if (kind === null) return null;
304
- if (kind !== "paperlint" && kind !== "direct") return ask(kind.failed);
305
- if (kind === "direct") return ask(DIRECT);
306
655
  try {
307
- return ask(paperlintReason(command, deps.shellLeaves(command), cwd, deps));
656
+ return judge(text, cwd, payload, deps);
308
657
  } catch (e) {
309
- return ask(
310
- `${SENDS} paperlint could not say what will be sent: ${messageOf(e)}. ${LAST}`,
311
- );
312
- }
313
- }
314
-
315
- /** What the command sends, or — when reading it failed — the reason to ask anyway. */
316
- function kindOf(
317
- command: string,
318
- cwd: string,
319
- deps: ConfirmDeps,
320
- ): ReturnType<typeof sendKind> | { readonly failed: string } {
321
- try {
322
- return sendKind(allLeaves(command, deps.shellLeaves), () =>
323
- deps.portalHosts(cwd),
658
+ return deny(
659
+ `Not run: paperlint could not check whether this command sends a paper: ${messageOf(e)}.`,
324
660
  );
325
- } catch (e) {
326
- return {
327
- failed: `paperlint could not tell what this command sends (${messageOf(e)}). ${LAST}`,
328
- };
329
661
  }
330
662
  }