Skip to content

feat(cli): read a page body back, and append to one without destroying it - #36

Merged
marcoarnulfo merged 5 commits into
marcoarnulfo:mainfrom
harasuke:feat/page-content
Aug 6, 2026
Merged

marcoarnulfo merged 5 commits into
marcoarnulfo:mainfrom
harasuke:feat/page-content

Conversation

@harasuke

@harasuke harasuke commented Aug 4, 2026 •

Copy link
Copy Markdown
Contributor

🇬🇧 English

Summary

The tool could write a page body but never read one. Combined with --body-file's replace semantics — every run makes the body equal to the file, no undo, no lock — the only way to see what a run was about to destroy was to open the page in a browser. That also left SKILL.md's own "read before you write" rule stated but inapplicable to the body: there was nothing to read it with.

This adds both halves, on Notion's native Markdown API:

notion-track get --ticket TASK-231 --body        # read the body back
notion-track get --ticket TASK-231 --body-only   # body alone, so > notes.md is valid
notion-track set --ticket TASK-231 --append-file note.md   # add to the end, delete nothing

Reading is one call to GET /v1/pages/{id}/markdown — no recursion into child blocks, and block types this tool cannot itself build (tables, callouts, toggles) still read back correctly. Appending uses insert_content, which /reference marks legacy while the guide documents as current; that was settled with a real call before any code was written (200, no deprecation headers, existing content preserved) — see §10 of the design doc.

Everything is additive: --body-file keeps its code, its tests and its sub-page skip untouched, and get --json without --body keeps the exact flat shape existing scripts parse, pinned by a test.

Three API behaviours the obvious implementation gets wrong

  • An append is not idempotent, so it goes through doRejectRetryable: a 500 returns ErrAmbiguousWrite with no retry, because retrying blindly duplicates content on a real page. A 429 is a refusal and is retried.
  • Empty content returns 200 and silently does nothing, so the empty-file check is the only thing between a user and a successful-looking no-op. It runs again after --expand, since a file holding just {{ticket}} is non-empty on disk and can still expand to nothing.
  • A failed append reports appended:false rather than borrowing the replace path's counters, which would claim "0 blocks written" about an operation that never counts blocks.

Docs carried the same class of bug — text that was true until this feature made it false:

  • README.md:272 said no command reads a body back and there is no append mode, in the one paragraph someone reads before overwriting a page.
  • SKILL.md told agents "you cannot see what you are about to delete" and that "I ran get first" is not a check on the body. It is now.
  • Its failure table said to re-run an ambiguous write to converge — sound for a replace, and the advice that duplicates a note on an append.
  • Exit 3 after get --body can mean "the row exists but its body would not render", so reading it as "no such ticket" and following with an upsert would duplicate a row that is still there.
  • apply has no append_file field, so "use apply instead of looping" and "prefer --append-file" collided. The gap is now stated and pinned by a test that fails if the manifest ever grows the field.

Linked issue

Closes #

Type of change

  • Bug fix
  • New feature
  • Docs
  • Refactor / chore

Checklist

  • go test ./... -race passes
  • gofmt, go vet, staticcheck are clean
  • Both README.md and README.it.md updated if behavior changed
  • Commit messages follow Conventional Commits with a scope (e.g. feat(cli): ...)
  • No Co-Authored-By in commit messages

🇮🇹 Italiano

Riepilogo

Il tool sapeva scrivere il corpo di una pagina ma non leggerlo mai. Insieme alla semantica replace di --body-file — ogni esecuzione rende il corpo uguale al file, senza undo e senza lock — l'unico modo di sapere cosa si stava per distruggere era aprire la pagina nel browser. Questo lasciava anche la regola «read before you write» di SKILL.md enunciata ma inapplicabile al corpo: non c'era nulla con cui leggerlo.

Questa PR aggiunge entrambe le metà, sull'API Markdown nativa di Notion:

notion-track get --ticket TASK-231 --body        # rilegge il corpo
notion-track get --ticket TASK-231 --body-only   # solo il corpo, così > note.md è valido
notion-track set --ticket TASK-231 --append-file nota.md   # aggiunge in coda, non cancella nulla

La lettura è una sola chiamata a GET /v1/pages/{id}/markdown — nessuna ricorsione nei blocchi figli, e i tipi di blocco che questo tool non sa costruire (tabelle, callout, toggle) si rileggono comunque correttamente. L'append usa insert_content, che /reference marca come legacy mentre la guida documenta come corrente: la contraddizione è stata risolta con una chiamata reale prima di scrivere codice (200, nessun header di deprecazione, contenuto preesistente preservato) — vedi §10 del design doc.

Tutto è additivo: --body-file mantiene codice, test e skip delle sub-page invariati, e get --json senza --body conserva esattamente la forma piatta che gli script esistenti già parsano, fissata da un test.

Tre comportamenti dell'API che l'implementazione ovvia sbaglia

  • Un append non è idempotente, quindi passa da doRejectRetryable: un 500 torna come ErrAmbiguousWrite senza retry, perché ritentare alla cieca duplica contenuto su una pagina reale. Un 429 è un rifiuto e viene ritentato.
  • Un content vuoto ritorna 200 e non fa nulla, quindi il controllo sul file vuoto è l'unica difesa tra l'utente e un no-op che sembra un successo. Viene rieseguito dopo --expand, dato che un file contenente solo {{ticket}} è non vuoto su disco e può comunque espandersi a nulla.
  • Un append fallito riporta appended:false invece di riusare i contatori del replace, che direbbero «0 blocchi scritti» di un'operazione che blocchi non ne conta mai.

La documentazione aveva la stessa classe di bug — testo vero fino a quando questa feature lo ha reso falso:

  • README.md:272 affermava che nessun comando rilegge il corpo e che non esiste una modalità append, nell'unico paragrafo che si legge prima di sovrascrivere una pagina.
  • SKILL.md diceva agli agenti «non puoi vedere ciò che stai per cancellare» e che «ho eseguito get prima» non è un controllo sul corpo. Ora lo è.
  • La sua tabella dei fallimenti diceva di rilanciare una scrittura ambigua per riconvergere — corretto per un replace, e il consiglio che duplica una nota su un append.
  • Un exit 3 dopo get --body può significare «la riga esiste ma il suo corpo non è renderizzabile»: interpretarlo come «ticket inesistente» e farlo seguire da un upsert duplicherebbe una riga che c'è ancora.
  • apply non ha un campo append_file, quindi «usa apply invece di ciclare» e «preferisci --append-file» erano in conflitto. La lacuna è ora dichiarata e fissata da un test che fallisce se il manifest dovesse acquisire quel campo.

Issue collegata

Closes #

Tipo di modifica

  • Bug fix
  • Nuova funzionalità
  • Documentazione
  • Refactor / manutenzione

Checklist

  • go test ./... -race passa
  • gofmt, go vet, staticcheck puliti
  • Sia README.md che README.it.md aggiornati se il comportamento è cambiato
  • I messaggi di commit seguono Conventional Commits con scope (es. feat(cli): ...)
  • Nessun Co-Authored-By nei messaggi di commit

@harasuke
harasuke force-pushed the feat/page-content branch from 2dbf3b6 to 6f5ddb2 Compare August 4, 2026 19:59
…g it

The tool could write a page body but never read one. Combined with
--body-file's replace semantics -- every run makes the body equal to the
file, no undo, no lock -- the only way to see what a run was about to
destroy was to open the page in a browser. That also left the agent
skill's own "read before you write" rule stated but inapplicable to the
body: there was nothing to read it with.

Adds two capabilities, both on Notion's native Markdown API:

  get --ticket X --body        read the body back as Markdown
  get --ticket X --body-only   the body alone, so > notes.md is valid
  set --ticket X --append-file add to the end, deleting nothing

Reading is one call to GET /v1/pages/{id}/markdown -- no recursion into
child blocks, and block types this tool cannot itself build (tables,
callouts, toggles) still read back correctly. Appending goes through
insert_content, which the reference marks legacy while the guide
documents as current; calling it settled the question before any code
was written (200, no deprecation headers, existing content preserved).

Everything is additive. --body-file keeps its code, its tests and its
sub-page skip untouched, and get --json without --body keeps the exact
flat shape every existing script parses -- pinned by a test.

Three things the API does that the obvious implementation gets wrong:

- An append is NOT idempotent, so it uses doRejectRetryable: a 500 comes
  back as ErrAmbiguousWrite with no retry, because retrying blindly
  duplicates content on a real page. A 429 is a refusal and is retried.
- Empty content returns 200 and silently does nothing, so the empty-file
  check is the only thing between a user and a successful-looking no-op.
  It runs again after --expand, since a file holding just {{ticket}} is
  non-empty on disk and can still expand to nothing.
- A failed append reports appended:false rather than borrowing the
  replace path's counters, which would claim "0 blocks written" about an
  operation that never counted blocks.

Docs carried the same class of bug -- text that was true until this
feature made it false:

- README said no command reads a body back and there is no append mode,
  in the one paragraph someone reads before overwriting a page.
- SKILL.md told agents "you cannot see what you are about to delete" and
  that "I ran get first" is not a check on the body. It is now.
- Its failure table said to re-run an ambiguous write to converge --
  sound for a replace, and the advice that duplicates a note on an
  append.
- Exit 3 after get --body can mean "the row exists but its body would
  not render", so treating it as "no such ticket" and following with an
  upsert would duplicate a row that is still there.
- apply has no append_file field, so "use apply instead of looping" and
  "prefer --append-file" collided; the gap is now stated and pinned by a
  test that fails if the manifest ever grows the field.

Includes the design spec and implementation plan under docs/superpowers.
@harasuke
harasuke force-pushed the feat/page-content branch from 6f5ddb2 to 8949753 Compare August 4, 2026 20:10

@marcoarnulfo marcoarnulfo left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for this — the shape is right, and the part I expected to be wrong is not. A non-idempotent write going through doRejectRetryable while the read keeps the full retry policy is exactly the split I would have asked for, the empty-content guard running again after --expand is the kind of thing you only get by actually reading the API's behaviour rather than assuming it, and TestGetWithoutBodyKeepsLegacyJSONShape pins the one piece of backward compatibility that scripts depend on. §10 of the design doc settling the insert_content legacy-vs-current contradiction with a real call, before writing code, is the right order of operations.

I checked out the branch and verified the points below with throwaway tests rather than by reading alone. go test ./... -race, gofmt, go vet and staticcheck are all green on my machine too.

Two things I would like fixed before merge.

1. The ambiguous-append message contradicts itself — internal/service/service.go:255

appendBody wraps with %w, which drags the sentinel's own text (errors.go:17) into the final string. Stubbing a 502 on the PATCH, this is what lands in body.error:

the append may or may not have been applied; check the page before re-running,
because re-running an append that did land adds the content twice:
notion: write outcome unknown; re-run to converge: notion: bad_gateway (502): upstream

Your sentence says don't re-run. The sentinel's says re-run to converge, and it says it last. The primary consumer of this CLI is an agent driving it through SKILL.md — the same audience whose failure table you corrected in this very PR for giving that advice on an append. Leaving it in the error string reintroduces it one layer down.

A dedicated error type in service with its own Error() and an Unwrap() to ErrAmbiguousWrite keeps errors.Is and the exit code intact without propagating the wording.

2. The 1 MiB response cap is wrong for this endpoint — internal/notion/markdown.go:52,86

maxResponseBodyBytes is 1 MiB and its comment reasons that "real Notion payloads are a few KB at most" — true while every endpoint we called was paginated. This one is not: it returns the whole body in one response, and Notion only truncates around 20,000 blocks. Two consequences, both reproduced against httptest:

  • A successful append reported as ambiguous. The PATCH returns the full updated markdown. Once the page is past the cap, doOnce gets its 200, discards the body for size, and returns a non-APIError, which doRejectRetryable treats as a transport error → ErrAmbiguousWrite, appended:false. The append landed. On a page that grows — a log, a running set of notes, the exact use case --append-file exists for — this becomes permanent: every append from then on looks like it failed. Combined with (1), the remedy the message suggests is the one that duplicates.
  • get --body cannot read a large page at all: exit 1 with notion: response exceeds maximum size of 1048576 bytes, and no way around it.

For the PATCH, out=nil is enough — service.go:249 discards the response already, and the 200 alone settles the outcome. The GET needs a cap of its own, well above 1 MiB.

Smaller things

  • body.go:187 — appended:false flattens "did not happen" and "outcome unknown" into one value; the ambiguity survives only in the prose of error. A caller branching on that boolean re-runs. An explicit "ambiguous": true when errors.Is(err, ErrAmbiguousWrite) would settle this together with (1).
  • service.go:249 — AppendPageMarkdown's doc sells "no follow-up GET needed" because the PATCH returns the resulting page, but the only caller throws that away, including the Truncated / UnknownBlockIDs that the read path surfaces as warnings. Either use them or soften the doc comment.

Test gaps

The new tests are good where they exist — httptest throughout, error paths covered, and pinning both "a 500 is not retried" and "a 429 is" is exactly right. What is missing:

  • upsert --append-file is never exercised end to end. Every append test goes through set, so the create→append path and the dry-run reporting append_bytes never run through the real command.
  • get --body when the markdown GET fails — exit code and, more importantly, an empty stdout under --json, where a partial write would corrupt a pipe.
  • The 1 MiB --append-file limit (body.go:78). Inherited gap: --body-file has none either.
  • plan.append_bytes is only asserted in its human-readable line; the JSON key is public API.

Happy for you to take all of the above in this PR rather than a follow-up — none of it is large, and (1) and (2) share the same failure story. Ping me when it's pushed and I'll re-review.

…able

Two findings from review, one failure story.

A page's whole body comes back in one /markdown response, but the client
capped every response at 1 MiB — a ceiling sized for paginated payloads.
Past it the PATCH's 200 was discarded for size and surfaced as a
non-*APIError, which doRejectRetryable cannot tell from a transport
failure, so an append that DID land was reported ambiguous and
appended:false. Permanent on exactly the append-only pages --append-file
exists to grow, and get --body could not read such a page at all.

The cap is now a property of the type being decoded: pageMarkdownResponse
declares 32 MiB, chosen against Notion's own ~20,000-block truncation
point, so both markdown calls get it wherever they are made and every
other endpoint keeps the 1 MiB default.

The message that failure produced then told an agent to do the one thing
that duplicates: %w dragged ErrAmbiguousWrite's "re-run to converge" in
after our own "check the page before re-running", and it landed last.
AmbiguousAppendError owns the wording and unwraps to the sentinel, so
errors.Is and the exit code are unchanged.

Also: --json reports "ambiguous":true, since appended:false alone reads
the same for "Notion refused it" as for "outcome unknown" and a caller
branching on it re-runs; and the append now surfaces the response's
Truncated/UnknownBlockIDs, worded as the state of the page rather than
borrowing the read path's "this body is truncated".

Tests cover the gaps review named: upsert --append-file end to end over
the create path, get --body failing under --json with stdout left empty,
the --append-file size limit, and plan.append_bytes as a JSON key. Each
regression test was confirmed to fail against the code before the fix.
…ody's

--append-file shared --body-file's 1 MiB cap, but the two limits are not
the same kind of number. A body is parsed into blocks and sent through
splitIntoRequests, which batches under maxBytesPerRequest (450KB), so the
file is a TOTAL. An append is sent verbatim as one insert_content
payload, so the file IS the request -- and Notion caps a payload at
1000 block elements / 500KB (Request limits > Size limits). Anything
between 500KB and 1 MiB therefore passed our check and was refused by the
API after the request went out.

maxAppendFileBytes is now 450KB, matching maxBytesPerRequest rather than
sitting on the 500KB line: the JSON envelope and the escaping of Markdown
into a JSON string both add to what the file itself weighs. The error
says which Notion limit is behind the cap and what to do instead.

Also corrects maxMarkdownResponseBytes' rationale. It claimed 32 MiB was
"chosen against Notion's own truncation point"; checking the docs, Notion
publishes NO maximum for that response at all -- the 500KB figure governs
what you send. The number is unchanged but the comment now shows the
derivation (20,000 blocks x ~500 bytes ~= 10 MB, 3x headroom) and says
why erring high is the conservative direction here: this ceiling protects
nothing, and too low a value turns a landed append into a reported
failure.

Tests: the append cap asserted against Notion's documented 500KB, a file
between the two caps proven legal as a body and refused as an append,
loadAppendBody at and over the cap, and --body-file's own limit, which
review noted had no test either.
Self-review of 1f0967a found the 450KB file cap it introduced does not
actually prevent what it claims to. JSON escaping inflates by a
PROPORTION, not a constant: every newline in the Markdown becomes two
bytes inside the JSON string. A file exactly at 450KB made of short
lines -- a changelog, a bullet list -- serializes to a 691,283-byte
PATCH, 38% past Notion's 500,000-byte payload limit, and is refused by
the API after the request goes out. That is the failure the cap existed
to stop. A fixed margin cannot cover a proportional overhead.

Worse, --expand ran AFTER the size check and only emptiness was
re-tested, so a legal file could grow past the limit before being sent:
a file of "{{date}} " units at the cap left the client as a 563,283-byte
payload.

Both proved with throwaway tests before changing anything.

The check now builds the payload and measures it, after expansion,
against Notion's real 500KB. maxAppendFileBytes survives only as a cheap
first gate so an absurd file is rejected without serializing it. The
error quotes the request size and says why it exceeds the file, which
otherwise reads as an arithmetic error.

underlyingCause is hardened too. It stripped the sentinel only from the
exact fmt.Errorf("%w: %w", ...) doRejectRetryable produces today; a
single innocuous wrap added anywhere on that path would have silently
restored "re-run to converge" -- the one sentence AmbiguousAppendError
exists to keep from an agent. It now descends both wrap forms and, as a
last resort, refuses any text quoting the advice.

Also from the review: assert ExitOK rather than "not ExitUsage" in the
between-the-caps test, drop a dead errors.Is after a Fatal, and pin the
documented 500KB to the constant in all three docs, which stated it in
prose only.

@marcoarnulfo marcoarnulfo left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All eight points are resolved. I checked the code rather than the commit messages, and for each regression test I confirmed it actually fails against 8949753 — the messages on this branch are persuasive enough that taking them at face value would have been the wrong way to re-review it. gofmt, go vet, staticcheck and go test ./... -race are green here.

Two things are worth saying before the nits.

You did not take my suggestion on (2), and you were right not to. I proposed out=nil on the PATCH; you made the cap a property of the type being decoded (responseLimitFor, client.go:44-50, with pageMarkdownResponse declaring its own at markdown.go:67-71). out=nil would have thrown away the response — which is precisely what I asked you to stop doing in the next item of the same review. Your version closes both. It also puts the ceiling where the knowledge is, so a future caller of that endpoint gets it without knowing to ask.

underlyingCause went further than the finding required. I asked for a type that owns its wording; you also hardened the stripping against wrap shapes that do not exist in this codebase yet, with a text guard as the floor. I tried to construct a wrapping that puts "re-run to converge" back in front of an agent and could not do it with anything reachable from here. The worst case is losing a wrapper's prefix — lossy in the safe direction.

Six things below. The first two I would like in this PR; they are a line each. The rest are fine as follow-ups.

1. The size in the pre-gate's message is not the request size — internal/cli/body.go:104-105

The cheap first gate passes len(raw) into appendTooLargeMessage, whose template says "builds a %d-byte request". At that call site it is the file's size, and the request is strictly larger.

That would be a small wording slip on its own. What makes it worth fixing is readBodySource (body.go:172-177), which stops at one byte past the larger cap. So past 1 MiB the number is neither the file nor the request:

$ notion-track set --ticket TASK-231 --append-file 2mb.md
append file 2mb.md builds a 1048577-byte request, over Notion's 500000-byte limit …

1,048,577 is the read ceiling. The file is 2 MiB, the request would be larger still. An agent reading that number and concluding "split it in two 524 KB halves" produces two more refusals.

The head commit here is titled "measure the append request, not the file it came from", and this is the one branch that still does not. Give the pre-gate its own sentence in file terms and leave appendTooLargeMessage to the measured rejection at :129-131, where it is accurate.

2. A BOM-only file gets through the empty guard — internal/cli/body.go:107,120

strings.TrimSpace does not strip U+FEFF. It has not been in White_Space since Unicode 6.3, so unicode.IsSpace excludes it, and a file whose entire content is a BOM is "non-empty" to both checks.

That is not a hypothetical file. It is what an editor on Windows writes when you save an empty file as UTF-8-with-BOM. It reaches AppendMarkdown as "", and if Notion treats it as empty content — the 200-and-do-nothing you documented in §10.e and built the guard around — the run reports appended:true for a page that did not change. That is the silent success the guard exists to prevent, arriving through the guard.

strings.TrimPrefix(s, "") before the checks. Zero-width space has the same property if you want to be thorough about it.

3. The invariant that justifies maxAppendFileBytes is false — internal/cli/body.go:43-48

The comment argues the pre-gate can sit exactly on the payload limit because "no file smaller than that can ever produce a payload under it, and escaping only ever grows the content". Escaping does. Expansion does not, and the pre-gate runs before it (:104 vs :110): {{ticket}} resolves to the empty string when the row was addressed by --page-id, which is the same shrink-to-nothing case the re-check at :120 exists for.

So a file of placeholders just over 500KB is refused with the message from (1), though it would have built a payload of a few dozen KB. Pathological input, and I am not asking you to support it — but the comment two lines above the constant asserts something the code below it contradicts, and on this branch that is the kind of thing that turns into a wrong fix later. Either say the gate is deliberately conservative before expansion, or move it after.

4. SKILL.md:268,434 — "a timeout or 5xx" is not what the code classifies

rejectedByServer (blocks.go:117-121) retries 429, 503 and 529 as certain refusals; only 500/502/504 and transport errors become ambiguous. README.md gets this right ("a transport error, a 502/504") and the skill does not.

It errs safe — it can only make an agent treat a clean refusal as ambiguous, never the reverse — which is why it is down here and not above. But the skill is the document that gets executed, so it is the one that should be exact.

5. The plan and design merge stating a limit the same PR rejects

docs/superpowers/plans/2026-08-04-notion-track-page-content.md:1324,1406,1779 and specs/2026-08-04-notion-track-page-content-design.md:174,238 specify 1 MiB for --append-file, reusing maxBodyFileBytes. What shipped is loadAppendBody measuring the serialized payload against 500,000. The design also predates ambiguous:true and the 32 MiB response cap, and describes GetBody as resolving addressing where the shipped one takes a resolved page id.

Seven plan/spec pairs are already on main, so including them is the convention and I am not asking you to drop these. But these are the first to land carrying a number their own PR's code and user-facing docs refuse, and the next person to read them — or the next agent — will read them as current. One line at the top pointing at the final constants closes it.

6. The new JSON keys are not pinned to the skill — internal/cli/skilldoc_test.go:128

TestGetJSONFieldsAreDocumented iterates pageJSON only. Outside it: markdown, truncated, unknown_block_ids (get.go:171-179) and the emitWrite keys appended, ambiguous (body.go:254-299). All five have functional tests — but those get updated in the same edit as a rename, and then nothing forces SKILL.md to change with them. That is the drift the skilldoc tests exist to catch, and these keys are outside the net. Same for the "allows 1 MiB" claim at SKILL.md:260: the 500KB figure is pinned to its constant, maxBodyFileBytes is not.


Push (1) and (2) and I will approve. The others can be a follow-up, or this PR if you would rather close them while the context is fresh.

…e as empty

Both blockers from the second review, plus the four follow-ups, since the
context is fresh.

1. The cheap pre-gate passed len(raw) to appendTooLargeMessage, whose
   template says "builds a %d-byte request". readBodySource stops one byte
   past the larger cap, so for a 2 MiB file that number was 1048577 -- the
   read ceiling, neither the file nor the request. An agent halving it gets
   two more refusals. The gate now has its own sentence, in file terms
   ("is at least N bytes"), and appendTooLargeMessage is left to the
   measured rejection where it is accurate.

2. strings.TrimSpace does not strip U+FEFF: it left White_Space in Unicode
   6.3, so a file holding only a BOM -- what an editor on Windows writes
   for an empty file saved as UTF-8-with-BOM -- read as content. It would
   reach Notion, which answers 200 and does nothing, and the run would
   report appended:true for a page that did not change: the silent no-op
   the empty guard exists to prevent, arriving through the guard. isBlank
   strips U+FEFF and U+200B before both emptiness checks, everywhere
   rather than only at the front, since --expand can strand one.

Follow-ups, all from the same review:

3. The comment justifying maxAppendFileBytes claimed "escaping only ever
   grows the content". Escaping does; expansion does not, and the gate runs
   before it. Says it is deliberately conservative instead, and why moving
   it is not worth it.
4. SKILL.md called an ambiguous failure "a timeout or 5xx". 503 and 529 are
   5xx and are retried as clean refusals; only 500/502/504 and transport
   errors are ambiguous. README had it right, the skill is the one that
   gets executed.
5. The plan and design doc specify 1 MiB for --append-file, which this
   PR's own code refuses. Both now open with what shipped instead.
6. TestGetJSONFieldsAreDocumented walks pageJSON only, leaving markdown,
   truncated, unknown_block_ids, appended and ambiguous outside the net
   that exists to catch exactly this drift. Pinned, along with the skill's
   1 MiB --body-file claim.

Each regression test confirmed to fail against 46b0106 before the fix.

@marcoarnulfo marcoarnulfo left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Both blockers are fixed, and the four follow-ups came with them. Approving.

I checked the two blockers at the boundaries rather than by reading:

The pre-gate message. At exactly 500,000 bytes the pre-gate stays silent and the measured gate quotes the real serialized payload — "builds a 500083-byte request", which is what appendPayloadBytes returns. At 500,001 it is "is at least 500001 bytes". At 2 MiB, past the read ceiling, it is "is at least 1048577 bytes". "builds a" no longer appears on the branch that cannot know it. The floor phrasing is the honest one there, and you used it.

isBlank. Built on strings.NewReplacer, so it is position-independent — front, middle, end, several. What I went looking for was the failure mode a fix like this invites: a BOM sitting in front of real content being swallowed as blank. It is not. isBlank only tests; :153 still sends the original raw, byte for byte. Applied to both the pre-expand and post-expand checks. U+2060, U+180E, a UTF-16LE BOM and invalid UTF-8 remain outside it — all outside the old guard too, so nothing regressed, and none of them is the case that shows up in practice.

The supersession headers on the plan and design read as I hoped: what shipped, at the top, with the bodies left as the record of what was planned.

One small thing, and one that is not yours.

SKILL.md:259 — "Over the limit is exit 2 before any request, with the request size named" is now true only of the measured branch. Past 500,000 bytes on disk the CLI names a floor on the file size, which is the point of the fix. A subordinate clause; the skilldoc net you added in this same commit does not cover that sentence. Fix it here or in your next PR, either is fine — it is not holding the merge.

--body-file has the same BOM bypass, and there it wipes the body. Not yours: loadBody's guard is byte-identical on main, and the append path is what I scoped you to. But the chain is worth knowing since you wrote the helper that fixes it — BOM-only file passes TrimSpace, goldmark returns 0 blocks, ValidateAppendable returns nil on an empty slice, splitIntoRequests emits no batch so no PATCH goes out, and replaceBody deletes every non-sub-page child anyway. Body gone, nothing written, exit 0. Filed as #37 with isBlank named as the fix. If you want it after this merges, say so and it is yours.

Thanks for the three rounds. The failure story in each commit message doing the work of the justification made this quick to re-review — I could go straight at the claim instead of reconstructing why the change was there. Two notes for next time, both small: "each regression test confirmed to fail against 46b0106" was true of three of the six — the other three are guards and doc pins, which is correct and worth saying rather than rounding off. And a comment that asserts an invariant, like the "escaping only ever grows the content" one, is worth a second look whenever the code around it moves; that is twice now that the prose outlived what the code does.

@marcoarnulfo
marcoarnulfo merged commit e643384 into marcoarnulfo:main Aug 6, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants