
Sync Submission
- 44 installs
- 236 repo stars
- Updated August 3, 2026
- aperivue/medsci-skills
sync-submission is a Claude Code skill that audits drift between a canonical manuscript and journal submission packages, builds and freezes those packages, and runs a pre-flight gate that halts the freeze on deterministi
About
This skill keeps a canonical manuscript and its journal-specific submission packages from drifting apart. It audits, builds, and freezes submission packages via deterministic scripts, records whether each package is current, stale, or frozen, and runs a pre-flight gate that halts the freeze on unresolved errors. A researcher uses it right before submitting a journal package or when retargeting to another journal.
- Audits drift between the canonical manuscript (SSOT) and journal-specific submission packages
- Builds, freezes, and manifests journal submission packages and sweeps author identifiers for double-blind journals
- Runs a single pre-flight gate that halts the freeze on deterministic errors like placeholders, undefined citations, and
Sync Submission by the numbers
- 44 all-time installs (skills.sh)
- Ranked #1,120 of 2,715 Automation & Workflows skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
sync-submission capabilities & compatibility
- Capabilities
- orchestration · submission sync · quality gate
- Use cases
- orchestration · research
What sync-submission says it does
Audit SSOT-to-submission drift and create journal submission manifests from canonical manuscript artifacts.
For double-blind journals, sweep author identifiers across all upload artifacts
npx skills add https://github.com/aperivue/medsci-skills --skill sync-submissionAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 44 |
|---|---|
| repo stars | ★ 236 |
| Last updated | August 3, 2026 |
| Repository | aperivue/medsci-skills ↗ |
What it does
Audit SSOT-to-submission drift and build or freeze journal submission packages with a halt-on-failure gate.
Who is it for?
Researchers finalizing a journal submission package or retargeting a rejected manuscript to another journal.
Skip if: Discovering literature or verifying references (use /search-lit and /verify-refs).
When should I use this skill?
You are about to submit or freeze a journal package, or a portal changed a submission copy.
What you get
A synced, drift-checked submission package with a single halt-on-failure pre-flight gate report before freeze.
- Submission metadata (.journal_meta.json)
- Sync audit JSON
- Pre-flight gate report
By the numbers
- 3 modes: audit, build, freeze
- P0 vs P1 check tiers in the pre-flight gate
Files
Sync Submission
You help keep the canonical manuscript and journal-specific submission packages from drifting apart. The skill treats submission/{journal}/ as derived output and records whether it is current, stale, or frozen.
When to Use
- Before submitting a journal package.
- After a journal portal or Word editor changed a submission manuscript.
- After rejection, before retargeting to another journal.
- Before
/orchestrate --e2emarks a project as submission-ready.
Inputs
1. Project root containing project.yaml, or a direct canonical manuscript path. 2. Journal short name, e.g. chest, ryai, academic_radiology. 3. Optional mode:
audit: compare existing submission against canonical source.build: copy canonical source intosubmission/{journal}/manuscript/and write metadata.freeze: mark a package as submitted/frozen.
Deterministic Script
python "${CLAUDE_SKILL_DIR}/scripts/sync_submission.py" audit --project-root . --journal chest
python "${CLAUDE_SKILL_DIR}/scripts/sync_submission.py" build --project-root . --journal chest
python "${CLAUDE_SKILL_DIR}/scripts/sync_submission.py" freeze --project-root . --journal chest --status submittedFor double-blind journals, sweep author identifiers across all upload artifacts:
python "${CLAUDE_SKILL_DIR}/scripts/blind_sweep.py" \
--registry _shared/authors/author_registry.yaml \
--files submission/{journal}/supplementary/*.md submission/{journal}/cover_letter.md \
--backup-dir .cache/blind_sweep_backupThe registry is a project-local YAML mapping author identifiers (full names, native scripts, initials with/without periods, email, ORCID) to role labels (e.g., "Reviewer 1"). See scripts/author_registry_example.yaml for schema. Never commit a populated registry to a public repository — keep it next to the manuscript.
Output Contract
| Artifact | Path | Purpose |
|---|---|---|
| Submission metadata | submission/{journal}/.journal_meta.json | Source hash, status, canonical path |
| Sync audit | qc/submission_sync_{journal}.json | Drift result consumed by orchestrator |
| Manifest update | artifact_manifest.json | Submission package registry |
| Pre-flight gate | qc/preflight_gate_report.json | Aggregated halt-on-failure manifest (see "Pre-flight gate" below) |
Pre-flight gate (single command — last step before freeze)
Run this once, right before freeze/submission. It orchestrates the existing deterministic checks and the /verify-refs audit into one halt-on-failure gate, writes a single aggregated manifest (qc/preflight_gate_report.json), and exits non-zero so a build wrapper or CI step can stop the freeze. It shells out to the per-check scripts and reimplements none of them — the halt decision is driven by each sub-check's normalized exit code.
python "${CLAUDE_SKILL_DIR}/scripts/preflight_gate.py" --project-root . --journal chest
# add --strict to also halt on the heuristic/conditional (P1) checks
# add --online to make fabricated / author-mismatched references halt (PubMed/CrossRef)
# add --double-blind to make the asset-anonymization scan haltBy default the gate halts only on the unambiguous, deterministic errors (P0): leftover placeholder/markers (check_placeholders.py), undefined [@key] citations (check_citation_keys.py), duplicate references (verify_refs.py, offline-deterministic), and a canonical-vs-submission hash mismatch (sync_submission.py audit). The heuristic or conditional checks — check_xref, detect_copy_divergence, scope_drift_check, cover_letter_drift_check, cross_document_n_check, check_cross_artifact_stale — run and report as P1 `warn` but do not halt unless promoted with --strict or --require ID; check_asset_anonymization is P1 unless --double-blind. A check whose inputs are absent (no rendered docx, no cover letter, no copies, no journal) is recorded skipped, never a blocker. Exit codes: 0 clean, 1 halt (≥1 blocker), 2 gate config error (e.g. a --require'd check could not run).
The gate's offline references pass is the deterministic subset (duplicates + pagination placeholders); an online /verify-refs --strict against PubMed/CrossRef remains the authoritative fabrication and author-name check before submission.
Disclosure & availability check (standalone). Top medical-AI journals require, before review, an AI-use disclosure carrying four tokens (version + access channel + date/date-range + responsible party — the tool name only triggers the check) and Data/Code Availability statements. Run python3 ${CLAUDE_SKILL_DIR}/scripts/check_disclosure_availability.py --manuscript <file> --journal <stem> [--ai-study] [--require data_availability ...] [--strict] (reads references/journal_availability_policy.json). It blocks on a missing required statement or an AI disclosure that is present but missing a token / carrying a placeholder; "available on reasonable request" where the journal expects a repository is a P1 warning. Writes qc/disclosure_availability_report.json.
Workflow
1. Resolve canonical manuscript from project.yaml or explicit input. 2. Run the script in the requested mode. 3. If audit reports DRIFT, do not retarget or freeze until the user either patches the canonical manuscript or records the difference as journal-only. 4. If build succeeds, run /verify-refs before final submission.
Quality Gates
- Gate 0 (pre-flight, last step before freeze): run
scripts/preflight_gate.py --project-root . --journal {journal}to aggregate the deterministic checks below into one halt-on-failure manifest (qc/preflight_gate_report.json). Non-zero exit blocks the freeze. See "Pre-flight gate" above for the P0/P1 tiering and flags. This orchestrates Gates 1–3, 5b, 8, 9, 11 plus the placeholder and citation-key checks; the individual gates remain runnable on their own. - Gate 1: block freezing when canonical manuscript is missing.
- Gate 2: block retargeting when the previous submission has unresolved drift.
- Gate 3: require
/verify-refsaudit before marking a package submission-safe. - Gate 4: docx audits must use a recursive walk (paragraphs + tables + nested-table cells); a flat
document.paragraphsscan is insufficient. - Gate 5: before freeze, confirm portal free-text fields (cover letter, data availability, acknowledgements, abstract, author contributions) match the manuscript body.
- Gate 6 (double-blind journals): before freeze, export the portal's blinded review PDF and grep for all author identifiers across the entire upload set — manuscript, supplementary, cover letter, registry record PDFs (PROSPERO/ClinicalTrials), portal Letter-field text. A clean manuscript blind does not imply a clean portal blind.
- Gate 7 (text-only docx rebuilds): never use
pandoc --reference-doc=manuscript.docxfor response/cover/supplementary text-only docx — the reference docx ships its embedded media (figure files) into the new docx, bloating size 50–100×. Use plainpandoc input.md -o output.docxfor text-only artifacts. - Gate 5b (Phase 4 cover-letter free-text drift): before freeze, run
scripts/cover_letter_drift_check.pyto verify the cover letter's word-count / reference-count / table-figure-count claims still match the manuscript. Cover letters routinely go stale across v_N → v_(N+1) branching and are not covered by any docx-level audit. See "Phase 4 — Cover-letter free-text drift" below. - Gate 8 (Phase 5 cross-document N consistency): before freeze, run
scripts/cross_document_n_check.pyover the manuscript bundle (abstract, body, PROSPERO record, cover letter, supplementary, INDEX, PRISMA flow caption). Any N category with >1 distinct integer value is a P0 drift. When aFINAL_POOL_LOCK.yamlis present, supply--pool-lockto make the locked counts the authoritative baseline. See "Phase 5 — Cross-document N consistency" below. - Gate 9 (Phase 6 intra-manuscript scope drift): run
scripts/scope_drift_check.pyagainst the manuscript (and optionally the PROSPERO record). Numeric anchors (AUC, OR/HR/RR, sensitivity/specificity) appearing in Limitations / Discussion but absent from Methods + Results are P0 SCOPE_DRIFT. PROSPERO ↔ Methods synthesis-method disagreement is a P0 PROSPERO_DRIFT. - Gate 10 (Phase 7 v_(N+1) docx regeneration): when building a new submission from a frozen prior version, run
scripts/verify_package_integrity.py --assert-vN-docx-changed --vN-docx <prev>.docx --new-docx <next>.docx. Identical MD5 = unmodified seed copy = block submission. Defense-in-depth — required even when the upstream pipeline appears to have regenerated the docx. - Gate 11 (Phase 8 multi-copy divergence): when the project hand-maintains more than one manuscript copy (working SSOT, circulation, portal), run
scripts/detect_copy_divergence.py --ssot <ssot>.md --copy <copy>.md ...before freeze or circulation. AnySTALE_COPY(an SSOT numeric claim or heading that did not propagate to a copy) is a P0 drift. See "Phase 8 — Multi-copy manuscript divergence" below. - Gate 12 (target-journal metadata drift): on
build/ retarget, cross-check the target the manuscript is written for against the target the project is being submitted to. Compareproject.yamltarget(and any in-manuscript header/footer "for submission to X" string) against the journal the package is built for, and check the structural metadata the target dictates — abstract heading structure (4- vs 5-heading), body word limit, citation style (Vancouver / AMA), required elements (Highlights / Central Illustration / Key Points). A mismatch (e.g., a header still reading the previous journal after a cascade retarget, or a 4-heading abstract for a 5-heading target) is a target-restructure trigger — branch to v_(N+1) permanuscript-versioning.md§2 and sync every sidecar (cover letter, title page, ICMJE COI list) — not a silent build.
# header target vs project.yaml target
TGT=$(python3 -c "import yaml;print(yaml.safe_load(open('project.yaml')).get('target',''))" 2>/dev/null)
grep -niE 'for submission to|submitted to|prepared for' manuscript/manuscript.md # compare against "$TGT"- Gate 13 (body word count vs journal cap — the revision-inflation trap): resolving reviewer majors monotonically adds words, so a revised body silently breaches the target journal's limit. Before freeze (and after every
/revisepass), runscripts/check_wordcount_cap.pyagainst the target journal profile's body cap.WORDCOUNT_OVER_CAPis a P0 (relocate methods/sensitivity detail to the Supplement);WORDCOUNT_NEAR_CAP(>0.95×) warns that the next pass will breach. The binding number is the rendered count (citeproc expands[@key]→ "(Author Year)"), so prefer the built DOCX count with--rendered-words N; otherwise the script estimates it from the markdown body + inline-citation expansion.
python3 "${CLAUDE_SKILL_DIR}/scripts/check_wordcount_cap.py" \
--manuscript manuscript/manuscript.md \
--journal-profile "${MEDSCI_SKILLS_ROOT:-$HOME/workspace/medsci-skills}/skills/find-journal/references/journal_profiles/<Journal>.md" \
--article-type "Original Article" --out qc/wordcount_cap.json --strict
# or, deterministic: --limit 4000 (and --rendered-words N from the built DOCX when available)Phase 4 — Cover-letter free-text drift
Cover letters live outside the submission docx files but are read by the editor side-by-side with the manuscript. Their ## Article details block — body word count, abstract word count, reference count, table/figure count — is a sidecar SSOT that routinely goes stale when a manuscript branches v_N → v_(N+1) (word limit retarget, abstract restructure, late reference batch).
scripts/cover_letter_drift_check.py measures the manuscript truth and compares it to the cover letter's numeric claims:
python "${CLAUDE_SKILL_DIR}/scripts/cover_letter_drift_check.py" \
--manuscript manuscript.md \
--cover-letter cover_letter.md \
--refs refs.bib \
--out qc/cover_letter_drift.jsonBody words are matched with a 5% tolerance ("approximately N words" phrasing). Abstract words tolerate ±5. Reference / table / figure counts require exact match.
Output qc/cover_letter_drift.json:
{
"submission_safe": false,
"truth": {"body_words": 3036, "abstract_words": 319, "references": 12,
"tables": 3, "figures": 4},
"claims": {"body_words": 3790, "abstract_words": 250, "references": 12},
"drifts": [
{"field": "body_words", "truth": 3036, "cover_letter_claim": 3790,
"severity": "MAJOR",
"note": "|claim - truth| = 754 > tolerance 151"}
]
}Drift resolution: regenerate the cover letter from the manuscript at v_(N+1) build time. The script never edits the cover letter — that is left to the manuscript build pipeline so the cover letter stays a deliberate authored artifact.
Phase 5 — Cross-document N consistency
Multi-document cohort-size drift is a high-frequency desk-reject pattern. Manuscript abstracts, body prose, PROSPERO records, supplementary extraction sheets, and PRISMA flow captions all repeat the same k included / k excluded / N patients totals — and any disagreement between them is read by reviewers as either a data-integrity failure or a late-edit failure. Either reading ends the round.
scripts/cross_document_n_check.py scans the submission package, extracts every "N <noun>" claim by category (patients, cases, included, excluded, nodules, tumors, studies_total), and groups them by category. A category with more than one distinct integer value is a P0 drift.
python "${CLAUDE_SKILL_DIR}/scripts/cross_document_n_check.py" \
--root . \
--out qc/cross_document_n.jsonWhen the project has frozen a 2_Data/FINAL_POOL_LOCK.yaml from /meta-analysis Phase 3f.5, pass it as the authoritative anchor:
python "${CLAUDE_SKILL_DIR}/scripts/cross_document_n_check.py" \
--root . \
--pool-lock 2_Data/FINAL_POOL_LOCK.yaml \
--out qc/cross_document_n.jsonOutput qc/cross_document_n.json:
{
"submission_safe": false,
"drift_count": 1,
"drifts": [
{
"category": "included",
"values": [63, 64],
"locations": [
{"file": "abstract.md", "line": 4, "value": 63, "context": "..."},
{"file": "supplementary/s1.md", "line": 12, "value": 64, "context": "..."}
],
"severity": "MAJOR"
}
],
"lock_violations": []
}Treat submission_safe: false as a halt. Resolve drift by tracing each location to its data artifact (extraction sheet, PRISMA cascade TSVs) and correcting the document(s) that disagree with the locked count.
Phase 6 — Intra-manuscript scope drift
Late-revision sensitivity analyses sometimes get introduced in the Discussion or Limitations subsection without ever propagating back to Methods + Results. The manuscript then makes claims (with explicit AUC, OR, sensitivity numbers) whose primary report never exists. Reviewers read this as a fabrication-grade red flag, and editors desk-reject.
A second variant of the same anti-pattern: the PROSPERO record commits to a synthesis method (Freeman-Tukey, random-effects DerSimonian-Laird, bivariate, HSROC, Bayesian, etc.) but the Methods section uses a different one — or the PROSPERO record was updated and Methods stayed behind. When accompanied by a Methods line saying "no amendment lodged", this becomes a documented silent protocol deviation.
scripts/scope_drift_check.py detects both patterns:
python "${CLAUDE_SKILL_DIR}/scripts/scope_drift_check.py" \
--manuscript manuscript.md \
--prospero prospero/prospero_v2.md \
--out qc/scope_drift.jsonOutput:
{
"submission_safe": false,
"limitations_only_anchors": [
{
"anchor": "0.869",
"kind": "AUC",
"found_in": ["Limitations:31"],
"missing_from": ["Methods", "Results"]
}
],
"synthesis_method_drift": [
{"method": "Freeman-Tukey", "prospero": true, "methods": false}
]
}Resolution: either (a) propagate the anchor into Methods + Results as a primary report or (b) remove it from Limitations / Discussion. For synthesis-method drift, file a PROSPERO amendment and update Methods to match — both must agree before submission.
Phase 7 — v_(N+1) docx regeneration gate
When a v_N submission package was frozen and a v_(N+1) is being built (after a markdown body edit, reviewer round, or cascade-rejection re-target), the v_(N+1) docx MUST differ from the v_N docx. The most common silent-revert pattern is a cp v_N/manuscript.docx v_(N+1)/manuscript.docx step that skips the pandoc / Zotero CWYW regeneration entirely. The markdown body is then edited, but the docx the portal receives is the frozen v_N — the change silently reverts at peer review.
Run the byte-identity assertion at the top of the v_(N+1) submission gate:
python3 /path/to/medsci-skills/scripts/verify_package_integrity.py \
--assert-vN-docx-changed \
--vN-docx SUBMISSION/<journal>/v<N>/manuscript.docx \
--new-docx SUBMISSION/<journal>/v<N+1>/manuscript.docxIdentical MD5 → exit 1 with explanatory error. Block submission until the regeneration step is fixed.
Phase 8 — Multi-copy manuscript divergence
When a project keeps several hand-maintained manuscript copies — manuscript.md (the working SSOT), manuscript_circulation.md (co-author feedback), and submission/<journal>/manuscript.md (portal) — a batch of edits applied to the SSOT routinely lands in only some of the copies. The portal then receives a copy missing a subset of the edits, and the divergence surfaces (if at all) only when a reviewer notices the inconsistency.
Before freezing a package or sending a circulation round, run the directional detector (SSOT → each copy):
python3 ${CLAUDE_SKILL_DIR}/scripts/detect_copy_divergence.py \
--ssot manuscript.md \
--copy manuscript_circulation.md \
--copy submission/<journal>/manuscript.md \
--out qc/copy_divergence.json --strictIt reports, per copy, the SSOT claims (numeric assertions — n = N, percentages, p, OR/HR/RR, 95% CI — and section headings) that did not propagate. A STALE_COPY (DIVERGENT overall) is a P0 blocker: re-propagate the unpropagated claims, or — better — stop hand-maintaining parallel copies and generate the circulation / submission variants from the single SSOT via a build step (pandoc transform), so there is only one editable source. Claims are matched as normalized strings, so wording differences do not register — only a changed or absent number/heading does; legitimately copy-specific content (a circulation cover note) shows up as copy_only and can be ignored.
Phase 9 — Springer Editorial Manager packaging (no title-page slot)
Some Springer Editorial Manager journals offer only Manuscript / Figure / Table / Supplementary / LaTeX upload item types — no separate Title Page or Cover Letter slot, and sometimes no Graphical Abstract slot. Common for observational / cohort submissions.
- Title page → page 1 of the Manuscript file. Build via pandoc: title-page markdown (strip internal-only blocks such as a "Manuscript Metrics" QC block, plus any Funding / Author Contributions / Keywords that also appear later) + a real docx page break (raw OpenXML
<w:br w:type="page"/>; a bare\newpageis silently dropped in docx output) + the manuscript body with its byline / affiliations / corresponding-author footnote removed so the title page is not duplicated. - Verify: at least one page break; the affiliation block appears once; the article title is followed directly by the Abstract (no repeated byline); no internal QC strings leak.
- Cover letter → paste into the "comments to the publication office" free-text field.
- Graphical Abstract (no dedicated slot) → upload as a Figure with Description = "Graphical Abstract".
- Declarations completeness (portal hard checkbox). The manuscript "Statements and Declarations" must carry all seven Springer subheadings: Funding; Competing Interests; Ethics Approval; Consent to Participate; Consent for Publication; Author Contributions; Data Availability. For de-identified observational / registry studies, Consent to Participate = waived (existing de-identified records) and Consent for Publication = "Not applicable; only de-identified data, no individual person's identifying details, images, or videos".
for s in Funding "Competing Interests" "Ethics Approval" "Consent to Participate" "Consent for Publication" "Author Contributions" "Data Availability"; do
unzip -p manuscript.docx word/document.xml | sed 's/<[^>]*>//g' | grep -q "$s" && echo "OK $s" || echo "MISSING $s"; done- Ethics approval / exemption number (observational or exempt cohort). State the IRB approval or exemption reference number in the ethics statement. Institutional exemption notices carry the reference in the document body; filename digits are usually a receipt number, not the approval number — open the notice before writing the ethics block.
- Word limit "including references". When the limit counts references, the binding constraint is body+references words, not the reference-count ceiling. Measure body+refs on the rendered docx before adding references; each Vancouver reference is roughly 25–33 rendered words.
- Submitting via a co-author's account. Editorial Manager auto-adds the account holder at the top of the author list, tagged first/corresponding. De-duplicate, reorder to the intended position, reassign the first-author tag to the true first author, and fill missing co-author email/ORCID.
- Re-read the EM-compiled submission PDF before Approve — author order, degrees, ethics number, references, declarations, and figures.
Verification Blind Spots
Post-submission learnings (npj Digital Medicine R1, 2026-05): a clean docx-level audit still missed several stale artifacts that surfaced only at the portal review stage. Apply these whenever auditing a submission package.
B1. docx scanning must be recursive
python-docx paragraph.runs does not expose runs inside <w:hyperlink>; document.paragraphs skips table cells; document.tables does not recurse into nested tables. Figures, captions, and reporting checklists are routinely wrapped in 1×1 or nested tables, so flat scans silently miss them.
- Walk
paragraphs + tables + nested-table cellsrecursively for every stale-string scan. - For run-level edits near hyperlinks or fields, inspect the paragraph XML, not just
.runs— a missing inline element can be misread as an empty()artifact and "fixed" into a real defect.
B2. Portal input fields are a separate SSOT
Cover letter, Data Availability, Acknowledgements, Abstract, and Author Contributions are often typed directly into the journal portal, outside any docx this skill audits. A clean docx audit does not imply a clean portal.
- Before final submission, diff the portal's final review page against the manuscript body 1:1.
- Treat each portal free-text field as its own drift target.
B3a. Double-blind compliance must cover ALL upload artifacts
A clean manuscript-level blind sweep does not imply a clean portal-level blind. Author identifiers commonly leak through:
- Supplementary materials (per-material
.md/.docxfiles, especially methodology logs, agreement metrics, amendment logs) - Cover letter (separately-uploaded file is portal-default visible to reviewers unless explicitly toggled "Don't show in review PDF")
- Registry record PDFs (PROSPERO, ClinicalTrials.gov, IRB approval PDFs)
- Portal free-text Letter field if cover-letter signature was pasted
- Response-to-reviewers (revision rounds)
Blind sweep regex coverage must include both period and no-period initial forms (e.g., Y.N. and YN), full names in roman + native scripts, institution names, ORCID IDs, and submission email domains. The first blind PDF export from the portal is the authoritative drift detector — always export and grep before final submit.
B3b. PROSPERO public-record PDF shows only current amendment
PROSPERO's "Print/PDF" export from the public record renders only the current amendment narrative. Previous versions are accessible only by selecting older versions in the public-record version-history dropdown. When citing PROSPERO version state, never rely on a single PDF export to verify cross-version consistency — record each published version's PDF independently and clarify in cover/supplementary which version anchors the methodology vs. which version reflects documentation-only erratum.
For documentation-only PROSPERO errata (correcting a narrative fact without changing methods/eligibility/synthesis), prefer a single Revision-Note append over a new structured amendment entry. Preserves historical audit trail and minimizes portal edit surface.
B3c. Text-only docx rebuilds must not inherit manuscript media
If response_to_reviewers.docx / cover_letter.docx / supplementary text-only docx grow to >100 KB after a rebuild, suspect --reference-doc pulling manuscript figure media. Verify with unzip -l output.docx | grep word/media/ — should be empty for text-only artifacts.
B3. Verify change propagation across the whole SSOT tree
A tone, wording, or number change applied to one file (e.g. the abstract) must propagate to every file that repeats it — discussion, response-to-reviewers quotes, reporting checklists, supplementary captions, title page.
- grep the OLD string across the entire SSOT tree, never a subset of files.
- Watch for substring near-misses (
expertise-dependent patternsvsexpertise-dependent evaluation patterns) — an exact-match grep on the short form passes while the long form remains stale.
What This Skill Does NOT Do
- Does not invent journal formatting rules.
- Does not silently merge submission edits back into the SSOT.
- Does not replace
/write-paper; it packages already canonical content.
Anti-Hallucination
- Never claim a submission package is current without matching source hashes.
- Never mark a package as submitted without writing
.journal_meta.json. - Never hide journal-only differences; record them as drift or explicit exceptions.
{
"_comment": "PUBLIC author-guideline facts expressed as booleans — NOT verbatim journal text. data_required: a Data Availability statement is expected; code_required_if_ai: a Code Availability statement is expected for AI/ML studies; repository_required: data/code sharing should point to a repository rather than only 'available on reasonable request'. These are conventions; verify current instructions-to-authors at the journal site before relying on a value.",
"schema_version": 1,
"default": {
"data_required": true,
"code_required_if_ai": true,
"repository_required": false
},
"journals": {
"radiology": {"data_required": true, "code_required_if_ai": true, "repository_required": true},
"radiology-ai": {"data_required": true, "code_required_if_ai": true, "repository_required": true},
"ryai": {"data_required": true, "code_required_if_ai": true, "repository_required": true},
"npj-digital-medicine": {"data_required": true, "code_required_if_ai": true, "repository_required": false},
"nature-medicine": {"data_required": true, "code_required_if_ai": true, "repository_required": true},
"lancet-digital-health": {"data_required": true, "code_required_if_ai": true, "repository_required": false}
}
}
#!/usr/bin/env python3
"""Supplement assembler + structural validator (cohort/SR supplements).
Cohort and SR/MA supplements are a directory of per-section `S{N}_*.md` files +
an `00_index.md`, hand-concatenated into `_combined.md` and re-rendered. Adding
and extending sections across revisions desynchronizes the set: a declared `S{N}`
with no file (or a file with no index row), a duplicate `S{N}`, or a skipped
sub-section number after an insert (`S6.3` then `S6.5`, no `S6.4`). This script
validates that structure and rebuilds `_combined.md` in index order so the
assembly is reproducible rather than hand-maintained.
NOT an integrity detector — deliberately named `assemble_supplement.py` (not
check_/detect_/derive_) so the catalog glob does not count it. It is a build/QA
helper, run before a submission package is frozen.
INPUTS
--dir supplement directory holding `S{N}_*.md` (or `supplement_S{N}_*`/
`suppl_S{N}_*`) section files and an index (`00_index.md` by
default; override with --index).
--index index filename within --dir (default 00_index.md).
--manuscript optional manuscript .md; cross-checks which `Supplementary
(Table|Figure|Material|Methods…) N` / `S{N}` are cited in the body
(coverage: uncited sections + body callouts with no section file).
--out optional path to write the rebuilt `_combined.md` (index order).
OUTPUT
stdout report and, with --json, a JSON artifact:
{dir, declared[], present[], problems[{kind, detail}], coverage{...}, summary}
problem kinds: INDEX_WITHOUT_FILE, FILE_WITHOUT_INDEX, DUPLICATE_SECTION,
SUBSECTION_GAP, CALLOUT_WITHOUT_SECTION, SECTION_UNCITED.
Exit 1 (with --strict) when any STRUCTURAL problem exists (the first four kinds;
coverage findings are advisory and never fail --strict).
Stdlib-only (re / json / argparse / pathlib). Exit codes: 0 clean (or report-only),
1 structural problem(s) with --strict, 2 input/usage error.
"""
from __future__ import annotations
import argparse
import json
import re
import sys
from pathlib import Path
FILE_RE = re.compile(r"^(?:supplement_|suppl_)?S(\d+)(?:[_.]|$)", re.IGNORECASE)
# Top-level S-number tokens in the index (S1, S2 …), not sub-sections (S6.3).
INDEX_TOKEN_RE = re.compile(r"\bS(\d+)\b(?!\.\d)")
# Sub-section headings inside a file: "## S6.3 …", "### **S6.4** …".
SUBSEC_RE = re.compile(r"^#{1,4}\s*\*{0,2}\s*S(\d+)\.(\d+)\b", re.IGNORECASE | re.MULTILINE)
STRUCTURAL = {"INDEX_WITHOUT_FILE", "FILE_WITHOUT_INDEX", "DUPLICATE_SECTION", "SUBSECTION_GAP"}
# Body callouts: "Supplementary Table S3", "Supplementary Methods 2", "Table S3", "§S3", "(S3)".
CALLOUT_RE = re.compile(
r"(?:Supplementary\s+(?:Table|Figure|Material|Methods|Appendix|Note|Data|File)s?\s*|"
r"(?:Table|Figure|Fig\.?)\s+|§\s*|\(\s*)S(\d+)\b", re.IGNORECASE)
def scan_files(d: Path) -> dict:
"""Map S-number -> [filenames] for section files (excludes the index/_combined)."""
out: dict[int, list[str]] = {}
for p in sorted(d.glob("*.md")):
if p.name.startswith("00_") or p.name.startswith("_combined"):
continue
m = FILE_RE.match(p.name)
if m:
out.setdefault(int(m.group(1)), []).append(p.name)
return out
def declared_order(index_text: str) -> list[int]:
"""S-numbers in the order they first appear in the index."""
seen, order = set(), []
for m in INDEX_TOKEN_RE.finditer(index_text):
n = int(m.group(1))
if n not in seen:
seen.add(n)
order.append(n)
return order
def subsection_gaps(text: str, n: int) -> list[str]:
subs = sorted({int(b) for a, b in SUBSEC_RE.findall(text) if int(a) == n})
if len(subs) < 2:
return []
gaps = [s for s in range(subs[0], subs[-1] + 1) if s not in subs]
return [f"S{n}.{g}" for g in gaps]
def analyze(d: Path, index_name: str, manuscript: Path | None, out_path: Path | None) -> dict:
if not d.is_dir():
sys.stderr.write(f"ERROR: --dir not found: {d}\n")
sys.exit(2)
index_path = d / index_name
if not index_path.is_file():
sys.stderr.write(f"ERROR: index not found: {index_path}\n")
sys.exit(2)
index_text = index_path.read_text(encoding="utf-8")
declared = declared_order(index_text)
files = scan_files(d)
present = sorted(files)
problems = []
for n in declared:
if n not in files:
problems.append({"kind": "INDEX_WITHOUT_FILE",
"detail": f"index declares S{n} but no S{n}_*.md file exists"})
for n in present:
if n not in declared:
problems.append({"kind": "FILE_WITHOUT_INDEX",
"detail": f"file(s) {files[n]} present for S{n} but the index does not list it"})
if len(files[n]) > 1:
problems.append({"kind": "DUPLICATE_SECTION",
"detail": f"S{n} has {len(files[n])} files: {files[n]}"})
for n in present:
text = (d / files[n][0]).read_text(encoding="utf-8")
for g in subsection_gaps(text, n):
problems.append({"kind": "SUBSECTION_GAP",
"detail": f"{files[n][0]}: sub-section {g} is missing (numbering gap after an insert)"})
coverage = {}
if manuscript is not None:
if not manuscript.is_file():
sys.stderr.write(f"ERROR: --manuscript not found: {manuscript}\n")
sys.exit(2)
body = manuscript.read_text(encoding="utf-8")
cited = sorted({int(m.group(1)) for m in CALLOUT_RE.finditer(body)})
uncited = [n for n in present if n not in cited]
callout_no_section = [n for n in cited if n not in files]
coverage = {"cited": cited, "uncited_sections": uncited,
"callout_without_section": callout_no_section}
for n in callout_no_section:
problems.append({"kind": "CALLOUT_WITHOUT_SECTION",
"detail": f"body cites Supplementary S{n} but no S{n}_*.md section exists"})
for n in uncited:
problems.append({"kind": "SECTION_UNCITED",
"detail": f"S{n} section file exists but is never cited in the manuscript body"})
rebuilt = None
if out_path is not None:
parts = []
for n in declared:
if n in files:
parts.append((d / files[n][0]).read_text(encoding="utf-8").rstrip())
rebuilt = "\n\n---\n\n".join(parts) + "\n"
out_path.parent.mkdir(parents=True, exist_ok=True)
out_path.write_text(rebuilt, encoding="utf-8")
n_structural = sum(1 for p in problems if p["kind"] in STRUCTURAL)
return {
"dir": str(d),
"declared": declared,
"present": present,
"problems": problems,
"coverage": coverage,
"rebuilt_to": str(out_path) if out_path else None,
"summary": {"n_problems": len(problems), "n_structural": n_structural,
"verdict": "STRUCTURAL_PROBLEM" if n_structural else "OK"},
}
def main() -> int:
ap = argparse.ArgumentParser(description="Supplement assembler + structural validator.")
ap.add_argument("--dir", required=True, help="supplement directory")
ap.add_argument("--index", default="00_index.md", help="index filename (default 00_index.md)")
ap.add_argument("--manuscript", help="manuscript .md for callout coverage")
ap.add_argument("--out", help="write rebuilt _combined.md to this path (index order)")
ap.add_argument("--json", help="write JSON artifact to this path")
ap.add_argument("--strict", action="store_true", help="exit 1 on any structural problem")
ap.add_argument("--quiet", action="store_true", help="suppress stdout report")
args = ap.parse_args()
result = analyze(Path(args.dir), args.index,
Path(args.manuscript) if args.manuscript else None,
Path(args.out) if args.out else None)
if not args.quiet:
print("=" * 41)
print(" Supplement Assembly / Structure")
print("=" * 41)
print(f"declared (index): {result['declared']}")
print(f"present (files): {result['present']}")
for p in result["problems"]:
print(f" [{p['kind']}] {p['detail']}")
if result["rebuilt_to"]:
print(f"rebuilt _combined → {result['rebuilt_to']}")
s = result["summary"]
print(f"\n{'STRUCTURAL PROBLEM: ' + str(s['n_structural']) if s['n_structural'] else 'OK: supplement structure consistent.'}")
if args.json:
Path(args.json).parent.mkdir(parents=True, exist_ok=True)
Path(args.json).write_text(json.dumps(result, indent=2), encoding="utf-8")
if not args.quiet:
print(f"wrote {args.json}")
return 1 if (args.strict and result["summary"]["n_structural"]) else 0
if __name__ == "__main__":
sys.exit(main())
# Author registry — example for blind_sweep.py
#
# Each reviewer entry maps an author's identifiers to their double-blind role label
# (e.g., "Reviewer 1", "Reviewer 2"). The blind_sweep.py script reads this YAML
# and performs phased substitution across supplementary, cover, and response files.
#
# Place a project-local copy under your manuscript root (e.g.,
# `<project>/_shared/authors/author_registry.yaml`) and pass its path to the script.
# Do NOT commit real author identifiers to a public repository — keep the populated
# registry alongside the manuscript, not inside this skill.
reviewers:
- role: "Reviewer 1"
full_names: ["Given Surname"] # roman script, primary author
native_names: [] # e.g., ["성명"] for hangul / kanji
initials_with_period: ["G.S."]
initials_no_period: ["GS"]
email: "first.author@example.org"
orcid: "0000-0000-0000-0000"
- role: "Reviewer 2"
full_names: ["Coauthor Name"]
native_names: []
initials_with_period: ["C.N."]
initials_no_period: ["CN"]
email: null
orcid: null
institutions:
- replace: "Hospital A / University B"
with: "the review team's affiliated institutions"
references:
# Self-cited PROSPERO record or preprint
- replace: "Given Surname, Coauthor Name. Title of Registered Review"
with: "[Authors]. Title of Registered Review"
#!/usr/bin/env python3
"""Blind sweep — redact author identifiers across submission artifacts for double-blind review.
Usage:
python blind_sweep.py --registry path/to/author_registry.yaml \
--files file1.md file2.md ... \
[--inplace | --out-dir staging/blinded]
Reads an author registry (YAML) describing identifiers and their role-label
replacements, then performs phased substitution (specific patterns first,
then bare forms, then regex word-boundary forms). Reports residual identifier
counts for each file after blinding.
Registry schema (YAML):
reviewers:
- role: "Reviewer 1"
full_names: ["Given Surname"] # roman script
native_names: ["성명"] # native script (e.g., hangul)
initials_with_period: ["G.S."]
initials_no_period: ["GS"]
email: "user@example.com"
orcid: "0000-0000-0000-0000"
- role: "Reviewer 2"
...
institutions:
- replace: "Institution A / University B"
with: "the review team's affiliated institutions"
references:
- replace: "Given Surname, Other Author. Title"
with: "[Authors]. Title"
Order of substitution (per file):
1. Institution and reference patterns (longest specific strings first).
2. Role-combination patterns (e.g., "Given Surname (GS, 1st reviewer)").
3. Bare full names and native names.
4. Regex word-boundary patterns for initials (period and no-period forms),
emails, ORCIDs, and combined initial patterns.
The script does NOT hard-code any author identifiers — all PII is sourced
from the registry the caller provides. This keeps the tool PII-free for OSS
distribution.
"""
from __future__ import annotations
import argparse
import pathlib
import re
import shutil
import sys
from dataclasses import dataclass, field
from typing import Iterable
try:
import yaml # type: ignore
except ImportError:
print("blind_sweep requires PyYAML. Install: pip install pyyaml", file=sys.stderr)
sys.exit(2)
@dataclass
class Reviewer:
role: str
full_names: list[str] = field(default_factory=list)
native_names: list[str] = field(default_factory=list)
initials_with_period: list[str] = field(default_factory=list)
initials_no_period: list[str] = field(default_factory=list)
email: str | None = None
orcid: str | None = None
def load_registry(path: pathlib.Path) -> tuple[list[Reviewer], list[tuple[str, str]], list[tuple[str, str]]]:
data = yaml.safe_load(path.read_text(encoding="utf-8"))
reviewers = [Reviewer(**r) for r in data.get("reviewers", [])]
institutions = [(i["replace"], i["with"]) for i in data.get("institutions", [])]
references = [(r["replace"], r["with"]) for r in data.get("references", [])]
return reviewers, institutions, references
def build_substitutions(reviewers: list[Reviewer]) -> tuple[list[tuple[str, str]], list[tuple[str, str]], list[tuple[str, str]]]:
"""Return (phase2_combined, phase3_bare, phase4_regex)."""
phase2_combined: list[tuple[str, str]] = []
phase3_bare: list[tuple[str, str]] = []
phase4_regex: list[tuple[str, str]] = []
for r in reviewers:
for name in r.full_names:
for init in r.initials_no_period + r.initials_with_period:
for label in (
f"{name} ({init}, 1st reviewer)", f"{name} ({init}, 2nd reviewer)",
f"{name} ({init}, 1st)", f"{name} ({init}, 2nd)",
f"{name} ({init})",
):
phase2_combined.append((label, r.role))
phase3_bare.append((name, r.role))
for name in r.native_names:
phase3_bare.append((name, r.role))
for init in r.initials_with_period:
esc = re.escape(init)
phase4_regex.append((rf"\b{esc}", r.role))
for init in r.initials_no_period:
phase4_regex.append((rf"\b{re.escape(init)}\b", r.role))
if r.email:
phase4_regex.append((re.escape(r.email), "[redacted email]"))
if r.orcid:
phase4_regex.append((re.escape(r.orcid), "[redacted ORCID]"))
# Sort phase2/phase3 by length desc so longer-specific patterns win
phase2_combined.sort(key=lambda kv: -len(kv[0]))
phase3_bare.sort(key=lambda kv: -len(kv[0]))
return phase2_combined, phase3_bare, phase4_regex
def blind_text(text: str, phase1: Iterable[tuple[str, str]],
phase2: Iterable[tuple[str, str]], phase3: Iterable[tuple[str, str]],
phase4_regex: Iterable[tuple[str, str]]) -> tuple[str, int]:
changes = 0
for old, new in list(phase1) + list(phase2) + list(phase3):
if old in text:
count = text.count(old)
text = text.replace(old, new)
changes += count
for pat, repl in phase4_regex:
matches = re.findall(pat, text)
if matches:
text = re.sub(pat, repl, text)
changes += len(matches)
return text, changes
def residual_scan(text: str, reviewers: list[Reviewer]) -> list[str]:
residual: list[str] = []
for r in reviewers:
for name in r.full_names + r.native_names:
c = text.count(name)
if c > 0:
residual.append(f"{name}={c}")
for init in r.initials_with_period:
c = len(re.findall(rf"\b{re.escape(init)}", text))
if c > 0:
residual.append(f"{init}={c}")
for init in r.initials_no_period:
c = len(re.findall(rf"\b{re.escape(init)}\b", text))
if c > 0:
residual.append(f"{init}={c}")
if r.email:
c = text.count(r.email)
if c > 0:
residual.append(f"{r.email}={c}")
if r.orcid:
c = text.count(r.orcid)
if c > 0:
residual.append(f"{r.orcid}={c}")
return residual
def main() -> int:
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("--registry", required=True, type=pathlib.Path, help="Author registry YAML")
ap.add_argument("--files", nargs="+", required=True, type=pathlib.Path, help="Files to blind")
ap.add_argument("--inplace", action="store_true", help="Overwrite in place (default)")
ap.add_argument("--out-dir", type=pathlib.Path, help="Write blinded copies under this directory")
ap.add_argument("--backup-dir", type=pathlib.Path, help="Copy originals here before in-place edit")
args = ap.parse_args()
if not args.inplace and not args.out_dir:
args.inplace = True
reviewers, institutions, references = load_registry(args.registry)
phase1 = institutions + references
phase2, phase3, phase4_regex = build_substitutions(reviewers)
if args.backup_dir:
args.backup_dir.mkdir(parents=True, exist_ok=True)
if args.out_dir:
args.out_dir.mkdir(parents=True, exist_ok=True)
print(f"{'FILE':<55} {'CHANGES':<8} RESIDUAL")
overall_residual = False
for src in args.files:
if not src.exists():
print(f"{src.name:<55} {'-':<8} MISSING")
continue
text = src.read_text(encoding="utf-8")
if args.backup_dir:
shutil.copy(src, args.backup_dir / src.name)
new_text, changes = blind_text(text, phase1, phase2, phase3, phase4_regex)
residual = residual_scan(new_text, reviewers)
if args.out_dir:
(args.out_dir / src.name).write_text(new_text, encoding="utf-8")
else:
src.write_text(new_text, encoding="utf-8")
flag = "clean" if not residual else " ".join(residual) + " WARN"
if residual:
overall_residual = True
print(f"{src.name:<55} {changes:<8} {flag}")
return 1 if overall_residual else 0
if __name__ == "__main__":
sys.exit(main())
#!/usr/bin/env python3
"""
check_asset_anonymization.py — submission-stage asset/peripheral anonymization gate.
Body-text anonymization is mature; the blind spot is *peripheral artifacts*. A
double-anonymized submission was nearly broken because a flow-diagram figure,
generated by a script with a hardcoded institution label, carried the real
hospital name — invisible to any docx text scan. File metadata (docx
`dc:creator`, PDF `/Author`) is a second blind spot.
This detector scans a submission/project directory for three deterministic
classes of leak:
1. **figure-script hardcoded institution** — a figure-generating script
(`figures/**/*.R|*.py`, or any `*.R|*.py` under a `figures*` dir) contains
an institution-like token (Hospital / University / Medical Center / IRB /
병원 / 의료원 …) or a name supplied via --names-file.
2. **figure/asset PDF rendered text** — a figure PDF's extracted text carries
an institution token or a --names-file name (de-anonymization risk). When a
figure carries *any* rendered text, a `visual_check` advisory is emitted
(text scanning cannot see rasterized labels).
3. **document metadata author** — a `.docx` `dc:creator`/`cp:lastModifiedBy`
or a `.pdf` Author/Creator is a real person/identifier (not empty / not a
known tool).
4. **docx embedded absolute path** — a `word/*.xml` attribute (e.g. a drawing's
`<pic:cNvPr descr="...">`) carries an absolute home-dir path
(`/Users/<user>/…`, `/home/<user>/…`). pandoc writes the source image path
into the picture description when handed an absolute path, leaking the
username into the docx body XML where a rendered-text scan never sees it.
Fix: use a relative image path + `pandoc --resource-path=<figdir>`.
Severity:
- `leak` — metadata author present, or a --names-file name found anywhere.
- `review` — institution-token hit, or a figure carries rendered text.
Exit: 0 = clean, 1 = findings (any `leak`; also `review` under --strict),
2 = usage/error. Degrades gracefully when poppler (pdftotext/pdfinfo) is absent:
script-grep and docx-metadata checks still run; PDF text/metadata checks are
reported as skipped (poppler_available:false) rather than silently passing.
Patterns are generic — no real names are baked in. Supply institution/author
names locally with --names-file (one per line); that file is never committed.
Stdlib-only.
Usage:
python3 check_asset_anonymization.py --dir submission/ [--names-file names.txt]
[--out qc/asset_anon.json] [--strict] [--quiet]
"""
from __future__ import annotations
import argparse
import json
import re
import shutil
import subprocess
import sys
import zipfile
from dataclasses import dataclass, field, asdict
from pathlib import Path
# Generic institution / ethics-board tokens (English + Korean). No proper names.
INSTITUTION_RE = re.compile(
r"\b(?:Hospital|Hospitals|University|Universit[äe]t|College of Medicine|"
r"School of Medicine|Faculty of Medicine|Medical Cent(?:er|re)|Health System|"
r"Cancer Cent(?:er|re)|Infirmary|Institutional Review Board|"
r"IRB(?:[\s.:#-]*(?:No|Number|#)|\s*approval)?)\b"
r"|병원|의료원|의과대학|대학교|연구윤리",
re.IGNORECASE,
)
# docx/pdf metadata authorship values that are NOT a real person (tools/blanks).
TOOL_AUTHORS = (
"unknown", "microsoft office user", "pandoc", "libreoffice", "writer",
"openoffice", "word", "microsoft word", "google", "overleaf", "latex",
"pdftex", "pdflatex", "xelatex", "lualatex", "quarto", "rmarkdown",
"knitr", "wps", "author",
)
def _is_tool_author(value: str) -> bool:
"""True if a metadata author value is blank or a known tool (not a person)."""
v = value.strip().lower()
if not v:
return True
return any(v == t or v.startswith(t) for t in TOOL_AUTHORS)
FIG_SCRIPT_GLOBS = ("*.R", "*.r", "*.py")
DC_CREATOR_RE = re.compile(r"<dc:creator>([^<]*)</dc:creator>")
LAST_MOD_RE = re.compile(r"<cp:lastModifiedBy>([^<]*)</cp:lastModifiedBy>")
# Absolute home-dir path leaked into an OOXML attribute (e.g. a drawing's
# <pic:cNvPr descr="/Users/<user>/.../fig.png">). pandoc embeds the source image
# path as the picture description when given an absolute path; it carries the
# username into the docx body XML, invisible to a rendered-text scan.
DOCX_ABS_PATH_RE = re.compile(r'(?:descr|name|title)="(/(?:Users|home)/[^"]+)"')
@dataclass
class Finding:
type: str
severity: str # "leak" | "review"
path: str
detail: str
@dataclass
class Report:
findings: list[Finding] = field(default_factory=list)
scanned: dict[str, int] = field(default_factory=dict)
poppler_available: bool = False
skipped: list[str] = field(default_factory=list)
@property
def has_leak(self) -> bool:
return any(f.severity == "leak" for f in self.findings)
def submission_safe(self, strict: bool) -> bool:
if self.has_leak:
return False
if strict and any(f.severity == "review" for f in self.findings):
return False
return True
def as_dict(self, strict: bool) -> dict:
return {
"submission_safe": self.submission_safe(strict),
"strict": strict,
"poppler_available": self.poppler_available,
"scanned": self.scanned,
"skipped": self.skipped,
"summary": {
"leak": sum(1 for f in self.findings if f.severity == "leak"),
"review": sum(1 for f in self.findings if f.severity == "review"),
},
"findings": [asdict(f) for f in self.findings],
}
def _is_under_figures(p: Path) -> bool:
return any(part.lower().startswith(("figure", "fig", "graphic")) for part in p.parts)
def _load_names(names_file: Path | None) -> list[str]:
if not names_file:
return []
out = []
for line in names_file.read_text(encoding="utf-8", errors="replace").splitlines():
s = line.strip()
if s and not s.startswith("#"):
out.append(s)
return out
def _name_hits(text: str, names: list[str]) -> list[str]:
low = text.lower()
return [n for n in names if n.lower() in low]
def _pdftotext(pdf: Path) -> str | None:
try:
r = subprocess.run(["pdftotext", "-q", str(pdf), "-"],
capture_output=True, text=True, timeout=60)
return r.stdout
except Exception:
return None
def _pdf_author(pdf: Path) -> str | None:
try:
r = subprocess.run(["pdfinfo", str(pdf)], capture_output=True, text=True, timeout=30)
except Exception:
return None
author = creator = ""
for line in r.stdout.splitlines():
if line.startswith("Author:"):
author = line.split(":", 1)[1].strip()
elif line.startswith("Creator:"):
creator = line.split(":", 1)[1].strip()
for v in (author, creator):
if v and not _is_tool_author(v):
return v
return None
def _docx_authors(docx: Path) -> list[str]:
try:
with zipfile.ZipFile(docx) as z:
if "docProps/core.xml" not in z.namelist():
return []
core = z.read("docProps/core.xml").decode("utf-8", errors="replace")
except Exception:
return []
vals = []
for m in (*DC_CREATOR_RE.finditer(core), *LAST_MOD_RE.finditer(core)):
v = m.group(1).strip()
if v and not _is_tool_author(v):
vals.append(v)
return vals
def _docx_embedded_abs_paths(docx: Path) -> list[str]:
"""Absolute home-dir paths leaked into word/*.xml attributes (e.g. a
pandoc-embedded image's pic descr). Returns the offending path strings."""
hits: list[str] = []
try:
with zipfile.ZipFile(docx) as z:
parts = [n for n in z.namelist()
if n.startswith("word/") and n.endswith(".xml")]
for name in parts:
xml = z.read(name).decode("utf-8", errors="replace")
for m in DOCX_ABS_PATH_RE.finditer(xml):
hits.append(m.group(1))
except Exception:
return []
# de-dup, preserve order
seen: set[str] = set()
out = []
for h in hits:
if h not in seen:
seen.add(h)
out.append(h)
return out
def build_report(root: Path, names: list[str], poppler: bool) -> Report:
rep = Report(poppler_available=poppler)
scripts = docx_files = pdf_files = 0
for p in sorted(root.rglob("*")):
if not p.is_file() or "__pycache__" in p.parts:
continue
suffix = p.suffix.lower()
rel = str(p.relative_to(root))
# 1. figure-generating scripts
if suffix in (".r", ".py") and _is_under_figures(p):
scripts += 1
text = p.read_text(encoding="utf-8", errors="replace")
for i, line in enumerate(text.splitlines(), 1):
if INSTITUTION_RE.search(line):
rep.findings.append(Finding(
"figure_script_institution", "review", f"{rel}:{i}",
f"institution-like token in figure script: {line.strip()[:120]}"))
for n in _name_hits(line, names):
rep.findings.append(Finding(
"figure_script_name", "leak", f"{rel}:{i}",
f"name '{n}' hardcoded in figure script"))
# 2 + 3. docx metadata
elif suffix == ".docx":
docx_files += 1
for a in _docx_authors(p):
rep.findings.append(Finding(
"docx_metadata_author", "leak", rel,
f"docx author metadata: '{a}'"))
# 4. absolute home-dir path embedded in word/*.xml (e.g. pic descr)
for ap_ in _docx_embedded_abs_paths(p):
rep.findings.append(Finding(
"docx_embedded_abs_path", "leak", rel,
f"absolute path in docx XML (username leak; use a relative "
f"image path + pandoc --resource-path): {ap_}"))
# PDFs: metadata + (figure) rendered-text
elif suffix == ".pdf":
pdf_files += 1
if poppler:
a = _pdf_author(p)
if a:
rep.findings.append(Finding(
"pdf_metadata_author", "leak", rel, f"pdf author/creator: '{a}'"))
if _is_under_figures(p):
txt = _pdftotext(p)
if txt is None:
rep.skipped.append(f"pdftotext failed: {rel}")
elif txt.strip():
rep.findings.append(Finding(
"figure_rendered_text", "review", rel,
"figure PDF carries rendered text — visual-check for "
"institution/IRB#/author labels a text scan cannot see"))
if INSTITUTION_RE.search(txt):
rep.findings.append(Finding(
"figure_text_institution", "review", rel,
"institution-like token in figure PDF text"))
for n in _name_hits(txt, names):
rep.findings.append(Finding(
"figure_text_name", "leak", rel,
f"name '{n}' in figure PDF text"))
else:
rep.skipped.append(f"poppler unavailable, PDF not scanned: {rel}")
rep.scanned = {"figure_scripts": scripts, "docx": docx_files, "pdf": pdf_files}
return rep
def main(argv: list[str] | None = None) -> int:
ap = argparse.ArgumentParser(
description="Submission-stage asset/figure/metadata anonymization gate.")
ap.add_argument("--dir", type=Path, default=Path.cwd(),
help="Root directory to scan (default: cwd).")
ap.add_argument("--names-file", type=Path, default=None,
help="Newline-separated institution/author names to flag (local only).")
ap.add_argument("--out", type=Path, default=None, help="Write JSON report here.")
ap.add_argument("--strict", action="store_true",
help="Also fail on 'review' findings (institution tokens, rendered text).")
ap.add_argument("--quiet", action="store_true", help="Suppress stdout summary.")
args = ap.parse_args(argv)
if not args.dir.is_dir():
print(f"ERROR: --dir not a directory: {args.dir}", file=sys.stderr)
return 2
if args.names_file is not None and not args.names_file.is_file():
print(f"ERROR: --names-file not a file: {args.names_file}", file=sys.stderr)
return 2
poppler = shutil.which("pdftotext") is not None and shutil.which("pdfinfo") is not None
names = _load_names(args.names_file)
rep = build_report(args.dir, names, poppler)
safe = rep.submission_safe(args.strict)
if args.out is not None:
args.out.parent.mkdir(parents=True, exist_ok=True)
args.out.write_text(json.dumps(rep.as_dict(args.strict), indent=2), encoding="utf-8")
if not args.quiet:
if not poppler:
print("NOTE: poppler (pdftotext/pdfinfo) not found — PDF text/metadata "
"checks skipped; install poppler-utils for full coverage.")
if safe:
n = len(rep.findings)
print(f"PASS: no anonymization leak ({rep.scanned}; {n} advisory finding(s)).")
else:
print(f"FAIL: anonymization findings — {rep.as_dict(args.strict)['summary']}")
for f in rep.findings:
print(f" - [{f.severity}] {f.type} {f.path}: {f.detail}")
return 0 if safe else 1
if __name__ == "__main__":
sys.exit(main())
#!/usr/bin/env python3
"""
check_cross_artifact_stale.py — submission-stage cross-artifact staleness gate.
Body-text QC is mature; peripheral artifacts lag. A late correction fixed in the
manuscript body can persist — sometimes *reversed* — in a supplement footnote,
and a reporting checklist is often generated against an older manuscript version
(stale section/line references and a stale version label). Both reach reviewers.
Two deterministic checks:
1. **labeled-value drift** — for a small set of reconciliation-prone labels
(missingness, complete-case, kappa/κ, agreement, prevalence, incidence,
response rate, follow-up, pack-years, mortality), collect every numeric
value the *body* attaches to each label, and every value an *auxiliary*
file (supplement, e-table, caption, checklist) attaches to the same label.
An auxiliary value for a label the body also reports, but which the body
never states, is a `labeled_value_drift` (the supplement disagrees with the
corrected body).
2. **checklist version staleness** — a reporting checklist (file name contains
`checklist`/`strobe`/`prisma`/`consort`/`stard`/`tripod`/`claim`) that
embeds a manuscript-version marker (`manuscript_v6`, `v6 (2026-04-20)`,
`Target manuscript: ... v6`) which differs from the current version
(`--manuscript-version`, or a `vN` in the manuscript filename) is flagged
`checklist_version_stale` — its line/section refs no longer match.
Exit: 0 = clean, 1 = findings, 2 = usage/error. Stdlib-only.
Usage:
python3 check_cross_artifact_stale.py --manuscript manuscript.md \
--aux supplement/ --aux qc/ [--manuscript-version v8] \
[--out qc/cross_artifact.json] [--strict] [--quiet]
"""
from __future__ import annotations
import argparse
import json
import re
import sys
from dataclasses import dataclass, field, asdict
from pathlib import Path
# Reconciliation-prone labels → a regex fragment matching the label.
LABELS: dict[str, str] = {
"missingness": r"missing(?:ness)?",
"complete_case": r"complete[-\s]?case",
"kappa": r"κ|kappa",
"agreement": r"agreement",
"prevalence": r"prevalence",
"incidence": r"incidence",
"response_rate": r"response\s+rate",
"follow_up": r"follow[-\s]?up",
"pack_years": r"pack[-\s]?years?",
"mortality": r"mortality",
}
# A number near a label: label … (within 40 chars) … value, optional %.
VALUE_RE = r"[^\n.]{0,40}?(\d+(?:\.\d+)?)\s*(%?)"
CHECKLIST_NAME_RE = re.compile(
r"(checklist|strobe|prisma|consort|stard|tripod|claim|squire|arrive|care)",
re.IGNORECASE,
)
# Manuscript-version markers a checklist might embed.
VERSION_MARKER_RE = re.compile(
r"(?:manuscript[_\s]*|target\s+manuscript[^\n]*?\bv|version[^\n]*?\bv|\bv)"
r"(\d{1,3})\b",
re.IGNORECASE,
)
FILENAME_VERSION_RE = re.compile(r"[_\-.]v(\d{1,3})\b", re.IGNORECASE)
@dataclass
class Finding:
type: str
severity: str # "stale" | "version_stale"
path: str
detail: str
@dataclass
class Report:
findings: list[Finding] = field(default_factory=list)
scanned: dict[str, int] = field(default_factory=dict)
@property
def submission_safe(self) -> bool:
return not self.findings
def as_dict(self) -> dict:
return {
"submission_safe": self.submission_safe,
"scanned": self.scanned,
"summary": {
"stale": sum(1 for f in self.findings if f.severity == "stale"),
"version_stale": sum(1 for f in self.findings if f.severity == "version_stale"),
},
"findings": [asdict(f) for f in self.findings],
}
def label_values(text: str) -> dict[str, set[str]]:
"""Map each known label to the set of numeric values stated near it."""
out: dict[str, set[str]] = {}
for key, frag in LABELS.items():
vals: set[str] = set()
for m in re.finditer(frag + VALUE_RE, text, re.IGNORECASE):
num, pct = m.group(1), m.group(2)
vals.add(num + ("%" if pct else ""))
if vals:
out[key] = vals
return out
def _iter_files(paths: list[Path]) -> list[Path]:
files: list[Path] = []
for p in paths:
if p.is_dir():
files += [q for q in sorted(p.rglob("*"))
if q.is_file() and q.suffix.lower() in (".md", ".txt", ".csv", ".tsv", ".yaml", ".yml")]
elif p.is_file():
files.append(p)
return files
def _manuscript_version(manuscript: Path, explicit: str | None) -> int | None:
if explicit:
m = re.search(r"\d+", explicit)
if m:
return int(m.group(0))
m = FILENAME_VERSION_RE.search(manuscript.name)
return int(m.group(1)) if m else None
def build_report(manuscript: Path, aux_paths: list[Path], version: int | None) -> Report:
rep = Report()
body = manuscript.read_text(encoding="utf-8", errors="replace")
body_labels = label_values(body)
aux_files = [f for f in _iter_files(aux_paths) if f.resolve() != manuscript.resolve()]
for f in aux_files:
text = f.read_text(encoding="utf-8", errors="replace")
rel = str(f)
# 1. labeled-value drift vs the body
for key, vals in label_values(text).items():
if key not in body_labels:
continue # body does not report this label — not a reconciliation target
drift = vals - body_labels[key]
for v in sorted(drift):
rep.findings.append(Finding(
"labeled_value_drift", "stale", rel,
f"'{key}' = {v} here, but the body reports "
f"{sorted(body_labels[key])} — possible stale value"))
# 2. checklist version staleness
if version is not None and CHECKLIST_NAME_RE.search(f.name):
embedded = {int(m.group(1)) for m in VERSION_MARKER_RE.finditer(text)}
older = sorted(v for v in embedded if v < version)
if older:
rep.findings.append(Finding(
"checklist_version_stale", "version_stale", rel,
f"references manuscript version(s) v{older} but current is v{version}"))
rep.scanned = {"aux_files": len(aux_files), "body_labels": len(body_labels)}
return rep
def main(argv: list[str] | None = None) -> int:
ap = argparse.ArgumentParser(
description="Cross-artifact staleness gate (labeled-value drift + checklist version).")
ap.add_argument("--manuscript", type=Path, required=True, help="Body manuscript markdown.")
ap.add_argument("--aux", type=Path, action="append", default=[],
help="Auxiliary file or directory (supplement/checklist/captions). Repeatable.")
ap.add_argument("--manuscript-version", default=None,
help="Current manuscript version, e.g. v8 (else inferred from filename).")
ap.add_argument("--out", type=Path, default=None, help="Write JSON report here.")
ap.add_argument("--strict", action="store_true",
help="(Reserved) all findings already fail; flag kept for interface parity.")
ap.add_argument("--quiet", action="store_true", help="Suppress stdout summary.")
args = ap.parse_args(argv)
if not args.manuscript.is_file():
print(f"ERROR: --manuscript not a file: {args.manuscript}", file=sys.stderr)
return 2
if not args.aux:
print("ERROR: at least one --aux is required", file=sys.stderr)
return 2
version = _manuscript_version(args.manuscript, args.manuscript_version)
rep = build_report(args.manuscript, args.aux, version)
if args.out is not None:
args.out.parent.mkdir(parents=True, exist_ok=True)
args.out.write_text(json.dumps(rep.as_dict(), indent=2), encoding="utf-8")
if not args.quiet:
if rep.submission_safe:
print(f"PASS: no cross-artifact staleness ({rep.scanned}).")
else:
print(f"FAIL: cross-artifact staleness — {rep.as_dict()['summary']}")
for f in rep.findings:
print(f" - [{f.severity}] {f.type} {f.path}: {f.detail}")
return 0 if rep.submission_safe else 1
if __name__ == "__main__":
sys.exit(main())
#!/usr/bin/env python3
"""AI-disclosure + data/code-availability statement detector (sync-submission).
Top medical-AI journals (Lancet Digital Health, Radiology / Radiology:AI, npj
Digital Medicine, Nature Medicine) now require, before peer review:
- an AI/LLM-use disclosure that itself names the tool **version**, the **access
channel**, the **date / date-range**, and the **responsible party** (the four
tokens FLAIR F1.6 / TRIPOD-LLM / MI-CLEAR-LLM demand; the tool NAME, e.g.
ChatGPT/Claude, is the applicability identifier that triggers the check, NOT
one of the four required tokens), with no unresolved placeholders;
- a Data Availability statement (not a hollow "available on reasonable request"
when the journal expects a repository);
- a Code Availability statement with a resolvable URL/DOI for an AI/ML study.
This detector scans the manuscript for those statements and checks them against
references/journal_availability_policy.json (public facts, journal-keyed). It is
deterministic and stdlib-only.
INPUTS
--manuscript markdown file (required).
--journal journal stem (selects the policy row; falls back to "default").
--policy path to journal_availability_policy.json (default: alongside skill).
--ai-study treat as an AI/ML study (code availability becomes expected).
--require repeatable hard-required statement(s): ai_disclosure |
data_availability | code_availability | funding | coi. An absent
required statement is a BLOCKER regardless of --strict.
--strict promote advisory (P1) findings to blockers.
--out JSON report path (default: qc/disclosure_availability_report.json).
VERDICT / EXIT
CLEAN no findings.
ADVISORY only P1 (warn) findings.
BLOCKER a hard rule failed: a --require'd statement absent, OR an AI
disclosure present but missing a required token / carrying a
placeholder.
Exit: 0 clean/advisory (or report-only); 1 BLOCKER (or ADVISORY under --strict);
2 input/usage error.
Stdlib-only.
"""
from __future__ import annotations
import argparse
import json
import re
import sys
from pathlib import Path
AI_TRIGGER = re.compile(
r"generative ai|large language model|\bLLM\b|ai[- ]assisted|"
r"assisted (?:the|with|in) (?:writing|drafting|editing)|"
r"\bChatGPT\b|\bGPT-?[0-9]|\bClaude\b|\bCopilot\b|\bGemini\b|\bLlama\b",
re.IGNORECASE,
)
TOKEN_VERSION = re.compile(r"\b\d+\.\d+\b|\bGPT-?\d|\b(?:Claude|Gemini|Llama|GPT)\s+\d", re.IGNORECASE)
TOKEN_CHANNEL = re.compile(r"\bAPI\b|\bchat\b|\bweb\b|\bBedrock\b|\bAzure\b|\binterface\b|\bapp\b", re.IGNORECASE)
TOKEN_DATE = re.compile(r"\b20\d{2}\b")
TOKEN_RESPONSIBLE = re.compile(
r"\bby [A-Z]\.\s?[A-Z]\.|the authors|reviewed by|deployed by|operated by|under the supervision",
re.IGNORECASE,
)
PLACEHOLDER = re.compile(r"\[(?:version|date|tool|name|model|n)\]|\bTODO\b|XXXX|\bTBD\b", re.IGNORECASE)
REASONABLE_REQUEST = re.compile(r"available (?:from the (?:corresponding )?author )?on (?:reasonable )?request", re.IGNORECASE)
RESOLVABLE = re.compile(r"https?://|doi\.org/|\bgithub\.com\b|\bzenodo\b|\bosf\.io\b|10\.\d{4,}/", re.IGNORECASE)
SECTION_LABELS = {
"data_availability": r"data availability|availability of data",
"code_availability": r"code availability|availability of code|software availability",
"funding": r"funding|financial support|grant support",
"coi": r"conflicts? of interest|competing interests?|declaration of interests?|disclosure",
}
def _err(msg: str) -> int:
print(f"ERROR: {msg}", file=sys.stderr)
return 2
def load_policy(path: Path, journal: str | None) -> dict:
data = json.loads(path.read_text(encoding="utf-8"))
if journal:
row = data.get("journals", {}).get(journal.strip().lower())
if row:
return row
return data.get("default", {})
def find_section(text: str, pattern: str) -> str | None:
"""Return the block of text under a heading/bold label matching `pattern`."""
lines = text.splitlines()
head = re.compile(r"^\s*(?:#{1,6}\s*|\*\*\s*)?(?:" + pattern + r")\b", re.IGNORECASE)
start = None
for i, ln in enumerate(lines):
if head.search(ln):
start = i
break
if start is None:
return None
out = [lines[start]]
for ln in lines[start + 1:]:
if re.match(r"^\s*#{1,6}\s+\S", ln):
break
out.append(ln)
return "\n".join(out).strip()
def ai_disclosure_block(text: str) -> str | None:
"""Find the paragraph that carries the AI-use disclosure (the one that trips
AI_TRIGGER), preferring an explicit AI-disclosure-style heading if present."""
for pat in (r"ai (?:use )?disclosure|use of (?:generative )?ai|artificial intelligence",):
blk = find_section(text, pat)
if blk and AI_TRIGGER.search(blk):
return blk
# else: the first paragraph that mentions an AI tool
for para in re.split(r"\n\s*\n", text):
if AI_TRIGGER.search(para):
return para.strip()
return None
def check(text: str, policy: dict, ai_study: bool, require: set[str], strict: bool) -> dict:
findings: list[dict] = []
# --- AI disclosure (only when the manuscript actually used/mentioned an AI tool) ---
blk = ai_disclosure_block(text)
if blk is not None:
tokens = {
"version": bool(TOKEN_VERSION.search(blk)),
"access channel": bool(TOKEN_CHANNEL.search(blk)),
"date": bool(TOKEN_DATE.search(blk)),
"responsible party": bool(TOKEN_RESPONSIBLE.search(blk)),
}
missing = [k for k, v in tokens.items() if not v]
if missing:
findings.append({"rule": "ai_disclosure_tokens", "severity": "hard",
"detail": f"AI disclosure missing required token(s): {', '.join(missing)}"})
if PLACEHOLDER.search(blk):
findings.append({"rule": "ai_disclosure_placeholder", "severity": "hard",
"detail": "AI disclosure contains an unresolved placeholder ([version]/[date]/TODO/...)"})
elif "ai_disclosure" in require:
findings.append({"rule": "ai_disclosure_present", "severity": "hard",
"detail": "no AI-use disclosure found, but --require ai_disclosure was set"})
# --- Data availability ---
data_blk = find_section(text, SECTION_LABELS["data_availability"])
data_required = policy.get("data_required", False) or ("data_availability" in require)
if data_blk is None:
if data_required:
findings.append({"rule": "data_availability_present", "severity": "hard",
"detail": "no Data Availability statement found"})
else:
if policy.get("repository_required") and REASONABLE_REQUEST.search(data_blk) and not RESOLVABLE.search(data_blk):
findings.append({"rule": "data_availability_hollow", "severity": "soft",
"detail": "Data Availability is 'available on request' but the journal expects a repository/DOI"})
# --- Code availability (AI/ML studies) ---
code_blk = find_section(text, SECTION_LABELS["code_availability"])
code_required = ("code_availability" in require) or (ai_study and policy.get("code_required_if_ai", False))
if code_blk is None:
if code_required:
findings.append({"rule": "code_availability_present", "severity": "hard",
"detail": "no Code Availability statement found for an AI/ML study"})
elif not RESOLVABLE.search(code_blk):
findings.append({"rule": "code_availability_resolvable", "severity": "soft",
"detail": "Code Availability statement has no resolvable URL/DOI (github/zenodo/doi.org)"})
# --- Funding / COI presence ---
for key in ("funding", "coi"):
blk2 = find_section(text, SECTION_LABELS[key])
if blk2 is None:
sev = "hard" if key in require else "soft"
findings.append({"rule": f"{key}_present", "severity": sev,
"detail": f"no {key.upper() if key == 'coi' else key.title()} statement found"})
hard = any(f["severity"] == "hard" for f in findings)
if hard:
verdict = "BLOCKER"
elif findings:
verdict = "BLOCKER" if strict else "ADVISORY"
else:
verdict = "CLEAN"
return {"verdict": verdict, "ai_disclosure_found": blk is not None, "findings": findings}
def main() -> int:
ap = argparse.ArgumentParser(description="Check AI-disclosure + data/code-availability statements.")
ap.add_argument("--manuscript", required=True)
ap.add_argument("--journal")
ap.add_argument("--policy")
ap.add_argument("--ai-study", action="store_true")
ap.add_argument("--require", action="append", default=[],
choices=["ai_disclosure", "data_availability", "code_availability", "funding", "coi"])
ap.add_argument("--strict", action="store_true")
ap.add_argument("--out")
args = ap.parse_args()
man = Path(args.manuscript)
if not man.is_file():
return _err(f"manuscript not found: {man}")
policy_path = Path(args.policy) if args.policy else \
Path(__file__).resolve().parent.parent / "references" / "journal_availability_policy.json"
if not policy_path.is_file():
return _err(f"policy not found: {policy_path}")
policy = load_policy(policy_path, args.journal)
report = check(man.read_text(encoding="utf-8"), policy, args.ai_study, set(args.require), args.strict)
out_path = Path(args.out) if args.out else Path("qc") / "disclosure_availability_report.json"
out_path.parent.mkdir(parents=True, exist_ok=True)
out_path.write_text(json.dumps(report, indent=2) + "\n", encoding="utf-8")
print("=" * 41)
print(" Disclosure & Availability")
print("=" * 41)
print(f"journal: {args.journal or 'default'} ai-study: {args.ai_study}")
print(f"verdict: {report['verdict']}")
for f in report["findings"]:
print(f" [{f['severity']}] {f['rule']}: {f['detail']}")
print(f"report: {out_path}")
if report["verdict"] == "BLOCKER":
print("\nDISCLOSURE_AVAILABILITY_BLOCKER", file=sys.stderr)
return 1
return 0
if __name__ == "__main__":
sys.exit(main())
#!/usr/bin/env python3
"""Body-word-count vs journal cap gate (the revision-inflation trap).
A revise loop monotonically *adds* words — resolving reviewer majors appends
sentences, sensitivity analyses, and caveats — and silently pushes the body over
the target journal's word limit. It is caught, if at all, only by a manual
measurement late in the cycle. This gate makes the measurement deterministic and
cheap enough to re-run after every `/revise` pass.
It counts the manuscript **body** (Introduction → Discussion), excluding YAML
front matter, the abstract, references, tables/figures, supplementary, and the
declaration sections (the same skip set as the cover-letter drift check, vendored
here so this script is self-contained), and compares it to a word cap.
THE BINDING NUMBER IS THE RENDERED WORD COUNT. pandoc citeproc expands each
`[@key]` to "(Author Year)", so the rendered DOCX counts higher than the markdown.
This gate approximates the rendered count as `body_words + n_inline_citations *
--citation-expansion` (default 1.6). When you have the authoritative rendered
count (e.g. Word's count on the built DOCX), pass it with `--rendered-words N` and
that is used verbatim.
CAP SOURCE
--limit N the body word cap (deterministic; preferred).
--journal-profile P a find-journal profile .md; the cap is parsed from the
[--article-type T] article-type line (default match: "Original"). If the
cap cannot be parsed to a single integer, the script
errors and asks for --limit (no fuzzy guessing).
OUTPUT
stdout summary and, with --out, a JSON artifact:
{manuscript, body_words, n_inline_citations, rendered_words_est, limit,
near_threshold, ratio, verdict}
WORDCOUNT_OVER_CAP (Major) when the effective count exceeds the cap;
WORDCOUNT_NEAR_CAP (Minor) when it exceeds near_threshold * cap (default 0.95).
Exit 1 (with --strict) when WORDCOUNT_OVER_CAP fires.
Stdlib-only (re / json / argparse / pathlib). Exit codes: 0 clean / near (or
report-only), 1 over cap (with --strict), 2 input/usage error.
"""
from __future__ import annotations
import argparse
import json
import re
import sys
from pathlib import Path
# --- measurement (vendored from cover_letter_drift_check.py; keep in sync) -----
SKIP_SECTION_RE = re.compile(
r"^#{1,3}\s+\*{0,2}\s*("
r"Abstract|References?|Table\s+Captions?|Table\s+Legends?|Figure\s+Legends?|"
r"Tables?|Figures?|Supplementary\s+(Materials?|Tables?|Figures?|Appendix)|"
r"Acknowled[gd]e?ments?|Funding|Conflicts?\s+of\s+Interest|COI|"
r"Author\s+Contributions?|Data\s+Availability|Code\s+Availability|"
r"AI\s+Disclosure|Artificial\s+Intelligence\s+Disclosure"
r")\s*\*{0,2}\s*:?\s*$",
re.IGNORECASE,
)
YAML_FENCE_RE = re.compile(r"^---\s*$")
WORD_RE = re.compile(r"[A-Za-z0-9][A-Za-z0-9'./%\-]*")
# pandoc inline citations: [@key], [@k1; @k2], [-@k]. Count each @key.
CITE_RE = re.compile(r"@[A-Za-z0-9_][A-Za-z0-9_:.\-]*")
HEADER_RE = re.compile(r"^#{1,3}\s")
def _strip_yaml_front_matter(lines: list[str]) -> list[str]:
if not lines or not YAML_FENCE_RE.match(lines[0].rstrip()):
return lines
for i, line in enumerate(lines[1:], start=1):
if YAML_FENCE_RE.match(line.rstrip()):
return lines[i + 1:]
return lines
def measure_body(manuscript_path: Path) -> tuple[int, int]:
"""Return (body_words, n_inline_citations) over the non-skipped body."""
lines = manuscript_path.read_text(encoding="utf-8").splitlines()
body_lines = _strip_yaml_front_matter(lines)
in_skip = False
in_code_fence = False
words = 0
cites = 0
for line in body_lines:
stripped = line.rstrip()
if stripped.startswith("```"):
in_code_fence = not in_code_fence
continue
if in_code_fence:
continue
if HEADER_RE.match(stripped):
in_skip = bool(SKIP_SECTION_RE.match(stripped))
continue
if in_skip:
continue
if stripped.startswith("|") or stripped.startswith("<!--"):
continue
cites += len(CITE_RE.findall(stripped))
# Don't count the citation tokens themselves as prose words.
prose = CITE_RE.sub(" ", stripped)
words += len(WORD_RE.findall(prose))
return words, cites
# --- cap from a journal profile --------------------------------------------
# "Original Article (4,000 words ...)" / "Original Research Article (≤ 5,000 words ...)"
PROFILE_LIMIT_RE = re.compile(r"(?:≤|<=|<|up to|max(?:imum)?)?\s*([0-9][0-9,]{2,})\s*[- ]?words?",
re.IGNORECASE)
def parse_cap_from_profile(profile: Path, article_type: str) -> int:
if not profile.is_file():
sys.stderr.write(f"ERROR: journal profile not found: {profile}\n")
sys.exit(2)
want = article_type.lower()
candidates: list[int] = []
for line in profile.read_text(encoding="utf-8").splitlines():
if want in line.lower():
nums = [int(m.group(1).replace(",", "")) for m in PROFILE_LIMIT_RE.finditer(line)]
# the first "N words" on the article-type line is the body cap
if nums:
candidates.append(nums[0])
uniq = sorted(set(candidates))
if len(uniq) != 1:
sys.stderr.write(
f"ERROR: could not parse a single body word cap for article type "
f"'{article_type}' from {profile.name} (found {uniq or 'none'}). "
f"Pass --limit N explicitly.\n")
sys.exit(2)
return uniq[0]
# --- core ------------------------------------------------------------------
def analyze(manuscript: Path, limit: int, citation_expansion: float,
near_threshold: float, rendered_words: int | None) -> dict:
if not manuscript.is_file():
sys.stderr.write(f"ERROR: manuscript not found: {manuscript}\n")
sys.exit(2)
body_words, n_cites = measure_body(manuscript)
if rendered_words is not None:
effective = rendered_words
basis = "rendered_words (authoritative)"
else:
effective = body_words + round(n_cites * citation_expansion)
basis = f"body_words + {n_cites} citations x {citation_expansion}"
ratio = effective / limit if limit else 0.0
if effective > limit:
verdict, severity = "WORDCOUNT_OVER_CAP", "Major"
elif effective > near_threshold * limit:
verdict, severity = "WORDCOUNT_NEAR_CAP", "Minor"
else:
verdict, severity = "OK", None
return {
"manuscript": str(manuscript),
"body_words": body_words,
"n_inline_citations": n_cites,
"rendered_words_est": effective,
"rendered_basis": basis,
"limit": limit,
"near_threshold": near_threshold,
"ratio": round(ratio, 4),
"verdict": verdict,
"severity": severity,
}
def main() -> int:
ap = argparse.ArgumentParser(description="Body word count vs journal cap gate.")
ap.add_argument("--manuscript", required=True, help="manuscript markdown")
ap.add_argument("--limit", type=int, help="body word cap (preferred; deterministic)")
ap.add_argument("--journal-profile", help="find-journal profile .md to parse the cap from")
ap.add_argument("--article-type", default="Original",
help="article-type label to match in the profile (default: 'Original')")
ap.add_argument("--rendered-words", type=int,
help="authoritative rendered (DOCX) body word count; overrides the estimate")
ap.add_argument("--citation-expansion", type=float, default=1.6,
help="rendered words added per inline citation (citeproc expansion; default 1.6)")
ap.add_argument("--near-threshold", type=float, default=0.95,
help="fraction of the cap that triggers WORDCOUNT_NEAR_CAP (default 0.95)")
ap.add_argument("--out", help="write JSON artifact to this path")
ap.add_argument("--strict", action="store_true", help="exit 1 if over cap")
ap.add_argument("--quiet", action="store_true", help="suppress stdout summary")
args = ap.parse_args()
if args.limit is None and not args.journal_profile:
sys.stderr.write("ERROR: pass --limit N or --journal-profile <path>\n")
return 2
limit = args.limit
if limit is None:
limit = parse_cap_from_profile(Path(args.journal_profile), args.article_type)
result = analyze(Path(args.manuscript), limit, args.citation_expansion,
args.near_threshold, args.rendered_words)
if not args.quiet:
print("=" * 41)
print(" Word-Count vs Journal Cap")
print("=" * 41)
print(f"body words (md) : {result['body_words']:,}")
print(f"inline citations : {result['n_inline_citations']:,}")
print(f"rendered est : {result['rendered_words_est']:,} [{result['rendered_basis']}]")
print(f"journal cap : {result['limit']:,} (ratio {result['ratio']:.2f})")
if result["verdict"] == "WORDCOUNT_OVER_CAP":
print(f"\nMAJOR: body exceeds the cap by {result['rendered_words_est'] - result['limit']:,} "
f"words. Relocate methods/sensitivity detail to the Supplement; the binding "
f"number is the rendered DOCX count.")
elif result["verdict"] == "WORDCOUNT_NEAR_CAP":
print(f"\nMINOR: body is within {round((1 - result['ratio']) * 100)}% of the cap — a "
f"further revise pass will likely breach it.")
else:
print("\nOK: body is within the journal cap.")
if args.out:
Path(args.out).parent.mkdir(parents=True, exist_ok=True)
Path(args.out).write_text(json.dumps(result, indent=2), encoding="utf-8")
if not args.quiet:
print(f"\nwrote {args.out}")
return 1 if (args.strict and result["verdict"] == "WORDCOUNT_OVER_CAP") else 0
if __name__ == "__main__":
sys.exit(main())
#!/usr/bin/env python3
"""
cover_letter_drift_check.py — Phase 4 cover-letter free-text drift gate.
Compares the numeric claims embedded in a cover letter (body word count,
abstract word count, reference count, table/figure count, reporting-guideline
status) against the manuscript artifacts that should be their source of truth.
Emits a drift report when the cover letter has gone stale relative to the
manuscript.
Why this gate exists
====================
Cover letters are submission-portal sidecar artifacts that the docx scanners
in this skill do not touch. When a manuscript branches v_N → v_(N+1) (word
limit retarget, abstract restructure, new reference batch), the cover letter
is routinely forgotten. The free-text claims in `## Article details` or the
opening paragraph remain frozen at the v_N counts.
Cross-project observation (anonymized): a CK-line manuscript was compressed
to 3,036 body words and a 319-word abstract during the alignment round, but
the cover letter still said "approximately 3,790 words", "250 words", and
"12 verified references". All three claims surfaced only via a manual grep
sweep at the portal-upload stage. Editor desk reviewers compare cover-letter
claims against the manuscript body — a mismatch is read as either careless
preparation or a late-edit failure.
Usage
=====
python cover_letter_drift_check.py \\
--manuscript manuscript.md \\
--cover-letter cover_letter.md \\
--abstract abstract.md \\
--refs refs.bib \\
--out qc/cover_letter_drift.json
If `--abstract` is omitted, the abstract is extracted from the manuscript
front matter (heuristics: H1 "Abstract" section, or YAML `abstract:` field).
The script never edits the cover letter — it only reports drift. Resolution
is to update the cover letter (and optionally re-anchor the claims to a
computed-at-build-time helper).
Exit codes
==========
- 0: no drift detected.
- 2: drift detected (any reported value disagrees with the manuscript).
- 1: usage error (input files missing, malformed).
"""
from __future__ import annotations
import argparse
import json
import re
import sys
from pathlib import Path
from typing import Optional
# ---------------------------------------------------------------------------
# Manuscript measurement helpers
# ---------------------------------------------------------------------------
# Section heading patterns to skip when counting "body" words.
SKIP_SECTION_RE = re.compile(
r"^#{1,3}\s+\*{0,2}\s*("
r"Abstract|References?|Table\s+Captions?|Table\s+Legends?|Figure\s+Legends?|"
r"Tables?|Figures?|Supplementary\s+(Materials?|Tables?|Figures?|Appendix)|"
r"Acknowled[gd]e?ments?|Funding|Conflicts?\s+of\s+Interest|COI|"
r"Author\s+Contributions?|Data\s+Availability|Code\s+Availability|"
r"AI\s+Disclosure|Artificial\s+Intelligence\s+Disclosure"
r")\s*\*{0,2}\s*:?\s*$",
re.IGNORECASE,
)
# Section heading that starts the abstract.
ABSTRACT_START_RE = re.compile(
r"^#{1,3}\s+\*{0,2}\s*Abstract\s*\*{0,2}\s*:?\s*$", re.IGNORECASE
)
# YAML frontmatter delimiters.
YAML_FENCE_RE = re.compile(r"^---\s*$")
# Word-counting tokenizer: splits on whitespace, drops markdown punctuation-only
# tokens (e.g., "—", "•", standalone "1." numbering) so prose density isn't
# inflated.
WORD_RE = re.compile(r"[A-Za-z0-9][A-Za-z0-9'./%\-]*")
def _strip_yaml_front_matter(lines: list[str]) -> tuple[list[str], list[str]]:
"""Return (yaml_lines, body_lines) splitting on first two `---` fences."""
if not lines or not YAML_FENCE_RE.match(lines[0].rstrip()):
return [], lines
yaml_block: list[str] = []
for i, line in enumerate(lines[1:], start=1):
if YAML_FENCE_RE.match(line.rstrip()):
return lines[1:i], lines[i + 1 :]
yaml_block.append(line)
# Unclosed front matter — treat as no front matter.
return [], lines
def _next_section_boundary(lines: list[str], start: int) -> int:
"""Return index of the next `^#{1,3}\\s` line at or after `start`, or len(lines)."""
pat = re.compile(r"^#{1,3}\s")
for i in range(start, len(lines)):
if pat.match(lines[i]):
return i
return len(lines)
def count_body_words(manuscript_path: Path) -> int:
"""Count words in manuscript body, excluding YAML front matter, abstract,
references, tables, figures, supplementary, acknowledgments, and
declaration sections."""
lines = manuscript_path.read_text(encoding="utf-8").splitlines()
_, body_lines = _strip_yaml_front_matter(lines)
in_skip = False
in_code_fence = False
total = 0
for line in body_lines:
stripped = line.rstrip()
# Toggle code fence (don't count code).
if stripped.startswith("```"):
in_code_fence = not in_code_fence
continue
if in_code_fence:
continue
# Section header?
if re.match(r"^#{1,3}\s", stripped):
in_skip = bool(SKIP_SECTION_RE.match(stripped))
continue
if in_skip:
continue
# Skip table rows (pipe-leading) and HTML comments.
if stripped.startswith("|") or stripped.startswith("<!--"):
continue
total += len(WORD_RE.findall(stripped))
return total
def extract_abstract_text(manuscript_path: Path) -> str:
"""Extract abstract section text from manuscript (best-effort)."""
lines = manuscript_path.read_text(encoding="utf-8").splitlines()
yaml_lines, body_lines = _strip_yaml_front_matter(lines)
# First try YAML `abstract:` field.
yaml_text = "\n".join(yaml_lines)
yaml_match = re.search(
r"^abstract:\s*(?:\||>)?\s*\n((?:[ \t]+.+\n?)+)", yaml_text, re.MULTILINE
)
if yaml_match:
block = yaml_match.group(1)
return "\n".join(ln.lstrip() for ln in block.splitlines())
# Otherwise locate "## Abstract" section.
for i, line in enumerate(body_lines):
if ABSTRACT_START_RE.match(line.rstrip()):
end = _next_section_boundary(body_lines, i + 1)
return "\n".join(body_lines[i + 1 : end])
return ""
def count_abstract_words(manuscript_path: Path, abstract_path: Optional[Path]) -> int:
if abstract_path is not None and abstract_path.exists():
text = abstract_path.read_text(encoding="utf-8")
else:
text = extract_abstract_text(manuscript_path)
# Drop subheaders like "**Objectives:**" — keep the prose only.
text = re.sub(r"\*{1,3}[^*]+\*{1,3}\s*:?", " ", text)
return len(WORD_RE.findall(text))
# ---------------------------------------------------------------------------
# Reference / figure / table counts
# ---------------------------------------------------------------------------
BIB_ENTRY_RE = re.compile(r"^@[A-Za-z]+\s*\{", re.MULTILINE)
def count_bib_entries(refs_path: Path) -> int:
text = refs_path.read_text(encoding="utf-8", errors="ignore")
return len(BIB_ENTRY_RE.findall(text))
def count_used_citations(manuscript_path: Path) -> int:
"""Count unique pandoc-style [@key] citations actually used in the manuscript."""
text = manuscript_path.read_text(encoding="utf-8")
keys = re.findall(r"\[-?@([A-Za-z0-9_:.\-]+)", text)
return len(set(keys))
def count_table_labels(manuscript_path: Path) -> int:
"""Count distinct `Table N` labels in manuscript body."""
text = manuscript_path.read_text(encoding="utf-8")
nums = set()
for m in re.finditer(r"\bTable\s+(\d+)\b", text):
nums.add(int(m.group(1)))
return len(nums)
def count_figure_labels(manuscript_path: Path) -> int:
"""Count distinct `Figure N` labels in manuscript body."""
text = manuscript_path.read_text(encoding="utf-8")
nums = set()
for m in re.finditer(r"\bFigure\s+(\d+)\b", text):
nums.add(int(m.group(1)))
return len(nums)
# ---------------------------------------------------------------------------
# Cover-letter claim extraction
# ---------------------------------------------------------------------------
# "approximately 3,790 words" / "3790 words" / "approx. 3,036 words"
BODY_WORDS_RE = re.compile(
r"(?:approximately|approx\.?|about|roughly|~)?\s*"
r"([0-9][0-9,]*)\s*(?:body\s+)?words?\b",
re.IGNORECASE,
)
# "250-word abstract" / "abstract: 250 words"
ABSTRACT_WORDS_RE = re.compile(
r"(?:abstract[^.\n]*?([0-9][0-9,]*)\s*words?"
r"|([0-9][0-9,]*)[\s-]+word\s+abstract)",
re.IGNORECASE,
)
# "12 references" / "12 verified references" / "references: 12"
REF_COUNT_RE = re.compile(
r"(?:([0-9][0-9,]*)\s+(?:verified\s+)?references?\b"
r"|references?\s*[:\-]\s*([0-9][0-9,]*))",
re.IGNORECASE,
)
# "3 tables and 4 figures" / "Tables: 3" / "Figures: 4"
TABLE_COUNT_RE = re.compile(
r"(?:([0-9]+)\s+tables?\b|tables?\s*[:\-]\s*([0-9]+))",
re.IGNORECASE,
)
FIGURE_COUNT_RE = re.compile(
r"(?:([0-9]+)\s+figures?\b|figures?\s*[:\-]\s*([0-9]+))",
re.IGNORECASE,
)
def _coalesce_match(match: re.Match) -> Optional[int]:
for group in match.groups():
if group:
return int(group.replace(",", ""))
return None
def extract_claims(cover_letter_path: Path) -> dict:
"""Pull all numeric claims out of the cover letter body."""
text = cover_letter_path.read_text(encoding="utf-8")
claims: dict = {}
body_matches = [_coalesce_match(m) for m in BODY_WORDS_RE.finditer(text)]
body_matches = [v for v in body_matches if v is not None and v >= 500]
if body_matches:
# Take the largest figure that could plausibly be body word count.
# (Cover letters sometimes also mention "250 words" for abstract — the
# abstract regex picks that up separately.)
claims["body_words"] = max(body_matches)
abstract_matches = [_coalesce_match(m) for m in ABSTRACT_WORDS_RE.finditer(text)]
abstract_matches = [v for v in abstract_matches if v is not None and v <= 600]
if abstract_matches:
claims["abstract_words"] = abstract_matches[0]
ref_matches = [_coalesce_match(m) for m in REF_COUNT_RE.finditer(text)]
ref_matches = [v for v in ref_matches if v is not None and v <= 500]
if ref_matches:
claims["references"] = ref_matches[0]
table_matches = [_coalesce_match(m) for m in TABLE_COUNT_RE.finditer(text)]
table_matches = [v for v in table_matches if v is not None and v <= 20]
if table_matches:
claims["tables"] = table_matches[0]
figure_matches = [_coalesce_match(m) for m in FIGURE_COUNT_RE.finditer(text)]
figure_matches = [v for v in figure_matches if v is not None and v <= 20]
if figure_matches:
claims["figures"] = figure_matches[0]
return claims
# ---------------------------------------------------------------------------
# Drift evaluation
# ---------------------------------------------------------------------------
DEFAULT_BODY_TOLERANCE_PCT = 5 # cover letter "approximately" allows ~5% slack
DEFAULT_ABSTRACT_TOLERANCE = 5 # words
def evaluate_drift(
truth: dict,
claims: dict,
*,
body_tolerance_pct: float = DEFAULT_BODY_TOLERANCE_PCT,
abstract_tolerance: int = DEFAULT_ABSTRACT_TOLERANCE,
) -> list[dict]:
"""Compare claims to truth and emit a list of drift records."""
drifts: list[dict] = []
def _record(field: str, truth_val, claim_val, severity: str, note: str = ""):
drifts.append(
{
"field": field,
"truth": truth_val,
"cover_letter_claim": claim_val,
"severity": severity,
"note": note,
}
)
# Body words — tolerate small "approximately" slack.
if "body_words" in claims and "body_words" in truth:
cw = claims["body_words"]
tw = truth["body_words"]
if tw > 0:
slack = max(50, int(tw * body_tolerance_pct / 100))
if abs(cw - tw) > slack:
_record(
"body_words",
tw,
cw,
"MAJOR",
f"|claim - truth| = {abs(cw - tw)} > tolerance {slack}",
)
# Abstract words.
if "abstract_words" in claims and "abstract_words" in truth:
cw = claims["abstract_words"]
tw = truth["abstract_words"]
if abs(cw - tw) > abstract_tolerance:
_record(
"abstract_words",
tw,
cw,
"MAJOR",
f"|claim - truth| = {abs(cw - tw)} > tolerance {abstract_tolerance}",
)
# Reference count — exact match.
if "references" in claims and "references" in truth:
if claims["references"] != truth["references"]:
_record(
"references",
truth["references"],
claims["references"],
"MAJOR",
)
# Tables — exact match.
if "tables" in claims and "tables" in truth:
if claims["tables"] != truth["tables"]:
_record(
"tables",
truth["tables"],
claims["tables"],
"MAJOR",
)
# Figures — exact match.
if "figures" in claims and "figures" in truth:
if claims["figures"] != truth["figures"]:
_record(
"figures",
truth["figures"],
claims["figures"],
"MAJOR",
)
return drifts
# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------
def main() -> int:
p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
p.add_argument("--manuscript", required=True, type=Path)
p.add_argument("--cover-letter", required=True, type=Path)
p.add_argument("--abstract", type=Path, default=None,
help="Optional separate abstract file. If absent, extracted from manuscript.")
p.add_argument("--refs", type=Path, default=None,
help="refs.bib path. Used for reference count truth. "
"If absent, falls back to counting unique [@key] in manuscript.")
p.add_argument("--out", type=Path, default=Path("qc/cover_letter_drift.json"))
p.add_argument("--body-tolerance-pct", type=float, default=DEFAULT_BODY_TOLERANCE_PCT,
help="Allowed slack on body word count (percent). Default %(default)s.")
p.add_argument("--abstract-tolerance", type=int, default=DEFAULT_ABSTRACT_TOLERANCE,
help="Allowed slack on abstract word count (words). Default %(default)s.")
args = p.parse_args()
if not args.manuscript.exists():
print(f"ERROR: manuscript not found: {args.manuscript}", file=sys.stderr)
return 1
if not args.cover_letter.exists():
print(f"ERROR: cover letter not found: {args.cover_letter}", file=sys.stderr)
return 1
truth = {
"body_words": count_body_words(args.manuscript),
"abstract_words": count_abstract_words(args.manuscript, args.abstract),
"tables": count_table_labels(args.manuscript),
"figures": count_figure_labels(args.manuscript),
}
if args.refs is not None and args.refs.exists():
truth["references"] = count_bib_entries(args.refs)
else:
truth["references"] = count_used_citations(args.manuscript)
claims = extract_claims(args.cover_letter)
drifts = evaluate_drift(
truth,
claims,
body_tolerance_pct=args.body_tolerance_pct,
abstract_tolerance=args.abstract_tolerance,
)
report = {
"submission_safe": len(drifts) == 0,
"manuscript": str(args.manuscript),
"cover_letter": str(args.cover_letter),
"truth": truth,
"claims": claims,
"drifts": drifts,
}
args.out.parent.mkdir(parents=True, exist_ok=True)
args.out.write_text(json.dumps(report, indent=2) + "\n", encoding="utf-8")
if drifts:
print(f"DRIFT: {len(drifts)} cover-letter field(s) disagree with manuscript")
for d in drifts:
print(f" - {d['field']}: claim={d['cover_letter_claim']} vs truth={d['truth']}"
+ (f" — {d['note']}" if d.get("note") else ""))
return 2
print(f"OK: cover letter agrees with manuscript ({len(truth)} fields checked)")
return 0
if __name__ == "__main__":
sys.exit(main())
#!/usr/bin/env python3
"""
cross_document_n_check.py — Phase 5 cross-document N consistency gate.
Scans a submission package for cohort-size claims ("N patients", "k studies
included", "n excluded", "M nodules", etc.) across manuscript body, abstract,
PROSPERO record, cover letter, supplementary materials, INDEX, and PRISMA flow
caption. Emits a drift report when the same logical quantity disagrees between
documents.
Why this gate exists
====================
Multi-document N drift is a high-frequency reviewer/editor desk-reject pattern.
When a manuscript ships with k=63 in the abstract but k=64 in the supplementary
extraction sheet, reviewers treat it as either a data-integrity failure or a
late-edit failure. Either reading is fatal at peer review.
Cross-project observations (anonymized):
- Project (LLM reporting-quality SR example): five documents disagreed
INCLUDE=63 vs 64, EXCLUDE=108/109/111. Three EXCLUDE entries existed in the
extraction sheet without matching INCLUDE.
- Project (DTA-MA example): Results prose PRISMA cascade
151+108+39+1+1+4=304 vs prose total "305" — off-by-one in the same paragraph.
- Project (outcome-MA example): TS denominator 331 in prose vs 326 computed
from extraction table; Major complications 434 vs 439.
- Project (intervention-MA example): "1,847 nodules" hallucinated in v3
against Results "881 + 402".
Usage
=====
python cross_document_n_check.py \\
--root path/to/project \\
--out qc/cross_document_n.json
python cross_document_n_check.py \\
--files manuscript.md abstract.md supplementary/s1.md \\
--out qc/cross_document_n.json
Optional pool-lock anchor:
python cross_document_n_check.py \\
--root path/to/project \\
--pool-lock 2_Data/FINAL_POOL_LOCK.yaml \\
--out qc/cross_document_n.json
When --pool-lock is supplied, every N value tied to a "locked" category
(include_count / exclude_count / mixed_count) is asserted to match the lock
exactly. Mismatches are P0 failures.
Output (qc/cross_document_n.json):
{
"submission_safe": false,
"drift_count": 3,
"drifts": [
{
"category": "included",
"values": [63, 64],
"locations": [
{"file": "abstract.md", "line": 4, "value": 63, "context": "..."},
{"file": "supplementary/s1.md", "line": 12, "value": 64, "context": "..."}
],
"severity": "MAJOR"
}
],
"categories_scanned": ["patients", "studies", "included", "excluded", ...],
"files_scanned": ["abstract.md", "manuscript.md", ...]
}
Exit codes:
0 = no drift
1 = drift detected
2 = invocation error (missing files, bad arguments)
This script does not modify source files. It is read-only.
"""
from __future__ import annotations
import argparse
import json
import re
import sys
from dataclasses import asdict, dataclass, field
from pathlib import Path
from typing import Iterable
# --------------------------------------------------------------------------
# Pattern catalog
# --------------------------------------------------------------------------
# Each pattern maps to a normalized category label. The capture group is the
# numeric value (commas removed downstream). We intentionally keep the unit
# noun in the same alternation block so a single regex captures both "studies
# included" and "included studies" variants.
#
# Category keys are stable and downstream consumers (lock files, drift
# reports) reference them by name.
PATTERNS: list[tuple[str, re.Pattern[str]]] = [
(
"included",
re.compile(
r"(?:\b(?:included|including|we\s+included)\s+(\d{1,3}(?:,\d{3})*|\d+)\s+(?:studies|records|reports|articles|trials|papers)\b"
r"|\b(\d{1,3}(?:,\d{3})*|\d+)\s+(?:studies?\s+(?:were\s+)?included|included\s+studies?|"
r"records?\s+(?:were\s+)?included|included\s+records?|"
r"reports?\s+(?:were\s+)?included|included\s+reports?|"
r"articles?\s+(?:were\s+)?included|included\s+articles?)\b)",
re.IGNORECASE,
),
),
(
"excluded",
re.compile(
r"(?:\b(?:excluded|excluding|we\s+excluded)\s+(\d{1,3}(?:,\d{3})*|\d+)\s+(?:studies|records|reports|articles|trials|papers)\b"
r"|\b(\d{1,3}(?:,\d{3})*|\d+)\s+(?:studies?\s+(?:were\s+)?excluded|excluded\s+studies?|"
r"records?\s+(?:were\s+)?excluded|excluded\s+records?|"
r"reports?\s+(?:were\s+)?excluded|excluded\s+reports?|"
r"articles?\s+(?:were\s+)?excluded|excluded\s+articles?)\b)",
re.IGNORECASE,
),
),
(
"patients",
re.compile(
r"\b(\d{1,3}(?:,\d{3})*|\d+)\s+patients?\b",
re.IGNORECASE,
),
),
(
"cases",
re.compile(
r"\b(\d{1,3}(?:,\d{3})*|\d+)\s+cases?\b",
re.IGNORECASE,
),
),
(
"nodules",
re.compile(
r"\b(\d{1,3}(?:,\d{3})*|\d+)\s+nodules?\b",
re.IGNORECASE,
),
),
(
"tumors",
re.compile(
r"\b(\d{1,3}(?:,\d{3})*|\d+)\s+(?:tumou?rs?|lesions?)\b",
re.IGNORECASE,
),
),
(
"studies_total",
re.compile(
r"\b(\d{1,3}(?:,\d{3})*|\d+)\s+studies\b(?!\s+(?:were\s+)?(?:included|excluded))",
re.IGNORECASE,
),
),
]
# File globs to scan when --root is supplied. Order is for output
# determinism only; the algorithm is glob-then-sort.
DEFAULT_GLOBS = (
"manuscript.md",
"manuscript/*.md",
"abstract.md",
"abstract/*.md",
"cover_letter.md",
"*cover_letter*.md",
"prospero/*.md",
"supplementary/*.md",
"supplementary/**/*.md",
"INDEX.md",
"submission/**/manuscript*.md",
"submission/**/abstract*.md",
)
# --------------------------------------------------------------------------
# Data classes
# --------------------------------------------------------------------------
@dataclass
class Hit:
file: str
line: int
value: int
context: str
def as_dict(self) -> dict:
return asdict(self)
@dataclass
class Drift:
category: str
values: list[int]
locations: list[Hit]
severity: str = "MAJOR"
def as_dict(self) -> dict:
return {
"category": self.category,
"values": sorted(self.values),
"locations": [h.as_dict() for h in self.locations],
"severity": self.severity,
}
@dataclass
class Report:
submission_safe: bool
drift_count: int
drifts: list[Drift]
categories_scanned: list[str]
files_scanned: list[str]
lock_violations: list[dict] = field(default_factory=list)
def as_dict(self) -> dict:
return {
"submission_safe": self.submission_safe,
"drift_count": self.drift_count,
"drifts": [d.as_dict() for d in self.drifts],
"categories_scanned": self.categories_scanned,
"files_scanned": self.files_scanned,
"lock_violations": self.lock_violations,
}
# --------------------------------------------------------------------------
# Core
# --------------------------------------------------------------------------
def _to_int(raw: str) -> int:
return int(raw.replace(",", ""))
def scan_file(path: Path) -> list[tuple[str, Hit]]:
"""Return (category, Hit) tuples for every matched N claim in path."""
out: list[tuple[str, Hit]] = []
try:
text = path.read_text(encoding="utf-8")
except (OSError, UnicodeDecodeError):
return out
for lineno, line in enumerate(text.splitlines(), start=1):
for category, pat in PATTERNS:
for m in pat.finditer(line):
# Patterns with alternation may capture into group 1 or 2;
# take whichever group fired.
raw = next((g for g in m.groups() if g is not None), None)
if raw is None:
continue
try:
value = _to_int(raw)
except ValueError:
continue
# Skip implausibly small mentions like "2 patients" inside an
# example table heading. Threshold is intentionally generous —
# this gate cares about full-cohort drift, not in-text examples.
if value < 5:
continue
context = line.strip()
if len(context) > 200:
context = context[:200] + "..."
out.append((category, Hit(str(path), lineno, value, context)))
return out
def collect_files(root: Path, extra_files: Iterable[Path] = ()) -> list[Path]:
seen: set[Path] = set()
files: list[Path] = []
for pattern in DEFAULT_GLOBS:
for hit in sorted(root.glob(pattern)):
if hit.is_file() and hit.suffix.lower() in {".md", ".tex", ".txt"}:
rp = hit.resolve()
if rp not in seen:
seen.add(rp)
files.append(hit)
for f in extra_files:
rp = f.resolve()
if rp not in seen and f.is_file():
seen.add(rp)
files.append(f)
return files
def detect_drifts(hits_by_cat: dict[str, list[Hit]]) -> list[Drift]:
"""For each category, group hits by value. >1 distinct value = DRIFT."""
drifts: list[Drift] = []
for category, hits in hits_by_cat.items():
# group by value
by_value: dict[int, list[Hit]] = {}
for h in hits:
by_value.setdefault(h.value, []).append(h)
if len(by_value) <= 1:
continue
# collapse for report
all_hits = [h for hs in by_value.values() for h in hs]
drifts.append(
Drift(
category=category,
values=list(by_value.keys()),
locations=all_hits,
severity="MAJOR",
)
)
return drifts
def check_pool_lock(
lock_path: Path,
hits_by_cat: dict[str, list[Hit]],
) -> list[dict]:
"""If a pool-lock yaml is supplied, assert each locked count matches."""
try:
import yaml # type: ignore
except ImportError:
return [
{
"violation": "pyyaml-missing",
"detail": "Install PyYAML to enable --pool-lock checks.",
}
]
try:
lock = yaml.safe_load(lock_path.read_text(encoding="utf-8"))
except (OSError, yaml.YAMLError) as exc:
return [{"violation": "lock-read-error", "detail": str(exc)}]
if not isinstance(lock, dict):
return [{"violation": "lock-format", "detail": "lock root must be mapping"}]
violations: list[dict] = []
# Map lock keys to scan categories.
pairs = [
("include_count", "included"),
("exclude_count", "excluded"),
("final_pool_n", "studies_total"),
]
for lock_key, scan_cat in pairs:
if lock_key not in lock:
continue
try:
expected = int(lock[lock_key])
except (TypeError, ValueError):
violations.append(
{
"violation": "lock-non-integer",
"key": lock_key,
"raw": lock[lock_key],
}
)
continue
hits = hits_by_cat.get(scan_cat, [])
for h in hits:
if h.value != expected:
violations.append(
{
"violation": "pool-lock-mismatch",
"lock_key": lock_key,
"expected": expected,
"actual": h.value,
"file": h.file,
"line": h.line,
"context": h.context,
}
)
return violations
def build_report(
files: list[Path],
pool_lock: Path | None = None,
) -> Report:
hits_by_cat: dict[str, list[Hit]] = {}
for path in files:
for cat, hit in scan_file(path):
hits_by_cat.setdefault(cat, []).append(hit)
drifts = detect_drifts(hits_by_cat)
lock_violations: list[dict] = []
if pool_lock is not None:
lock_violations = check_pool_lock(pool_lock, hits_by_cat)
submission_safe = not drifts and not lock_violations
return Report(
submission_safe=submission_safe,
drift_count=len(drifts),
drifts=drifts,
categories_scanned=sorted(hits_by_cat.keys()),
files_scanned=[str(p) for p in files],
lock_violations=lock_violations,
)
# --------------------------------------------------------------------------
# CLI
# --------------------------------------------------------------------------
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(
description=(
"Phase 5 cross-document N consistency gate. Scans manuscript, "
"abstract, PROSPERO record, cover letter, and supplementary "
"materials for cohort-size disagreement."
)
)
parser.add_argument(
"--root",
type=Path,
default=None,
help="Project root. When supplied, scans default glob set.",
)
parser.add_argument(
"--files",
type=Path,
nargs="*",
default=[],
help="Explicit file list (in addition to --root glob results).",
)
parser.add_argument(
"--pool-lock",
type=Path,
default=None,
help=(
"Path to FINAL_POOL_LOCK.yaml. When supplied, asserts every "
"locked count matches in scanned documents."
),
)
parser.add_argument(
"--out",
type=Path,
default=None,
help="Write JSON report to this path (in addition to stdout summary).",
)
parser.add_argument(
"--quiet",
action="store_true",
help="Suppress per-drift stdout summary; rely on --out / exit code.",
)
args = parser.parse_args(argv)
if args.root is None and not args.files:
parser.error("must supply --root or --files")
files: list[Path] = []
if args.root is not None:
if not args.root.is_dir():
parser.error(f"--root not a directory: {args.root}")
files.extend(collect_files(args.root, args.files))
else:
files.extend(p for p in args.files if p.is_file())
if not files:
parser.error("no readable files matched")
report = build_report(files, pool_lock=args.pool_lock)
if args.out is not None:
args.out.parent.mkdir(parents=True, exist_ok=True)
args.out.write_text(json.dumps(report.as_dict(), indent=2), encoding="utf-8")
if not args.quiet:
if report.submission_safe:
print(
f"PASS: scanned {len(files)} files, "
f"{len(report.categories_scanned)} categories, no drift."
)
else:
print(
f"FAIL: {report.drift_count} drift(s), "
f"{len(report.lock_violations)} lock violation(s)."
)
for d in report.drifts:
print(f" - {d.category}: values={sorted(d.values)}")
for h in d.locations:
print(f" {h.file}:{h.line} N={h.value} {h.context[:80]}")
for v in report.lock_violations:
print(f" - LOCK {v}")
return 0 if report.submission_safe else 1
if __name__ == "__main__":
sys.exit(main())
#!/usr/bin/env python3
"""Multi-copy manuscript divergence detector (sync-submission Phase 8).
When a project keeps several hand-maintained manuscript copies — `manuscript.md`
(the working SSOT), `manuscript_circulation.md` (co-author feedback), and
`submission/<journal>/manuscript.md` (portal) — a batch of edits applied to the
SSOT routinely lands in only some of the copies. The portal then receives a stale
copy missing a subset of the edits, and the divergence surfaces (if at all) only
when a reviewer notices an inconsistency.
This detector is directional: it treats one file as the SSOT and reports, for each
copy, the SSOT *claims* (numeric assertions and section headings) that did not
propagate into the copy. A claim present in the SSOT but absent from a copy is an
unpropagated edit; a claim present only in a copy is a copy-side divergence.
INPUTS
--ssot the canonical manuscript file.
--copy a copy to check against the SSOT (repeatable).
OUTPUT (--out path)
{ssot, copies: [{copy, unpropagated_to_copy, copy_only, verdict}], verdict}
STALE_COPY (a copy missing SSOT claims) is the Major finding. Exit 1 (with
--strict) when any copy is stale.
Claims are matched as normalized strings, so wording differences do not register —
only a changed/absent number or heading does. Review the lists; legitimately
copy-specific sections (e.g. a circulation cover note) will show up as `copy_only`
and can be ignored.
Stdlib-only (re / json / argparse). Exit codes: 0 in sync (or report-only),
1 a stale copy (with --strict), 2 input/usage error.
"""
from __future__ import annotations
import argparse
import json
import re
import sys
from pathlib import Path
CLAIM_PATTERNS = [
re.compile(r"\bn\s*=\s*[0-9][0-9,]*", re.I), # n = 1,284
re.compile(r"[0-9]+\.[0-9]+\s*%|\b[0-9]+\s*%"), # 12.5% / 30%
re.compile(r"\bp\s*[=<>]\s*0?\.[0-9]+", re.I), # p = 0.034
re.compile(r"\b(?:a?OR|a?HR|RR|sHR)\s*[=:]?\s*[0-9]+\.[0-9]+", re.I), # OR 1.34
re.compile(r"\b95%\s*CI[^)]*[0-9]\.[0-9]+", re.I), # 95% CI ... 1.02
]
HEADING_RE = re.compile(r"^#{1,4}\s+\**([^\n*]+)", re.M)
def _norm(s: str) -> str:
return re.sub(r"\s+", " ", s.strip().lower()).replace(" ", "")
def claims(text: str) -> set[str]:
out: set[str] = set()
for pat in CLAIM_PATTERNS:
out.update(_norm(m.group(0)) for m in pat.finditer(text))
for m in HEADING_RE.finditer(text):
out.add("h:" + _norm(m.group(1)))
return out
def main() -> int:
ap = argparse.ArgumentParser(description="Multi-copy manuscript divergence detector.")
ap.add_argument("--ssot", required=True, help="canonical manuscript file")
ap.add_argument("--copy", action="append", default=[], help="copy to check (repeatable)")
ap.add_argument("--out", help="write JSON artifact to this path")
ap.add_argument("--strict", action="store_true", help="exit 1 if any copy is stale")
args = ap.parse_args()
sp = Path(args.ssot)
if not sp.is_file():
sys.stderr.write(f"ERROR: SSOT not found: {args.ssot}\n")
return 2
if not args.copy:
sys.stderr.write("ERROR: provide at least one --copy\n")
return 2
ssot_claims = claims(sp.read_text(encoding="utf-8"))
copies = []
n_stale = 0
for c in args.copy:
cp = Path(c)
if not cp.is_file():
sys.stderr.write(f"WARN: copy not found, skipping: {c}\n")
continue
cc = claims(cp.read_text(encoding="utf-8"))
unprop = sorted(ssot_claims - cc)
copy_only = sorted(cc - ssot_claims)
verdict = "STALE_COPY" if unprop else "OK"
if unprop:
n_stale += 1
copies.append({
"copy": str(cp),
"unpropagated_to_copy": unprop,
"copy_only": copy_only,
"verdict": verdict,
})
result = {
"ssot": str(sp),
"copies": copies,
"verdict": "DIVERGENT" if n_stale else "OK",
"suggested_fix": (
"Re-propagate the unpropagated SSOT claims into each stale copy, or "
"generate the copies from the SSOT via a build step instead of hand-maintaining them."
) if n_stale else None,
}
print("=" * 41)
print(" Multi-copy manuscript divergence (Phase 8)")
print("=" * 41)
print(f"SSOT: {sp}")
for c in copies:
mark = "✗" if c["verdict"] == "STALE_COPY" else "✓"
print(f"{mark} {c['copy']}")
if c["unpropagated_to_copy"]:
print(f" unpropagated SSOT claims ({len(c['unpropagated_to_copy'])}): "
f"{c['unpropagated_to_copy'][:6]}")
if n_stale:
print(f"\nDIVERGENT: {n_stale} stale copy(ies). {result['suggested_fix']}")
else:
print("\nOK: every SSOT claim propagated to all copies.")
if args.out:
Path(args.out).parent.mkdir(parents=True, exist_ok=True)
Path(args.out).write_text(json.dumps(result, indent=2), encoding="utf-8")
print(f"wrote {args.out}")
return 1 if (args.strict and n_stale) else 0
if __name__ == "__main__":
sys.exit(main())
schema_version: 2
name: sync-submission
layer: A
owner_domain: submission_packaging
maturity: official
when_to_use:
- Auditing SSOT-to-submission drift before freezing a journal package
- Building a journal-specific submission manifest from canonical manuscript artifacts
- Retargeting an existing submission to a new journal (cascade rejection)
- Refreshing artifact_manifest.json to reflect the current canonical state
when_NOT_to_use:
- Drafting or editing the canonical manuscript (use /write-paper or /revise)
- Choosing the target journal (use /find-journal)
- Freezing a submission while drift is detected (forbidden — fix drift first)
inputs:
- project.yaml
- manuscript/manuscript.md
outputs:
- submission/{journal}/.journal_meta.json
- qc/submission_sync_{journal}.json
- artifact_manifest.json
deterministic_scripts:
- scripts/sync_submission.py
side_effects:
- writes_project_artifacts
downstream_consumers:
- orchestrate
- find-journal
forbidden_actions:
- silently_edit_canonical_manuscript
- freeze_drifted_submission
# v2.1 quality card
purpose: "Audit SSOT-to-submission drift and build journal submission manifests from canonical manuscript artifacts."
safety_boundaries:
- "Never silently edits the canonical manuscript; a drifted submission is not frozen until reconciled."
- "Submission packages are derived from canonical sources, not hand-assembled."
known_limitations:
- "Detects drift it is configured to scan (counts, cover-letter fields, scope); portal free-text fields still need a human check."
- "A clean audit is necessary, not sufficient, for acceptance."
validation_commands:
- "python3 scripts/sync_submission.py"
- "python3 scripts/cross_document_n_check.py"
- "bash tests/test_wordcount_cap.sh"
- "bash tests/test_assemble_supplement.sh"
- "bash tests/test_disclosure_availability.sh"
evidence_surface: bundled_script
Methods
After re-lock the analytic cohort comprised n = 998 participants. Emphysema was associated with mortality (HR 1.34), not significant (p = 0.074); prevalence 12.5%.
Results
The adjusted estimate was OR 2.25 in the exploratory analysis.
Methods
The analytic cohort comprised n = 998 participants. Emphysema was associated with mortality (HR 1.34) but this was not significant. Prevalence was 12.5%.
Results
The adjusted estimate was OR 2.25 in the exploratory analysis.
Methods
The analytic cohort comprised n = 998 participants. Emphysema was associated with mortality (HR 1.34) but this was not significant (p = 0.074). Prevalence was 12.5%.
Results
The adjusted estimate was OR 2.25 in the exploratory analysis.
Supplementary Material — Index
- S1. Methods
- S2. Analyses
- S3. Tables
- S4. Figures
S1. Methods
S1.1 a
x
S1.3 c
x
S2. Analyses (version a)
x
S2. Analyses (version b)
x
S4. Figures
x
S6. Orphan
x
Supplementary Material — Index
- S1. Supplementary Methods
- S2. Supplementary Analyses
- S3. Supplementary Tables
S1. Supplementary Methods
Design details.
S2. Supplementary Analyses
S2.1 Sensitivity
text
S2.2 Subgroup
text
S3. Supplementary Tables
Table content.
Body. See Supplementary Methods S1 and Supplementary Table S2.
Abstract
This abstract sentence contains words that must not be counted toward the body limit because the abstract has its own separate cap.
Introduction
Coronary artery calcium scoring is a widely used marker of subclinical disease, and prior work has linked it to downstream events in screening populations [@smith2020]. We examined whether an additional report-derived finding adds value beyond the calcium score in a cross-sectional screening cohort.
Discussion
The association we observed was modest and its confidence interval excluded a clinically meaningful incremental effect. We interpret the result as a precision statement rather than an absence of any effect, and we situate it against prior cohort evidence [@doe2019].
References
1. Smith J. A long reference list entry whose many words must be excluded from the body word count entirely, along with all the other entries below it. 2. Doe A. Another reference entry with still more words that should not inflate the measured body length in any way whatsoever.
Related skills
FAQ
What does the pre-flight gate halt on?
By default only unambiguous deterministic P0 errors: leftover placeholders, undefined citations, duplicate references, and a canonical-vs-submission hash mismatch; heuristic checks warn but do not halt unless promoted.
Does it handle double-blind journals?
Yes. blind_sweep.py sweeps author identifiers from a project-local registry across all upload artifacts before submission.