paperlint 2.0.1 → 3.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 (226) hide show
  1. package/CLAUDE.md +6 -1
  2. package/CONTRIBUTING.md +21 -0
  3. package/README.md +231 -180
  4. package/action.yml +4 -6
  5. package/dist/adapters/references/index.d.ts +13 -0
  6. package/dist/adapters/references/index.d.ts.map +1 -0
  7. package/dist/adapters/references/index.js +55 -0
  8. package/dist/adapters/references/index.js.map +1 -0
  9. package/dist/adapters/references/reach.io.d.ts +3 -0
  10. package/dist/adapters/references/reach.io.d.ts.map +1 -0
  11. package/dist/adapters/references/reach.io.js +14 -0
  12. package/dist/adapters/references/reach.io.js.map +1 -0
  13. package/dist/build-engine.d.ts.map +1 -1
  14. package/dist/build-engine.js +5 -2
  15. package/dist/build-engine.js.map +1 -1
  16. package/dist/build.d.ts +16 -1
  17. package/dist/build.d.ts.map +1 -1
  18. package/dist/build.js +40 -5
  19. package/dist/build.js.map +1 -1
  20. package/dist/cli.d.ts +37 -20
  21. package/dist/cli.d.ts.map +1 -1
  22. package/dist/cli.js +377 -251
  23. package/dist/cli.js.map +1 -1
  24. package/dist/doctor.d.ts.map +1 -1
  25. package/dist/doctor.js +37 -52
  26. package/dist/doctor.js.map +1 -1
  27. package/dist/facts-file.d.ts +33 -9
  28. package/dist/facts-file.d.ts.map +1 -1
  29. package/dist/facts-file.js +117 -17
  30. package/dist/facts-file.js.map +1 -1
  31. package/dist/hooks-settings.d.ts +0 -21
  32. package/dist/hooks-settings.d.ts.map +1 -1
  33. package/dist/hooks-settings.js +17 -135
  34. package/dist/hooks-settings.js.map +1 -1
  35. package/dist/init.d.ts +40 -19
  36. package/dist/init.d.ts.map +1 -1
  37. package/dist/init.js +144 -109
  38. package/dist/init.js.map +1 -1
  39. package/dist/link-skills.d.ts +1 -1
  40. package/dist/link-skills.d.ts.map +1 -1
  41. package/dist/link-skills.js +11 -29
  42. package/dist/link-skills.js.map +1 -1
  43. package/dist/new-paper.d.ts +2 -2
  44. package/dist/new-paper.d.ts.map +1 -1
  45. package/dist/new-paper.js +6 -2
  46. package/dist/new-paper.js.map +1 -1
  47. package/dist/paper-files.d.ts +63 -0
  48. package/dist/paper-files.d.ts.map +1 -0
  49. package/dist/paper-files.js +69 -0
  50. package/dist/paper-files.js.map +1 -0
  51. package/dist/paper-settings.d.ts +40 -0
  52. package/dist/paper-settings.d.ts.map +1 -0
  53. package/dist/paper-settings.js +144 -0
  54. package/dist/paper-settings.js.map +1 -0
  55. package/dist/ports/check-references.d.ts +31 -0
  56. package/dist/ports/check-references.d.ts.map +1 -0
  57. package/dist/ports/check-references.js +7 -0
  58. package/dist/ports/check-references.js.map +1 -0
  59. package/dist/presets.d.ts +84 -0
  60. package/dist/presets.d.ts.map +1 -0
  61. package/dist/presets.js +184 -0
  62. package/dist/presets.js.map +1 -0
  63. package/dist/reference-rules.d.ts +47 -0
  64. package/dist/reference-rules.d.ts.map +1 -0
  65. package/dist/reference-rules.js +156 -0
  66. package/dist/reference-rules.js.map +1 -0
  67. package/dist/references.d.ts +36 -0
  68. package/dist/references.d.ts.map +1 -0
  69. package/dist/references.js +91 -0
  70. package/dist/references.js.map +1 -0
  71. package/dist/rules-config.d.ts +11 -2
  72. package/dist/rules-config.d.ts.map +1 -1
  73. package/dist/rules-config.js +25 -11
  74. package/dist/rules-config.js.map +1 -1
  75. package/dist/structure.d.ts.map +1 -1
  76. package/dist/structure.js +3 -2
  77. package/dist/structure.js.map +1 -1
  78. package/dist/tex-requirements.d.ts +63 -7
  79. package/dist/tex-requirements.d.ts.map +1 -1
  80. package/dist/tex-requirements.js +91 -24
  81. package/dist/tex-requirements.js.map +1 -1
  82. package/dist/types.d.ts +20 -18
  83. package/dist/types.d.ts.map +1 -1
  84. package/dist/venue-rules.d.ts +109 -0
  85. package/dist/venue-rules.d.ts.map +1 -0
  86. package/dist/venue-rules.js +389 -0
  87. package/dist/venue-rules.js.map +1 -0
  88. package/docs/configuration.md +152 -77
  89. package/docs/e2e.md +1 -1
  90. package/docs/install.md +25 -40
  91. package/docs/optional-rules.md +26 -24
  92. package/docs/rules.md +181 -15
  93. package/eslint-rules/bib-reachable-entry.mjs +75 -0
  94. package/eslint-rules/latex-language.mjs +29 -1
  95. package/eslint-rules/paper-stages.harness.mjs +1 -117
  96. package/eslint-rules/paper-stages.mjs +0 -120
  97. package/eslint-rules/paper-stages.mutations.mjs +0 -76
  98. package/eslint-rules/paper-typography.mjs +267 -192
  99. package/eslint-rules/paper-typography.test.mjs +297 -0
  100. package/eslint-rules/papers.harness.mjs +8 -12
  101. package/eslint-rules/papers.mjs +17 -27
  102. package/eslint-rules/pdf-last-page-balance.mjs +23 -5
  103. package/eslint-rules/review-frontmatter.mjs +118 -0
  104. package/eslint-rules/review-frontmatter.schema.json +36 -0
  105. package/eslint-rules/review-frontmatter.test.mjs +118 -0
  106. package/eslint-rules/sibling-frontmatter.mjs +19 -0
  107. package/eslint-rules/sibling-frontmatter.schema.json +14 -0
  108. package/eslint.config.mjs +0 -15
  109. package/fixtures/build-e2e/acmart/paperlint.json +1 -0
  110. package/fixtures/real-markdown-paper/baseline.json +6 -7
  111. package/hooks/hooks.harness.mjs +39 -98
  112. package/hooks/hooks.mutations.mjs +6 -6
  113. package/hooks/paper-edit-guard.hook.d.mts +2 -3
  114. package/hooks/paper-edit-guard.hook.mjs +31 -42
  115. package/hooks/paper-skills-nudge.hook.mjs +21 -19
  116. package/hooks/paper-status-gates.hook.mjs +21 -19
  117. package/hooks/paper-status-gates.sh +8 -13
  118. package/lib/paper-config.d.mts +15 -18
  119. package/lib/paper-config.harness.mjs +18 -35
  120. package/lib/paper-config.mjs +90 -81
  121. package/lib/paper-config.mutations.mjs +2 -2
  122. package/lib/skill-checks.mjs +1 -1
  123. package/lib/skill-trigger-cases.harness.mjs +9 -8
  124. package/lib/skill-trigger-cases.mjs +7 -15
  125. package/package.json +1 -1
  126. package/scripts/eslint-report-guard.mjs +1 -1
  127. package/scripts/harness-api.frozen.json +0 -3
  128. package/scripts/mutation-batteries.frozen.json +4 -13
  129. package/skills/analyze-sibling-paper/SKILL.md +16 -12
  130. package/skills/analyze-sibling-paper/SKILL.md.spec.ts +15 -11
  131. package/skills/cold-read-diff/SKILL.md +20 -1
  132. package/skills/cold-read-diff/SKILL.md.spec.ts +19 -0
  133. package/skills/find-venue/SKILL.md +5 -1
  134. package/skills/find-venue/SKILL.md.spec.ts +4 -0
  135. package/skills/grade-paper-writing/SKILL.md +20 -1
  136. package/skills/grade-paper-writing/SKILL.md.spec.ts +19 -0
  137. package/skills/paper-pipeline/PIPELINE-MAP.md +14 -4
  138. package/skills/paper-pipeline/SKILL.md +4 -3
  139. package/skills/paper-pipeline/SKILL.md.spec.ts +3 -2
  140. package/skills/paper-pipeline/scripts/consumer.d.mts +0 -1
  141. package/skills/paper-pipeline/scripts/consumer.harness.mjs +8 -12
  142. package/skills/paper-pipeline/scripts/consumer.mjs +19 -33
  143. package/skills/paper-pipeline/scripts/extract-ref-facts.harness.mjs +6 -4
  144. package/skills/paper-pipeline/scripts/run-mechanical.mjs +2 -11
  145. package/skills/plan-paper-timeline/SKILL.md +2 -2
  146. package/skills/plan-paper-timeline/SKILL.md.spec.ts +1 -1
  147. package/skills/plan-paper-timeline/plan-paper-timeline.effects.harness.mjs +1 -1
  148. package/skills/plan-paper-timeline/plan-paper-timeline.effects.mutations.mjs +2 -2
  149. package/skills/render-paper/check-render.sh +2 -3
  150. package/skills/render-paper/extract-pdf-facts.harness.mjs +5 -5
  151. package/skills/render-paper/extract-pdf-facts.mjs +4 -4
  152. package/skills/render-paper/render-paper.harness.mjs +2 -2
  153. package/skills/study-accepted-papers/SKILL.md +2 -1
  154. package/skills/study-accepted-papers/SKILL.md.spec.ts +1 -0
  155. package/skills/submit-paper/SKILL.md +9 -1
  156. package/skills/submit-paper/SKILL.md.spec.ts +8 -0
  157. package/skills/submit-paper/references/venues/acm-sigconf.jsonc +86 -0
  158. package/skills/submit-paper/references/venues/agenticdev.jsonc +34 -102
  159. package/skills/submit-paper/references/venues/aisec.jsonc +21 -82
  160. package/skills/submit-paper/references/venues/realm.jsonc +45 -43
  161. package/skills/submit-paper/references/venues/tex-base.jsonc +1 -1
  162. package/skills/submit-paper/references/venues/venue-profile.schema.json +119 -29
  163. package/skills/verify-citations/SKILL.md +10 -1
  164. package/skills/verify-citations/SKILL.md.spec.ts +9 -0
  165. package/skills/verify-citations/scripts/bib-authors.mjs +48 -26
  166. package/src/adapters/references/index.ts +102 -0
  167. package/src/adapters/references/reach.io.ts +12 -0
  168. package/src/build-engine.ts +10 -2
  169. package/src/build.harness.mjs +31 -22
  170. package/src/build.ts +65 -8
  171. package/src/cli.harness.mjs +151 -294
  172. package/src/cli.mutations.mjs +35 -44
  173. package/src/cli.ts +457 -288
  174. package/src/doctor.harness.mjs +41 -84
  175. package/src/doctor.mutations.mjs +9 -9
  176. package/src/doctor.ts +41 -61
  177. package/src/facts-file.test.ts +47 -7
  178. package/src/facts-file.ts +145 -24
  179. package/src/hooks-settings.harness.mjs +7 -92
  180. package/src/hooks-settings.mutations.mjs +2 -17
  181. package/src/hooks-settings.ts +18 -162
  182. package/src/init.test.ts +206 -0
  183. package/src/init.ts +193 -159
  184. package/src/link-skills.harness.mjs +1 -38
  185. package/src/link-skills.mutations.mjs +9 -5
  186. package/src/link-skills.ts +10 -34
  187. package/src/lint-fix.test.ts +120 -0
  188. package/src/new-paper.harness.mjs +9 -6
  189. package/src/new-paper.test.ts +133 -0
  190. package/src/new-paper.ts +7 -2
  191. package/src/paper-files.test.ts +290 -0
  192. package/src/paper-files.ts +144 -0
  193. package/src/paper-settings-commands.test.ts +274 -0
  194. package/src/paper-settings.test.ts +249 -0
  195. package/src/paper-settings.ts +208 -0
  196. package/src/ports/check-references.ts +28 -0
  197. package/src/presets.test.ts +236 -0
  198. package/src/presets.ts +297 -0
  199. package/src/reference-rules.ts +229 -0
  200. package/src/references.test.ts +209 -0
  201. package/src/references.ts +138 -0
  202. package/src/rules-config.harness.mjs +24 -8
  203. package/src/rules-config.ts +30 -13
  204. package/src/structure.harness.mjs +6 -4
  205. package/src/structure.ts +3 -2
  206. package/src/tex-requirements.harness.mjs +11 -17
  207. package/src/tex-requirements.ts +179 -29
  208. package/src/types.ts +30 -18
  209. package/src/venue-rules.test.ts +554 -0
  210. package/src/venue-rules.ts +571 -0
  211. package/templates/paper/paperlint.json +4 -0
  212. package/eslint-rules/doc-fields.harness.mjs +0 -336
  213. package/eslint-rules/doc-fields.mjs +0 -186
  214. package/eslint-rules/doc-fields.mutations.mjs +0 -96
  215. package/eslint-rules/paper-typography.harness.mjs +0 -291
  216. package/eslint-rules/paper-typography.mutations.mjs +0 -131
  217. package/eslint-rules/review-findings-cause.harness.mjs +0 -228
  218. package/eslint-rules/review-findings-cause.mjs +0 -135
  219. package/eslint-rules/review-findings-cause.mutations.mjs +0 -72
  220. package/fixtures/build-e2e/acmart/venue.json +0 -1
  221. package/fixtures/paper-stages/authors-ran/PIPELINE-STATUS.md +0 -16
  222. package/fixtures/paper-stages/marker-in-prose/PIPELINE-STATUS.md +0 -17
  223. package/fixtures/review-findings-cause/clean.md +0 -17
  224. package/fixtures/review-findings-cause/defect.md +0 -14
  225. package/fixtures/review-findings-cause/old-debt.md +0 -14
  226. package/fixtures/review-findings-cause/quiet-in-fence.md +0 -16
@@ -43,13 +43,13 @@
43
43
  * this cannot — there is only one file.
44
44
  *
45
45
  * ── WHERE THE CONSUMER'S PAPERS ARE ─────────────────────────────────────────
46
- * One declaration, `"paperlint": { "papersDir": "…" }`, read the way a hook is able to
47
- * read anything at all: `needs: [provide("pkg", "cat \"${CLAUDE_PROJECT_DIR:-.}/package.json\"")]`.
46
+ * One declaration, `{ "papersDir": "…" }` in the root `paperlint.json` (optional; default
47
+ * `papers`), read the way a hook is able to read anything at all: a `provide` that cats the file.
48
48
  * The alternatives were measured and killed in `eslint-rules/papers.mjs` — an env var cannot be
49
49
  * read from a hook at all (no imports), and a symlinked root makes ESLint report zero files.
50
50
  *
51
51
  * 🔴 THE PATH IS ANCHORED, AND THAT IS NOT DECORATION. vigiles runs a provider "via execSync in
52
- * the hook's cwd", so a bare `cat package.json` resolves against whatever directory the hook
52
+ * the hook's cwd", so a bare `cat paperlint.json` resolves against whatever directory the hook
53
53
  * PROCESS happens to have — which is set by the consumer's wiring, a string this hook cannot
54
54
  * see and does not control. With the wiring this package documents (`cd "$CLAUDE_PROJECT_DIR"
55
55
  * && node …`) the bare read is correct; without it, any tool cwd outside the project root makes
@@ -63,8 +63,8 @@
63
63
  * when the variable is absent, so this is strictly wider than what it replaces.
64
64
  *
65
65
  * 🔴 AN UNREADABLE DECLARATION DENIES, IT DOES NOT DEFAULT. Measured 2026-09-12: when the
66
- * provider's command fails — no `package.json`, or one with conflict markers in it — `e.ctx.pkg`
67
- * arrives as the EMPTY STRING and the runtime says nothing. A `try { JSON.parse } catch { use the
66
+ * provider's command fails — an unreadable file, or one with conflict markers in it — `e.ctx.config`
67
+ * arrives as the EMPTY STRING (or text that is not JSON) and the runtime says nothing. A `try { JSON.parse } catch { use the
68
68
  * default }` therefore turns a broken declaration into a silently permissive gate: the prefix
69
69
  * quietly becomes `papers`, nothing under the real root matches, and every Bash write to a paper
70
70
  * is allowed by a gate that still looks installed and green. So parse failure is a `deny` that
@@ -107,19 +107,29 @@ import {
107
107
  deny,
108
108
  } from "vigiles/hook";
109
109
 
110
- /** The key every carrier of this package reads its consumer-specific settings from. */
110
+ /** The tool's name — the package this hook resolves its scripts through. */
111
111
  export const CONFIG_KEY = "paperlint";
112
- /** The key's name before 2.0.0 — still read, a copy of `lib/paper-config.mjs`. */
113
- export const LEGACY_CONFIG_KEY = "research-paper-pipeline";
112
+ /** The settings file at the project root. Optional: absent means every default. */
113
+ export const CONFIG_FILE = "paperlint.json";
114
114
  /** The default. A consumer that declares nothing is assumed to keep papers in `papers/`. */
115
115
  export const DEFAULT_PAPERS_ROOT = "papers";
116
116
  /**
117
- * The field under CONFIG_KEY that names the papers directory, and its old name. A copy of the
117
+ * The field of the root `paperlint.json` that names the papers directory. These are copies of the
118
118
  * constants in `lib/paper-config.mjs` (a hook may import nothing but `vigiles/hook`);
119
119
  * `lib/paper-config.harness.mjs` checks that the copies match.
120
120
  */
121
121
  export const PAPERS_DIR_FIELD = "papersDir";
122
- export const OLD_PAPERS_DIR_FIELD = "papers";
122
+
123
+ /**
124
+ * The provider: the root `paperlint.json`, or `{}` when the project has none — so an ABSENT file
125
+ * (every default) and an UNREADABLE one (the empty string the runtime gives a failed provider)
126
+ * stay two different inputs.
127
+ *
128
+ * 🔴 THE PATH IS ANCHORED TO THE PROJECT ROOT. vigiles runs a provider "via execSync in the hook's
129
+ * cwd", which is the consumer's wiring — a string this hook cannot see. A bare relative path reads
130
+ * from whatever directory the Bash tool last moved to.
131
+ */
132
+ const READ_CONFIG = `f="\${CLAUDE_PROJECT_DIR:-.}/${CONFIG_FILE}"; if [ -e "$f" ]; then cat "$f"; else echo '{}'; fi`;
123
133
 
124
134
  /**
125
135
  * The declared papers root, or a `deny` explaining why there is not one.
@@ -140,51 +150,30 @@ export const OLD_PAPERS_DIR_FIELD = "papers";
140
150
  * doesn't restrict exporting outward, and the reverse direction, CLI → hook, is free.
141
151
  * Returns the root string, or a rejection object: the caller tells them apart by `typeof`.
142
152
  */
143
- export const papersRoot = (rawPkg) => {
144
- let pkg;
153
+ export const papersRoot = (rawConfig) => {
154
+ let config;
145
155
  try {
146
- pkg = JSON.parse(rawPkg);
156
+ config = JSON.parse(rawConfig);
147
157
  } catch {
148
158
  return deny(
149
- `${CONFIG_KEY}: this gate could not read the consumer's package.json, so it does not know ` +
159
+ `${CONFIG_KEY}: this gate could not read the project's ${CONFIG_FILE}, so it does not know ` +
150
160
  `where papers live and is refusing rather than guessing.\n` +
151
161
  `The declaration it needs is:\n` +
152
- ` "${CONFIG_KEY}": { "${PAPERS_DIR_FIELD}": "path/to/papers" }\n` +
153
- `(no key at all is fine too — the default is "${DEFAULT_PAPERS_ROOT}").\n` +
154
- `If package.json is mid-merge or otherwise broken, fix it with Edit or Write: file ` +
162
+ ` { "${PAPERS_DIR_FIELD}": "path/to/papers" }\n` +
163
+ `(no file at all is fine too — the default is "${DEFAULT_PAPERS_ROOT}").\n` +
164
+ `If ${CONFIG_FILE} is mid-merge or otherwise broken, fix it with Edit or Write: file ` +
155
165
  `tools do not pass through this gate, so that path out is always open.\n` +
156
166
  `This refuses rather than falling back on purpose. A gate whose prefix quietly became ` +
157
167
  `the default would allow every Bash write to the real papers root while still looking ` +
158
168
  `installed and green.`,
159
169
  );
160
170
  }
161
- // The settings sit under CONFIG_KEY, or under its old name. Both, with different contents, is
162
- // refused: the gate cannot know which one the author means.
163
- const current = pkg?.[CONFIG_KEY];
164
- const legacy = pkg?.[LEGACY_CONFIG_KEY];
165
- if (
166
- current !== undefined &&
167
- legacy !== undefined &&
168
- JSON.stringify(current) !== JSON.stringify(legacy)
169
- )
170
- return deny(
171
- `package.json has both "${CONFIG_KEY}" and "${LEGACY_CONFIG_KEY}", and they differ. ` +
172
- `Keep "${CONFIG_KEY}" and delete "${LEGACY_CONFIG_KEY}" (its old name). ` +
173
- `Fix it with Edit or Write: file tools do not pass through this gate.`,
174
- );
175
- const settings = current ?? legacy;
176
- // The old field name is refused, not read as a fallback: this gate would otherwise guard the
177
- // default directory while the consumer believes it guards the one they declared.
178
- if (settings && Object.hasOwn(settings, OLD_PAPERS_DIR_FIELD))
179
- return deny(
180
- `"${OLD_PAPERS_DIR_FIELD}" was renamed to "${PAPERS_DIR_FIELD}" in package.json → "${CONFIG_KEY}"`,
181
- );
182
- const declared = settings?.[PAPERS_DIR_FIELD];
171
+ const declared = config?.[PAPERS_DIR_FIELD];
183
172
  const root = declared === undefined ? DEFAULT_PAPERS_ROOT : declared;
184
173
  if (typeof root !== "string" || root.length === 0)
185
174
  return deny(
186
175
  `${CONFIG_KEY}: "${PAPERS_DIR_FIELD}" must be a non-empty string, got ${JSON.stringify(root)}.\n` +
187
- `Fix it in the consumer's package.json. This gate refuses while the value is unusable: ` +
176
+ `Fix it in the project's ${CONFIG_FILE}. This gate refuses while the value is unusable: ` +
188
177
  `an empty prefix matches nothing, so the gate would pass every paper write in silence.`,
189
178
  );
190
179
  // One trailing slash at most, whatever the consumer typed: `"papersDir": "docs/papers/"` reaches
@@ -425,9 +414,9 @@ const namesPaperSource = (raw, prefixes) =>
425
414
  export default experimental_defineHook({
426
415
  on: "PreToolUse",
427
416
  match: tools("Bash"),
428
- needs: [provide("pkg", 'cat "${CLAUDE_PROJECT_DIR:-.}/package.json"')],
417
+ needs: [provide("config", READ_CONFIG)],
429
418
  decide: (e) => {
430
- const root = papersRoot(e.ctx.pkg);
419
+ const root = papersRoot(e.ctx.config);
431
420
  // A `deny` object rather than a string means the root could not be established. Returning it
432
421
  // unchanged is deliberate: there is no root to continue with, so there is nothing to decide.
433
422
  if (typeof root !== "string") return root;
@@ -38,19 +38,29 @@ import {
38
38
  nothing,
39
39
  } from "vigiles/hook";
40
40
 
41
- /** The key every carrier of this package reads its consumer-specific settings from. */
41
+ /** The tool's name — the package this hook resolves its scripts through. */
42
42
  export const CONFIG_KEY = "paperlint";
43
- /** The key's name before 2.0.0 — still read, a copy of `lib/paper-config.mjs`. */
44
- export const LEGACY_CONFIG_KEY = "research-paper-pipeline";
43
+ /** The settings file at the project root. Optional: absent means every default. */
44
+ export const CONFIG_FILE = "paperlint.json";
45
45
  /** The default. A consumer that declares nothing is assumed to keep papers in `papers/`. */
46
46
  export const DEFAULT_PAPERS_ROOT = "papers";
47
47
  /**
48
- * The field under CONFIG_KEY that names the papers directory, and its old name. A copy of the
48
+ * The field of the root `paperlint.json` that names the papers directory. These are copies of the
49
49
  * constants in `lib/paper-config.mjs` (a hook may import nothing but `vigiles/hook`);
50
50
  * `lib/paper-config.harness.mjs` checks that the copies match.
51
51
  */
52
52
  export const PAPERS_DIR_FIELD = "papersDir";
53
- export const OLD_PAPERS_DIR_FIELD = "papers";
53
+
54
+ /**
55
+ * The provider: the root `paperlint.json`, or `{}` when the project has none — so an ABSENT file
56
+ * (every default) and an UNREADABLE one (the empty string the runtime gives a failed provider)
57
+ * stay two different inputs.
58
+ *
59
+ * 🔴 THE PATH IS ANCHORED TO THE PROJECT ROOT. vigiles runs a provider "via execSync in the hook's
60
+ * cwd", which is the consumer's wiring — a string this hook cannot see. A bare relative path reads
61
+ * from whatever directory the Bash tool last moved to.
62
+ */
63
+ const READ_CONFIG = `f="\${CLAUDE_PROJECT_DIR:-.}/${CONFIG_FILE}"; if [ -e "$f" ]; then cat "$f"; else echo '{}'; fi`;
54
64
 
55
65
  /**
56
66
  * The declared papers root, or `null` when it is unusable.
@@ -74,14 +84,10 @@ export const OLD_PAPERS_DIR_FIELD = "papers";
74
84
  * 🔴 `declared === undefined`, NOT `declared ?? DEFAULT` — `"papersDir": null` is a keystroke, not
75
85
  * an absence. Same distinction as every other carrier in this package.
76
86
  */
77
- const papersRoot = (rawPkg) => {
87
+ const papersRoot = (rawConfig) => {
78
88
  let declared;
79
89
  try {
80
- const pkg = JSON.parse(rawPkg);
81
- const settings = pkg?.[CONFIG_KEY] ?? pkg?.[LEGACY_CONFIG_KEY];
82
- // Old field name: stay silent rather than fall back to the default directory.
83
- if (settings && Object.hasOwn(settings, OLD_PAPERS_DIR_FIELD)) return null;
84
- declared = settings?.[PAPERS_DIR_FIELD];
90
+ declared = JSON.parse(rawConfig)?.[PAPERS_DIR_FIELD];
85
91
  } catch {
86
92
  return null;
87
93
  }
@@ -113,21 +119,17 @@ RULES earned the hard way:
113
119
  export default experimental_defineReact({
114
120
  on: "PostToolUse",
115
121
  match: tools("Edit", "Write", "MultiEdit"),
116
- // 🔴 THE PATH IS ANCHORED TO THE PROJECT ROOT, and that is not decoration. vigiles runs the
117
- // provider "via execSync in the hook's cwd", and the hook process's cwd is the consumer's
118
- // own wiring — a string this hook cannot see. A bare `cat package.json` therefore reads from
119
- // whatever directory the Bash tool last moved to, and a `cd` into a subdirectory with no
120
- // manifest breaks the read.
122
+ // The provider is anchored to the project root (`READ_CONFIG`, above).
121
123
  //
122
124
  // ⚠️ THE FAILURE HERE IS SILENT, which makes it more dangerous than its neighbor's.
123
125
  // `paper-edit-guard` on PreToolUse DENIES loudly and visibly when the declaration is
124
126
  // unreadable. This hook on PostToolUse just returns `nothing()`, i.e. simply stops firing:
125
- // the `cat` chain fails → empty string → `JSON.parse("")` throws → `papersRoot` returns null
127
+ // the `cat` fails → empty string → `JSON.parse("")` throws → `papersRoot` returns null
126
128
  // → silence. And silence is exactly what a nudge's success state looks like, so a dead hook
127
129
  // is indistinguishable from a working one.
128
- needs: [provide("pkg", 'cat "${CLAUDE_PROJECT_DIR:-.}/package.json"')],
130
+ needs: [provide("config", READ_CONFIG)],
129
131
  react: (e) => {
130
- const root = papersRoot(e.ctx.pkg);
132
+ const root = papersRoot(e.ctx.config);
131
133
  if (root === null) return nothing();
132
134
  return e.path.under([root]) && isPaperSource(e.path.raw)
133
135
  ? notice(CHECKLIST)
@@ -63,19 +63,29 @@ import {
63
63
  nothing,
64
64
  } from "vigiles/hook";
65
65
 
66
- /** The key every carrier of this package reads its consumer-specific settings from. */
66
+ /** The tool's name — the package this hook resolves its scripts through. */
67
67
  export const CONFIG_KEY = "paperlint";
68
- /** The key's name before 2.0.0 — still read, a copy of `lib/paper-config.mjs`. */
69
- export const LEGACY_CONFIG_KEY = "research-paper-pipeline";
68
+ /** The settings file at the project root. Optional: absent means every default. */
69
+ export const CONFIG_FILE = "paperlint.json";
70
70
  /** The default. A consumer that declares nothing is assumed to keep papers in `papers/`. */
71
71
  export const DEFAULT_PAPERS_ROOT = "papers";
72
72
  /**
73
- * The field under CONFIG_KEY that names the papers directory, and its old name. A copy of the
73
+ * The field of the root `paperlint.json` that names the papers directory. These are copies of the
74
74
  * constants in `lib/paper-config.mjs` (a hook may import nothing but `vigiles/hook`);
75
75
  * `lib/paper-config.harness.mjs` checks that the copies match.
76
76
  */
77
77
  export const PAPERS_DIR_FIELD = "papersDir";
78
- export const OLD_PAPERS_DIR_FIELD = "papers";
78
+
79
+ /**
80
+ * The provider: the root `paperlint.json`, or `{}` when the project has none — so an ABSENT file
81
+ * (every default) and an UNREADABLE one (the empty string the runtime gives a failed provider)
82
+ * stay two different inputs.
83
+ *
84
+ * 🔴 THE PATH IS ANCHORED TO THE PROJECT ROOT. vigiles runs a provider "via execSync in the hook's
85
+ * cwd", which is the consumer's wiring — a string this hook cannot see. A bare relative path reads
86
+ * from whatever directory the Bash tool last moved to.
87
+ */
88
+ const READ_CONFIG = `f="\${CLAUDE_PROJECT_DIR:-.}/${CONFIG_FILE}"; if [ -e "$f" ]; then cat "$f"; else echo '{}'; fi`;
79
89
 
80
90
  /**
81
91
  * The declared papers root, or `null` when it is unusable.
@@ -88,14 +98,10 @@ export const OLD_PAPERS_DIR_FIELD = "papers";
88
98
  * 🔴 `declared === undefined`, NOT `declared ?? DEFAULT` — `"papersDir": null` is a keystroke, not
89
99
  * an absence.
90
100
  */
91
- const papersRoot = (rawPkg) => {
101
+ const papersRoot = (rawConfig) => {
92
102
  let declared;
93
103
  try {
94
- const pkg = JSON.parse(rawPkg);
95
- const settings = pkg?.[CONFIG_KEY] ?? pkg?.[LEGACY_CONFIG_KEY];
96
- // Old field name: stay silent rather than fall back to the default directory.
97
- if (settings && Object.hasOwn(settings, OLD_PAPERS_DIR_FIELD)) return null;
98
- declared = settings?.[PAPERS_DIR_FIELD];
104
+ declared = JSON.parse(rawConfig)?.[PAPERS_DIR_FIELD];
99
105
  } catch {
100
106
  return null;
101
107
  }
@@ -133,21 +139,17 @@ const surface = (dir) =>
133
139
  export default experimental_defineReact({
134
140
  on: "PostToolUse",
135
141
  match: tools("Edit", "Write", "MultiEdit"),
136
- // 🔴 THE PATH IS ANCHORED TO THE PROJECT ROOT, and that is not decoration. vigiles runs the
137
- // provider "via execSync in the hook's cwd", and the hook process's cwd is the consumer's
138
- // own wiring — a string this hook cannot see. A bare `cat package.json` therefore reads from
139
- // whatever directory the Bash tool last moved to, and a `cd` into a subdirectory with no
140
- // manifest breaks the read.
142
+ // The provider is anchored to the project root (`READ_CONFIG`, above).
141
143
  //
142
144
  // ⚠️ THE FAILURE HERE IS SILENT, which makes it more dangerous than its neighbor's.
143
145
  // `paper-edit-guard` on PreToolUse DENIES loudly and visibly when the declaration is
144
146
  // unreadable. This hook on PostToolUse just returns `nothing()`, i.e. simply stops firing:
145
- // the `cat` chain fails → empty string → `JSON.parse("")` throws → `papersRoot` returns null
147
+ // the `cat` fails → empty string → `JSON.parse("")` throws → `papersRoot` returns null
146
148
  // → silence. And silence is exactly what a nudge's success state looks like, so a dead hook
147
149
  // is indistinguishable from a working one.
148
- needs: [provide("pkg", 'cat "${CLAUDE_PROJECT_DIR:-.}/package.json"')],
150
+ needs: [provide("config", READ_CONFIG)],
149
151
  react: (e) => {
150
- const root = papersRoot(e.ctx.pkg);
152
+ const root = papersRoot(e.ctx.config);
151
153
  if (root === null) return nothing();
152
154
  const m = paperSourceRe(root).exec(e.path.raw.replace(/^\.\//, ""));
153
155
  if (m === null) return nothing();
@@ -27,33 +27,28 @@ case "$pdir_name" in
27
27
  *[!A-Za-z0-9._-]* | "" | . | ..) exit 0 ;;
28
28
  esac
29
29
 
30
- # ── WHERE THE CONSUMER'S THINGS ARE — TWO CARRIERS, READ, NOT ASSUMED ────────
31
- # This file used to hard-code both of these paths, which made it a tool with exactly one possible
32
- # user. Both now come from the one declaration every other carrier of this package reads:
30
+ # ── WHERE THE CONSUMER'S THINGS ARE — READ, NOT ASSUMED ─────────────────────
31
+ # Both paths come from the one declaration every other carrier of this package reads, the
32
+ # project's root paperlint.json (optional — absent means every default):
33
33
  #
34
- # "paperlint": { "papersDir": "docs/papers", "scripts": "tools/pipeline" }
34
+ # { "papersDir": "docs/papers", "scripts": "tools/pipeline" }
35
35
  #
36
- # (The key's name before 2.0.0, "research-paper-pipeline", is still read.)
37
- #
38
- # ⚠️ `node -p` RATHER THAN grep/sed ON package.json. A JSON value is not a line of text: it can
36
+ # ⚠️ `node -p` RATHER THAN grep/sed ON THE FILE. A JSON value is not a line of text: it can
39
37
  # be quoted, escaped, or spread across lines, and a pattern that gets it right today gets it
40
38
  # wrong on the first reformat — silently, by producing a root that matches nothing. Node is
41
39
  # already a hard dependency of the checker this script runs, so there is no new requirement.
42
40
  ROOT_DIR="${CLAUDE_PROJECT_DIR:-.}"
43
41
  read_key() { # $1 = key, $2 = default
44
- node -p "((p) => p['paperlint'] ?? p['research-paper-pipeline'] ?? {})(require('$ROOT_DIR/package.json'))['$1'] ?? '$2'" \
42
+ node -p "((c) => c['$1'] ?? '$2')((() => { try { return require('$ROOT_DIR/paperlint.json') } catch { return {} } })())" \
45
43
  2>/dev/null || echo "$2"
46
44
  }
47
- # The field used to be called "papers". Every other reader refuses the old name; this script only
48
- # gives advice, so it stays silent instead of falling back to the default directory.
49
- [ -z "$(read_key papers '')" ] || exit 0
50
45
  papers_root="$(read_key papersDir papers)"
51
46
  scripts_root="$(read_key scripts .claude/skills/paper-pipeline/scripts)"
52
47
 
53
48
  # 🔴 AN UNUSABLE ROOT EXITS, IT DOES NOT BUILD A PATH FROM IT. `node -p` prints `undefined` for a
54
49
  # key whose value is literally `null` (`?? ` only catches null/undefined AFTER the object lookup,
55
- # and a `null` value reaches the default — but a malformed package.json makes the whole command
56
- # fail and the `|| echo` hands back the default). Either way, an empty or nonsense value would
50
+ # and a `null` value reaches the default — but a malformed paperlint.json reads as {} and
51
+ # hands back the default). Either way, an empty or nonsense value would
57
52
  # produce `/<paper>/PIPELINE-STATUS.md` rooted at the filesystem, which does not exist, and this
58
53
  # script would exit 0 looking like a paper with nothing to report. Named rather than risked.
59
54
  case "$papers_root" in
@@ -5,30 +5,27 @@
5
5
  // The field names are typed as `string`, not as their values, so that the value is written
6
6
  // once, in paper-config.mjs.
7
7
 
8
+ /** The tool's name — its settings file is named after it. */
8
9
  export declare const CONFIG_KEY: string;
9
- export declare const LEGACY_CONFIG_KEY: string;
10
- export declare const LEGACY_KEY_MESSAGE: string;
10
+ /** `paperlint.json`: the settings file, at the project root and in each paper. */
11
+ export declare const CONFIG_FILE: string;
12
+ /** The files that make a directory a paper. */
13
+ export declare const PAPER_MARKERS: readonly string[];
11
14
 
12
- /** Where a parsed package.json keeps the settings: the new key, else the old one. */
13
- export declare function declaredSettings(pkg: unknown): {
14
- readonly settings: unknown;
15
- readonly legacy: boolean;
16
- readonly conflict: string | null;
17
- };
15
+ /** The project root, walking up from `startDir` (see the module). */
16
+ export declare function findProjectRoot(
17
+ startDir: string,
18
+ isFile?: (path: string) => boolean,
19
+ ): string;
18
20
 
19
- /** The settings object alone — undefined when absent or on a conflict. */
21
+ /** The root `paperlint.json` of `root` — undefined when absent, unparsable or not an object. */
20
22
  export declare function settingsOf(
21
- pkg: unknown,
23
+ root: string,
22
24
  ): Readonly<Record<string, unknown>> | undefined;
23
25
  export declare const DEFAULT_PAPERS_ROOT: string;
24
26
  export declare const PAPERS_DIR_FIELD: string;
25
- export declare const OLD_PAPERS_DIR_FIELD: string;
26
27
 
27
- /** The error text for settings that still use the old field name, or `null` when they do not. */
28
- export declare function renamedFieldMessage(
29
- settings: unknown,
30
- where?: string,
31
- ): string | null;
32
-
33
- /** Every key the settings object may hold, mapped to who reads it. */
28
+ /** Every key a `paperlint.json` may hold, at either level, mapped to who reads it. */
34
29
  export declare const SETTINGS_KEYS: Readonly<Record<string, string>>;
30
+ /** The keys refused in a paper's `paperlint.json`. */
31
+ export declare const ROOT_ONLY_KEYS: readonly string[];
@@ -62,8 +62,7 @@ const loaded = await Promise.all(
62
62
  key: mod.CONFIG_KEY,
63
63
  def: mod.DEFAULT_PAPERS_ROOT,
64
64
  field: mod.PAPERS_DIR_FIELD,
65
- old: mod.OLD_PAPERS_DIR_FIELD,
66
- legacyKey: mod.LEGACY_CONFIG_KEY,
65
+ file: mod.CONFIG_FILE,
67
66
  };
68
67
  }),
69
68
  );
@@ -98,30 +97,34 @@ const loaded = await Promise.all(
98
97
  `all carriers agree on DEFAULT_PAPERS_ROOT (${[...defs].join(" / ")})`,
99
98
  defs.size === 1,
100
99
  );
101
- // The papers-directory field name, and its old name. Only the source and the three hooks
100
+ // The papers-directory field name. Only the source and the three hooks
102
101
  // (which cannot import it) declare these; the value itself is pinned nowhere but the source,
103
102
  // so a rename stays a one-line change there plus the hook copies this checks.
104
103
  const fields = new Set(
105
104
  loaded.map((c) => c.field).filter((v) => v !== undefined),
106
105
  );
107
- const olds = new Set(loaded.map((c) => c.old).filter((v) => v !== undefined));
108
106
  check(
109
107
  `all carriers agree on PAPERS_DIR_FIELD (${[...fields].join(" / ")})`,
110
108
  fields.size === 1,
111
109
  );
110
+ const files = new Set(
111
+ loaded.map((c) => c.file).filter((v) => v !== undefined),
112
+ );
112
113
  check(
113
- `all carriers agree on OLD_PAPERS_DIR_FIELD (${[...olds].join(" / ")})`,
114
- olds.size === 1,
114
+ `all carriers agree on CONFIG_FILE (${[...files].join(" / ")})`,
115
+ files.size === 1,
115
116
  );
116
117
  check(
117
- "every hook declares both field names — a hook without them would read an undefined field",
118
+ "every hook declares the file name — its provider reads that file",
118
119
  loaded
119
120
  .filter((c) => c.rel.endsWith(".hook.mjs"))
120
- .every((c) => typeof c.field === "string" && typeof c.old === "string"),
121
+ .every((c) => typeof c.file === "string"),
121
122
  );
122
123
  check(
123
- "the new and the old field names differ",
124
- loaded[0].field !== loaded[0].old,
124
+ "every hook declares the field name — a hook without it would read an undefined field",
125
+ loaded
126
+ .filter((c) => c.rel.endsWith(".hook.mjs"))
127
+ .every((c) => typeof c.field === "string"),
125
128
  );
126
129
  check(
127
130
  "and no carrier is silent about both at once — such an entry is dead weight on the list",
@@ -135,23 +138,6 @@ const loaded = await Promise.all(
135
138
  loaded[0].key ===
136
139
  JSON.parse(readFileSync(join(ROOT, "package.json"), "utf8")).name,
137
140
  );
138
- // The old key is still read — by the source and by each hook, which keep their own copy.
139
- const legacyKeys = new Set(
140
- loaded.map((c) => c.legacyKey).filter((v) => v !== undefined),
141
- );
142
- const { LEGACY_PACKAGE_NAME } = await import(
143
- pathToFileURL(join(ROOT, "skills/paper-pipeline/scripts/consumer.mjs")).href
144
- );
145
- check(
146
- `all carriers agree on LEGACY_CONFIG_KEY, the package's old name (${[...legacyKeys].join(" / ")})`,
147
- legacyKeys.size === 1 && legacyKeys.has(LEGACY_PACKAGE_NAME),
148
- );
149
- check(
150
- "and every hook carries it — a hook without it would ignore settings under the old key",
151
- loaded
152
- .filter((c) => c.rel.endsWith(".hook.mjs"))
153
- .every((c) => c.legacyKey !== undefined),
154
- );
155
141
  }
156
142
 
157
143
  // ── III. THE HOOKS DUPLICATE, AND THAT IS CHECKED SEPARATELY ───────────────────────────────
@@ -186,7 +172,7 @@ const loaded = await Promise.all(
186
172
  "repro",
187
173
  ]);
188
174
  const declares =
189
- /^(?:export )?const (?:CONFIG_KEY|DEFAULT_PAPERS_ROOT|PAPERS_DIR_FIELD|OLD_PAPERS_DIR_FIELD)\s*=/m;
175
+ /^(?:export )?const (?:CONFIG_KEY|CONFIG_FILE|DEFAULT_PAPERS_ROOT|PAPERS_DIR_FIELD)\s*=/m;
190
176
  const found = [];
191
177
  const walk = (dir) => {
192
178
  for (const e of readdirSync(dir, { withFileTypes: true })) {
@@ -218,19 +204,16 @@ const loaded = await Promise.all(
218
204
  // The settings object has readers all over the package, each reading its own keys, and the CLI
219
205
  // refuses any key not in SETTINGS_KEYS. So the list must hold every key some reader reads — or a
220
206
  // consumer's valid key is refused — and nothing no one reads. The readers are FOUND, by the AST:
221
- // every `[CONFIG_KEY]?.<key>` (or the literal package name in the brackets), plus every field of
207
+ // every `settingsOf(root)?.<key>` (and the older `[CONFIG_KEY]?.<key>` shape), plus every field of
222
208
  // `PaperlintConfig`, the CLI's typed view of the same object.
223
209
  {
224
210
  const { SETTINGS_KEYS } = await import(join(HERE, "paper-config.mjs"));
225
211
  const ts = (await import("typescript")).default;
226
212
  const readKeys = new Set();
227
213
  const isConfigKey = (e) =>
228
- (ts.isIdentifier(e) &&
229
- (e.text === "CONFIG_KEY" || e.text === "LEGACY_CONFIG_KEY")) ||
230
- (ts.isStringLiteral(e) &&
231
- (e.text === "paperlint" || e.text === "research-paper-pipeline"));
232
- // A read is `pkg[CONFIG_KEY]?.<key>` or `settingsOf(pkg)?.<key>` — the helper that also reads
233
- // the old key.
214
+ (ts.isIdentifier(e) && e.text === "CONFIG_KEY") ||
215
+ (ts.isStringLiteral(e) && e.text === "paperlint");
216
+ // A read is `settingsOf(root)?.<key>` (or `x[CONFIG_KEY]?.<key>`).
234
217
  const isSettings = (e) =>
235
218
  (ts.isElementAccessExpression(e) && isConfigKey(e.argumentExpression)) ||
236
219
  (ts.isCallExpression(e) &&