# Comment Disposition Build

> Step 1 of the engineering-document review comment-disposition cycle: turn a checker/reviewer marked-up PDF into a structured IDC comment disposition matrix the originator can work through. Handles both typed markup (Bluebeam/ Acrobat annotations) and handwritten ink on scanned check-prints, merging them into one register, and separates the originator's responses from the reviewer's comments so replies are never logged as new comments. Use when the user asks to "build the comment disposition form", "create the IDC disposition form", "extract comments from this markup PDF", "turn this markup into a disposition matrix", "log the checker's comments into a register", or "transcribe the handwritten review comments". Do NOT use to verify incorporation or backcheck a revised document — use comment-backcheck instead; and do NOT author, technically review, or approve the document.

- Skill: `dgusoff/comment-disposition-build` (Agent Skill, multi-file: 2 files)
- Install (CLI): `npx skillmds@latest add dgusoff/comment-disposition-build`
- Raw SKILL.md: https://api.skillmd.com/api/skills/dgusoff/comment-disposition-build/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Docs & Writing
- Author: dgusoff (https://skillmd.com/u/dgusoff)
- Updated: 2026-09-22
- Page: https://skillmd.com/skills/dgusoff/comment-disposition-build

---


# Comment Disposition — Build (Step 1)

## Overview
Takes a checker/reviewer markup PDF and produces a structured comment disposition
matrix (the Bechtel IDC comment disposition form). The value is **quality and
traceability, not time savings** — a complete, auditable record that every review
comment was captured for the originator to disposition. This skill is **read-only
over the documents**: it logs what the reviewer marked and never edits, resolves,
or approves anything.

## When to Use
- "Build / create the comment disposition form" or "the IDC disposition form"
- "Extract the comments from this markup PDF" / "turn this markup into a matrix"
- "Log the checker's comments into a register"
- "Transcribe the handwritten comments off this check-print"
- "Consolidate these reviewer markups into one register"
- A marked-up review PDF exists and its comments need to be captured before the
  originator starts incorporating them.

## When NOT to Use
- **Verifying incorporation / backcheck** a revised document → use **comment-backcheck**.
- **Authoring or technically reviewing** the document → that is the engineer's job.
- **Judging technical correctness** of a comment, or **approving/signing** the
  deliverable → always a human.
- **One-off document Q&A** with no review/comment cycle → use general document tools.
- **Rendering the file** (Word/Excel mechanics) → the **docx** / **xlsx** skills own
  the file format; this skill owns the comment logic.

## Markup Legend (read this first)
If the PDF carries its own legend/key, use it verbatim. Otherwise apply the default
Bechtel conventions:

| Mark | Meaning | Action |
|------|---------|--------|
| Yellow highlight | Reviewed, no comment | **Ignore as noise** — do not log |
| Green | Delete | Log as a delete request |
| Red / pink | Add or modify | Log as an add/modify comment |
| Blue | **Not a legend colour** — on a two-party markup this is normally the **originator's response** | Put in the **Disposition** column, **never** the Comment column. Confirm by author first |
| Handwritten note | Comment / question | Log verbatim |
| Strikeout / cloud / inserted text | Requested change | Log as a comment — never perform it |

A strikeout or "delete" mark is a **reviewer's request logged as a comment**, never an
edit this skill makes.

**Colour never outranks authorship.** The legend is a fallback for when you have nothing
better. Whenever a typed annotation layer exists you have something better — the `/T`
author on every annotation — and it is decisive. Use colour only for handwritten ink,
where no author metadata exists.

## Two-Party Markups: Comments vs. Responses (read before numbering anything)

**The same PDF usually carries BOTH the reviewer's comments and the originator's replies.**
An IDC package goes out, the originator answers in the same file, and it comes back. Half
the marks are therefore *not comments at all* — they are answers to the other half.

Getting this wrong is the most expensive failure this skill can produce short of inventing
text: a register that logs the responses as new comments **roughly doubles** in size and
sends the originator back to answer their own replies.

`extract_markup.py` detects this automatically and reports it in `manifest.authorship`:

```json
"authorship": {
  "authors": {"chliu":  {"marks": 11, "dominant_color": "red"},
              "rhuribe": {"marks": 11, "dominant_color": "blue"}},
  "two_party_review": true,
  "commenter": "chliu", "responder": "rhuribe",
  "warning": "'rhuribe' marks are ORIGINATOR RESPONSES, not review comments..."
}
```

How it decides, in priority order:

1. **Author** (`/T`) — marks are grouped by who wrote them.
2. **On-page colour** — sampled from the *rendered pixels* inside each annotation rect, not
   from `/C`. Bluebeam and Acrobat routinely leave `/C` null or set it to the popup
   background while the visible text is a different colour, so `/C` alone will mislead you.
3. **Timestamp** (`/M`) — the responder writes *after* the commenter. Compared only across
   the pages the two authors share, since imported drawing title blocks carry old dates.

When `two_party_review` is true:
- Log **only** the commenter's marks as numbered comments.
- Put the responder's text in the **Disposition / Dispositioned by** column of the comment
  it answers, attributed (`"R. Uribe: Incorporated."`).
- If a comment has **no** matching response, say so explicitly in that row rather than
  letting an adjacent response appear to cover it. Two comments sharing one response box on
  a page is common and ambiguous — flag it, do not silently pair them.
- If the user asked for a clean Step 1 form, keep Disposition blank and deliver the
  responses as a separate reference sheet — but **never discard them**.

**Three or more authors is normal** — a drawing-label author or a title-block stamp does not
mean the review is not two-party. Detection ignores CAD/title-block authors and colourless
marks when pairing.

## Reconcile Before Delivering (coverage ledger)

**Every mark must be accounted for.** `manifest.coverage` gives you the denominator:

```json
"coverage": {"total_marks": 69, "typed_with_text": 39, "ink_marks": 30}
```

Before delivering, reconcile that total. Each mark must end up in exactly one bucket:

| Bucket | What it means |
|---|---|
| **Numbered comment** | In the register with its own number |
| **Merged** | Folded into another row — say which, and why |
| **Response** | In the Disposition column of the comment it answers |
| **Non-comment** | Recorded with the *reason* — "printed logo", "native CAD linework", "page furniture" |
| **Flagged** | Unreadable — page, colour, position and crop filename given |

State the reconciliation in your response ("39 typed + 30 ink = 69 marks: 10 comments,
9 responses, 45 native page/drawing content, 5 merged"). A mark that appears in no bucket is
a dropped review comment — the exact failure this skill exists to prevent.

**Resolve "unidentified" marks instead of shipping them as unknowns.** A colour hit with no
annotation behind it is almost always page furniture, and you can usually prove it in one
pass: compare the ink mask against the annotation rects, check whether the region repeats at
the same position on many pages (logo, footer band, revision stamp), and check whether the
page has any annotations at all. Cite the evidence in the row. "Needs manual read" is honest
but it is the *last* resort, not the first.

## Quick Start
```
User: "Build the disposition form from Vince's markup"
1. Read the legend (or apply Bechtel defaults); confirm which colors are noise.
2. python scripts/extract_markup.py REVIEW.pdf --outdir working/markup
3. python scripts/transcribe_marks.py --markup-dir working/markup --review-csv ...
4. Typed verbatim + transcribed ink; unclear marks FLAGGED WITH THEIR PAGE. Merge by page.
5. COPY references/Comment Disposition Step 1.docx and fill its cells in place.
6. Verify the render, then deliver to output/ — Disposition column left BLANK.
```

## Build ON the Template File (never recreate it)

**The deliverable is the template with its cells filled in — not a lookalike built from
scratch.** `references/Comment Disposition Step 1.docx` carries the Bechtel logo, the
"Coordination Sheet – Comment Disposition Form / NATRIUM™ DEMONSTRATION PROJECT" header,
the DOE/Bechtel confidentiality footer, the table styles, fonts, column widths, and the
ledger/landscape page setup. Rebuilding that layout with `insert_table` + `set_table_style`
loses the logo and the branding every time, and the user will send it back.

Mechanics:
1. `cp "/mnt/user-config/skills/comment-disposition-build/references/Comment Disposition Step 1.docx" working/<name>.docx`
   — copy to a **fresh path**; the artifact store caches by path, so reusing a path you
   already built at returns the stale model.
2. `GetArtifactModel(surface="working", path=...)` to confirm the live structure. As shipped:
   **table 0** = masthead info block (4 rows × 6 cols), **table 1** = comment table
   (74 rows × 5 cols, row 0 = header). Read it — never assume these indices.
3. Fill cells with `set_table_cell`. Headers/footers are separate parts and are preserved
   automatically — do not touch them.
4. Delete surplus comment rows **bottom-up** (`delete_table_row` from the last row back), so
   earlier indices stay valid.
5. Batch large edits into 2-3 `EditArtifact` calls; one 200-op batch is unwieldy.
6. Verify by converting to PDF and viewing the pages (`soffice.py --convert-to pdf` +
   `pdftoppm`) before publishing — confirm the logo, header, footer, and masthead landed.
7. Publish with `CopyArtifact(surface="output", ..., overwrite=true)`.

**The template ships with example content** from the original Raw Water review: sample
comment rows, two reviewer-note prompt lines, and a "CHANGES THAT WERE MADE TO OTHER
DOCUMENTS AFTER SPEC CHANGE" block (MR / Datasheet / TFOP). Replace the masthead and the
comment rows. For the surrounding example sections, **ask the user** whether to keep them,
blank them to empty headings, or drop them — do not silently delete structure they expect.
Reviewer names baked into the sample text (e.g. "Confirm Vince agrees…") become the generic
"reviewer" unless this review's reviewer is actually known.

## Extract the Markup — Typed AND Handwritten

Review markup arrives in two forms and **a single package usually contains both**:

| Form | What it is | How it is read |
|------|-----------|----------------|
| **Typed** | A real PDF annotation layer — Bluebeam/Acrobat sticky notes, text boxes, callouts, highlights, strikeouts | Extracted losslessly from `/Annots`. Exact text, author, page. **No OCR, no guessing.** |
| **Handwritten** | Colored ink on a scanned page image | Located by color, cropped per mark, transcribed by a vision model |

**Never assume which you have.** Run the bundled extractor first — it does both and tells you
what it found:

```bash
python scripts/extract_markup.py REVIEW.pdf --outdir working/markup
```

It writes `working/markup/manifest.json` (every mark), `crops/` (one enlarged image per ink
mark, named `PAGE_IDX_COLOR.jpg`), `pages/` (full renders for context), and `ocr/` (printed
text per page). Read `summary.guidance`, then work whichever streams are non-empty:

- **Typed annotations found** → use `text` **verbatim** as the comment. It is exact — never
  paraphrase it and never mark it `[verify]`. Carry `author` into Commenter and `page` into
  Section/Reference. **Check `summary.two_party_review` first** — if true, the responder's
  marks are answers, not comments (see **Two-Party Markups** above).
- **Ink marks found** → transcribe the crops (next section).
- **Both** → build one merged register. The same objection often appears twice (a typed
  callout *and* a circled value); merge those into one numbered row and note the merge.
  Sort the final register by page so it reads in document order.
- **Neither** → stop and tell the user; do not invent a register. The markup may be in a
  different file, a flattened layer, or black ink the color filter cannot see (see Limits).

Useful flags: `--annots-only` (skip rendering when you only want the typed layer), `--dpi`
(default 200), `--sat-threshold` (lower to catch faint pencil, raise to cut scanner noise),
`--no-ocr`.

## Handwriting: Prove You Can See Before You Transcribe

**STOP. Run this check before writing a single handwritten comment.** In many Cowork
sessions the `view` tool does **not** deliver image pixels — it returns only the string
`"Viewed image file successfully."` for both the main agent and every subagent. An agent
with no pixels that keeps "transcribing" is pattern-matching OCR noise against domain
knowledge and emitting confident fiction into an engineering record. This has actually
happened on this skill.

**Blind self-test (do this every run):**
```bash
python3 -c "import random,string; from PIL import Image,ImageDraw; \
code='-'.join(''.join(random.choice(string.ascii_uppercase) for _ in range(4)) for _ in range(2)); \
im=Image.new('RGB',(900,200),'white'); ImageDraw.Draw(im).text((40,80),'SELFTEST '+code,fill='black'); \
im.save('working/selftest.png'); open('working/selftest_answer.txt','w').write(code)"
```
Then `view` `working/selftest.png` and state the code **before** reading
`selftest_answer.txt`. Match = you have vision. No match = **you are blind; take the
no-vision path.** Never skip this because a previous run worked.

### If you DO have vision
Work from the **crops**, not whole pages. For each: verbatim `transcription` (`[?]` for a
single unreadable word), `mark_type` (DELETE_REQUEST / ADD_MODIFY / COMMENT_QUESTION /
SYMBOL_ONLY / ILLEGIBLE / NOISE), `confidence`, and `refers_to`. Keep the document's
title/number/revision, the legend, and a **domain glossary** (equipment tags, acronyms,
units, section numbers) in front of you — context is what separates reading from guessing.
**Do not delegate the reading to a subagent** unless that subagent passes the same blind
self-test; subagents are routinely pixel-blind even when the parent is not.

### Machine transcription — read what you can, flag the rest by page

Run the bundled transcriber. It reads handwriting far better than a raw page OCR, and —
critically — it **grades its own output** so nothing unclear is passed off as understood:

```bash
python scripts/transcribe_marks.py --markup-dir working/markup \
       --review-csv output/needs_manual_review.csv
```

How it gets robustness: it isolates the reviewer's coloured ink from the printed page
(ink → black on white), upscales and denoises, OCRs each mark under **five** tesseract
page-segmentation modes, and keeps the best candidate by **real-word ratio** (plain English
plus an engineering glossary) and **cross-mode agreement**. Isolating the ink first is what
makes it work — raw page OCR drowns handwriting in the printed text around it.

It then assigns confidence and routes accordingly:

| Confidence | Meaning | What goes in the form |
|---|---|---|
| **HIGH** | ≥5 real words, modes agree | Transcription, marked *OCR-derived — spot-check* |
| **MEDIUM** | ≥4 real words | Transcription, marked *OCR-derived — verify wording* |
| **LOW** | fragments only | Fragment + **flagged for manual review, with page** |
| **NONE** | nothing legible | **Flagged for manual review, with page** |

LOW and NONE get `needs_manual_review: true` and a ready-to-paste `review_note`:

> *“Handwritten blue mark on **PAGE 20** (comment / question) — wording not machine-readable.
> Read crop `20_04_blue.jpg` and transcribe manually.”*

That is the behaviour to preserve: **robust where the ink is legible, and an explicit,
page-located flag where it is not.** Never leave a mark silently unlogged, and never fill a
gap with plausible engineering prose.

Extend the glossary for the discipline at hand (`--glossary terms.txt`, one term per line) —
it directly raises the real-word ratio and therefore how much is auto-transcribed. Loosen
`--sat-threshold` for faint ink.

**A green highlight over printed text is a bonus:** the ink mask captures the *highlighted
printed words*, so the transcriber often recovers the exact sentence the reviewer wants
deleted. Log that text as the delete request.

### Always do these, regardless of confidence
1. **Typed annotations** are exact — use verbatim, never flag them for verification.
2. **Record what is objectively true for every mark**, even unreadable ones: page, position,
   ink colour → intent, the printed text it sits on (from `ocr/page-NN.txt`), and the crop
   filename. A located, classified mark is a real comment; *“notes on the calc — illegible”*
   is not.
3. **Embed the mark image in the row** whenever the handwriting is not machine-readable —
   see **Embed the Mark, Don't Guess At It** below. Also publish the crops folder to
   `output/` so the full-resolution originals are available.
4. **Hand over the review list.** `--review-csv` writes exactly which pages need a human
   pass; deliver it and say so in chat and in the form. Never let that be discovered later.

### Embed the Mark, Don't Guess At It

**When transcription confidence is LOW or NONE, put the cropped image of the mark into the
Comment cell instead of words.** A human reads cursive in a second; OCR of handwriting is
never trustworthy. Embedding converts the hardest problem this skill has into a non-problem
and drops fabrication risk to zero — there is no interpretation layer left to be wrong.

**Only where it is needed.** HIGH/MEDIUM transcriptions and all typed annotations stay as
**text**, so the register remains searchable and Step 2 has something to match against.
Embedding everything would trade that away for no gain.

Mechanics (the comment column in the template is ~4″ wide):

**One image per ROW, never one per mark.** The ink detector splits a single margin note into
several blobs — one comment can be 5 or 9 separate crops. Embedding them individually gives a
cell full of pixelated scraps and tells the reader nothing. Union the row's marks into ONE
region instead.

**Span the FULL PAGE WIDTH — edge to edge.** This is the failure that keeps coming back: crops
that show only part of the marked-up comment. Review marks live in the **margins**, not inside
the printed text column, so any horizontal inset (the old `X0, X1 = 90, W - 90`) slices off the
very marginalia you are trying to show. Crop the full page width less a hair of bleed — **never**
the ink bbox and **never** a text-column inset. A full-width strip is the only crop that reliably
holds both the margin note and the printed line it points at.

**Give it real vertical room.** Pad above and below the **union** of the row's marks by
`max(220 px, 6% of page height)`, clamped to the page. Then enforce a **minimum** strip height so
a one-line mark still gets a readable band, and a **maximum** so one row cannot eat half a page —
but the cap trims **padding only**, never the mark union. **Mark integrity beats every cap:** if
the union itself is taller than the cap, keep the whole union and exceed it.

**Drop the old "shrink to square" branch.** `if (Y1-Y0)/(X1-X0) > 1.15: recenter on the midpoint`
re-centred the band and cut the top and bottom off tall marks. A full-width strip is inherently
wide and short; that branch is wrong here, and the min/max height rules above replace it.

**Never upscale — but never over-shrink either.** Crop a larger region from the ORIGINAL page
render and downscale only. Full page width at 200 DPI is ~1700 px; displayed at 3.7″ that is
~460 effective DPI — more than needed. Downscale toward **1100 px** (~297 DPI at 3.7″: crisp
cursive, ~42% of the pixel area) and never below **600 px** (~162 DPI — the legibility floor).
Below ~150 effective DPI handwriting turns to mush and the whole point of embedding is lost.

Use this block **verbatim every run** — the constants are the whole point, so the geometry is
identical each time and never has to be re-derived. All lengths are in PAGE-RENDER pixels
(`working/markup/pages/` at 200 DPI → a US-Letter page is ~1700 × 2200 px).

```python
RENDER_DPI          = 200      # must match extract_markup.py --dpi
PAGE_MARGIN_PX      = 12       # hair of bleed off the paper edge (~= full width)
VPAD_FRAC           = 0.06     # vertical room as a fraction of page height
VPAD_FLOOR_PX       = 220      # ...but never less than this above/below the ink union
MIN_STRIP_H_PX      = 300      # a 1-line mark still gets a readable band
MAX_STRIP_H_PX      = 900      # one row never eats more than ~half a page
TARGET_MAX_W_PX     = 1100     # downscale target -> ~297 DPI at 3.7in
LEGIBILITY_MIN_W_PX = 600      # >=150 DPI at 3.7in; below this cursive is mush
DISPLAY_W_IN        = 3.7      # ~4in comment column, minus a little padding
MARK_SLACK_PX       = 40       # required clear space around every mark (verification)
JPEG_QUALITY        = 80

def crop_row_strip(page, marks_in_row):
    """Full-width, generously padded strip around the UNION of a row's marks.
    Never clips a mark; downscales only."""
    W, H = page.size
    mx0 = min(m["bbox_px"][0] for m in marks_in_row)
    my0 = min(m["bbox_px"][1] for m in marks_in_row)
    mx1 = max(m["bbox_px"][2] for m in marks_in_row)
    my1 = max(m["bbox_px"][3] for m in marks_in_row)

    # HORIZONTAL - full page width; never inside a mark that hugs the paper edge
    X0 = max(0, min(PAGE_MARGIN_PX, mx0))
    X1 = min(W, max(W - PAGE_MARGIN_PX, mx1))

    # VERTICAL - fraction of page height, with a generous floor
    vpad = max(VPAD_FLOOR_PX, int(VPAD_FRAC * H))
    Y0, Y1 = max(0, my0 - vpad), min(H, my1 + vpad)

    # GROW a too-thin band into free space; CAP a too-tall one by trimming PADDING ONLY
    if (Y1 - Y0) < MIN_STRIP_H_PX:
        Y0 = max(0, Y0 - (MIN_STRIP_H_PX - (Y1 - Y0)) // 2)
        Y1 = min(H, Y0 + MIN_STRIP_H_PX)
        Y0 = max(0, Y1 - MIN_STRIP_H_PX)
    if (Y1 - Y0) > MAX_STRIP_H_PX:
        over = (Y1 - Y0) - MAX_STRIP_H_PX
        t = min(over, max(0, (my0 - MARK_SLACK_PX) - Y0)); Y0 += t; over -= t
        b = min(over, max(0, Y1 - (my1 + MARK_SLACK_PX))); Y1 -= b
        # the cap NEVER trims inside MARK_SLACK_PX of the ink - integrity beats the cap

    im = page.crop((X0, Y0, X1, Y1))
    w0, h0 = im.size
    if w0 > TARGET_MAX_W_PX:                             # DOWNSCALE ONLY
        im = im.resize((TARGET_MAX_W_PX, round(h0 * TARGET_MAX_W_PX / w0)), Image.LANCZOS)
    return im, (X0, Y0, X1, Y1), (W, H)

def verify_strip(marks_in_row, rect, fin_size, page_size, dest):
    """Vision-free geometric proof that nothing was clipped."""
    X0, Y0, X1, Y1 = rect; W, H = page_size; fin_w, fin_h = fin_size
    assert X0 <= PAGE_MARGIN_PX and X1 >= W - PAGE_MARGIN_PX, "strip is not full width"
    for m in marks_in_row:
        bx0, by0, bx1, by1 = m["bbox_px"]
        assert X0 <= bx0 and bx1 <= X1 and Y0 <= by0 and by1 <= Y1, "mark CLIPPED"
        assert (bx0 - X0) >= MARK_SLACK_PX or X0 <= PAGE_MARGIN_PX, "left slack too tight"
        assert (X1 - bx1) >= MARK_SLACK_PX or X1 >= W - PAGE_MARGIN_PX, "right slack too tight"
        assert (by0 - Y0) >= MARK_SLACK_PX or Y0 == 0, "top slack too tight"
        assert (Y1 - by1) >= MARK_SLACK_PX or Y1 == H, "bottom slack too tight"
    assert fin_w <= (X1 - X0), "image was UPSCALED"
    assert fin_w >= LEGIBILITY_MIN_W_PX or fin_w == (X1 - X0), "below legibility floor"
    assert (fin_w / DISPLAY_W_IN) >= 150, "effective DPI < 150 at display size"
    disp_h_in = DISPLAY_W_IN * fin_h / fin_w
    union_h = max(m["bbox_px"][3] for m in marks_in_row) - min(m["bbox_px"][1] for m in marks_in_row)
    forced = (Y1 - Y0) <= union_h + 2 * MARK_SLACK_PX + 2   # union itself exceeds the cap
    assert disp_h_in <= 2.2 or forced, f"row too tall ({disp_h_in:.2f} in)"
    assert os.path.getsize(dest) > 8_000, "crop file suspiciously small"
    return disp_h_in

def embed_mark(cell, marks_in_row, pg, dest, MD):
    page = Image.open(f"{MD}/pages/p-{pg:02d}.jpg")      # ORIGINAL render, not the crop
    im, rect, page_size = crop_row_strip(page, marks_in_row)
    im.save(dest, quality=JPEG_QUALITY, optimize=True)
    cell.add_paragraph().add_run().add_picture(dest, width=Inches(DISPLAY_W_IN))
    return verify_strip(marks_in_row, rect, im.size, page_size, dest)
```

**Three rules the first real run proved out.** (a) The height cap must stop `MARK_SLACK_PX`
short of the ink, never flush to it — trimming to the mark edge is clipping by another name.
(b) Slack is *waived* at an edge already at its maximum extent (`X0 <= PAGE_MARGIN_PX`), or a
mark touching the paper edge fails a check it can never pass. (c) When the mark union alone
exceeds the height cap, the row is allowed to exceed 2.2″ — mark integrity beats the cap, so
`verify_strip` tests for that case instead of failing it.

Place every strip at a fixed `Inches(3.7)` — the old `3.4 / 3.7` toggle is gone, because
full-width strips are always wide and short. Displayed height follows the aspect ratio:
`3.7 × h / w` → ~0.66″ for a one-line strip, ~1.0″ typical, ~2.0″ at the height cap. That is
~8-9 image rows per Word page, so a 60-row form lands around 10-14 pages, not 37.

**The tight `pad = 30` crops in `crops/` stay tight — do not widen them.** They feed OCR, and
isolating the ink from the surrounding printed text is exactly what makes the transcriber work.
Widening them would drag printed text back into the OCR input and lower confidence. Two artifacts,
two purposes: `crops/` is for the machine, the full-width strip is for the human.

Rules for this mode:
- Keep the row's **location text alongside the image** — intent, page, section, printed
  context. The image supplies the wording; the text supplies everything else, and a row that
  is only a picture is not searchable at all.
- Put a prompt in **Basis of Comment** — *"Reviewer's handwriting shown above — transcribe
  when read."* The originator is the right person to type it, at the moment they read it.
- **Verify the images actually landed**: count successful `add_picture` calls and compare to
  the number of rows that needed one. A silent path failure yields a form with empty cells,
  and the file size gives it away (a 63-row form with ~100 images is ~1.7 MB, not 40 KB).
- Say plainly in chat that the handwriting is shown as images for the originator to
  transcribe, and that the form is a **working document** — a signed IDC return normally
  still wants typed text.

**Never smooth a guess into confident prose.** An invented comment reaches an engineer as
fact and is the worst possible failure of this skill — far worse than an honest gap.

### Other limits to disclose
The color filter finds *colored* ink: **black or pencil handwriting on a B&W scan will be
missed**, as will typed markup flattened into the page image. Pre-printed page furniture can
be colored and is **not** a comment — the cover-page logo and pink DOE/confidentiality footer
both trip the filter; drop them as NOISE (a footer band repeating at the same y-position on
many pages is the tell). Report per-page mark counts so coverage is auditable.

## Core Instructions (Step 1 — Build)
1. **Read the legend.** Use the document's own key if present; else the Bechtel
   defaults above. Ignore yellow/"reviewed" marks as noise.
2. **Extract every mark** with `scripts/extract_markup.py` — it captures the typed
   annotation layer AND crops the handwritten ink in one pass (see **Extract the Markup**
   above). Transcribe the ink crops with a strong vision model. Each distinct mark is one
   candidate comment; when two marks describe one change — including the same objection
   raised once typed and once in ink — merge them into one row and note the merge.
3. **Locate each comment** by page and, where present, the document section/reference
   the mark sits on.
4. **Transcribe faithfully** — the reviewer's words as-is; never paraphrase away meaning.
   Typed annotation text is exact: use it verbatim and never flag it for verification.
   For handwriting, keep every word you can read and bracket only the part you cannot
   (`[?]` / `[Partially legible — verify]`) rather than discarding the whole note or
   guessing at it. If a basis is not given, leave it blank.
5. **Consolidate (multi-package reviews).** When several markups come back (checker +
   discipline reviewers, common in IDC), merge them into ONE register: de-duplicate
   overlapping comments and tag each with its **source/discipline**. Keep one stable
   number per comment.
6. **Populate the form** (Output Contract below). The **Disposition column stays blank**
   in Step 1 for the originator to respond to.
7. **Bias conservative.** When unsure whether a mark is a comment, **include it and
   flag it** for human confirmation — never silently drop a mark.
8. **Fill in the template**, per **Build ON the Template File** above — copy
   `references/Comment Disposition Step 1.docx`, populate its cells, verify the render,
   and publish to `output/`. Never rebuild the layout from scratch.

## Output Contract — IDC Comment Disposition Form
Fill in the Bechtel IDC form template at `references/Comment Disposition Step 1.docx` —
copy the file and populate its cells (see **Build ON the Template File** above).

**Header block:** IDC Number · Document Title · Document Number · Revision · Return To ·
Originating Discipline · Commenter/Reviewer · Commenting Discipline · Comment Return
Date · Comments Dispositioned Date.

**Comment table (one row per comment):**

| No. | Section / Reference | Comment | Basis of Comment | Disposition / Dispositioned by |
|-----|---------------------|---------|------------------|--------------------------------|

- **Disposition / Dispositioned by** is **blank in Step 1**.
- For a review that does not require the formal IDC form, produce the same table as a
  lighter register — the columns are unchanged.

## Shared Contract (hand-off to Step 2)
Comment numbering is the spine of traceability: **assign one stable number per comment
and never renumber.** comment-backcheck consumes this exact register — same header,
same numbering — and fills the Disposition column. Preserve the numbering and the
five-column structure so the two passes map 1:1.

## Guardrails
- **Read-only over documents.** Never edit, resolve, or approve the source; a
  strikeout/"delete" mark is logged as a comment, never performed.
- **Never fabricate.** Do not invent comments, section numbers, or bases. Transcribe
  what is present; mark gaps `[ILLEGIBLE]` or leave the basis blank.
- **Conservative capture.** When unsure a mark is a comment, include and flag it.
- **Never log a response as a comment.** Check `manifest.authorship.two_party_review` before
  numbering. The originator's own replies go in the Disposition column — logging them as new
  comments doubles the register and is a failed deliverable.
- **Authorship beats colour.** Where a typed annotation layer exists, `/T` decides who said
  it; colour is a fallback for handwritten ink only. Blue is not in the Bechtel legend.
- **Reconcile the count.** Every mark in `manifest.coverage.total_marks` must be a numbered
  comment, a merge, a response, an evidenced non-comment, or a flagged unreadable. State the
  reconciliation when you deliver.
- **Stable numbering.** One number per comment, carried forward to backcheck.
- **Human gate.** Technical correctness and approval always stay with the
  engineer/reviewer; this skill prepares the record, a person decides.
- **Template fidelity.** Deliver the reference template with its cells filled — never a
  rebuilt lookalike. Losing the logo, header, or footer is a failed deliverable.
- **Flag, don't smooth.** Partially legible handwriting is transcribed as far as it reads
  and marked `[Partially legible — verify]`; never render a guess as confident prose.
- **Unreadable handwriting is embedded, not described.** At LOW/NONE confidence the crop
  image goes in the Comment cell with its location text — a row that says only "illegible"
  wastes the one thing that would have made it useful.
- **Embedded images are one-per-row, FULL-PAGE-WIDTH, and never upscaled.** A cell holding five
  enlarged ink fragments is worse than no image, and a strip cropped to the text column clips the
  margin note itself. Union the row's marks, span the full page width, pad generously above and
  below, downscale only — and never below ~150 effective DPI at display size.
- **Prove no crop was clipped, geometrically.** `view` returns no pixels here, so run
  `verify_strip()` on every embedded image: full page width, every mark inside the rect with
  >=40 px of slack on all four sides (unless that edge is the paper edge), no upscaling, displayed
  height <=2.2″, embedded count == needed count, and a docx well over 300 KB. A clipped mark that
  ships is a review comment the originator cannot read.
- **Both feedback streams, every time.** Check for typed annotations AND handwritten ink;
  reporting one when the package holds both silently drops half the review.
- **Vague is a defect.** "Notes on section X — illegible" is not a captured comment. Every
  handwritten row must at minimum carry page, position, ink-colour intent, the printed text
  the mark sits on, and its crop filename.
- **Unclear → flag with the page, never guess.** Anything below MEDIUM confidence is logged
  as needing manual review and names the page and crop so a human can read it directly. An
  honest flag is a good outcome; invented wording is not.
- **Prove vision before transcribing.** Run the blind self-test every run. If `view` returns
  no pixels, you are blind: locate and classify, cite OCR only where it actually returned
  text, and never let domain knowledge fill in cursive you cannot read. Confident fiction in
  an engineering record is the worst failure this skill can produce.
- **Disclose the gaps.** Say plainly when handwriting was not machine-transcribed, and that
  black/pencil ink on a B&W scan may not be detected at all.
- **Identify the right document.** The template is an example from a prior review — the
  document under review comes from the markup itself. Pull the title, number, revision,
  and originator off the marked-up pages, not off the template. Read the title block on
  **several** pages: appendices are often authored by a different engineer than the main
  body, so the originator can legitimately differ page to page. Capture whoever owns the
  pages the comments actually land on, and say so if they differ.

