Summary
edit_mode: "hashline" (v0.51.2) ships a rich patch language whose grammar lives only in crates/aft/src/hashline/syntax/mod.rs (and its unit tests). The tool description, README, and docs/config.md all say only "PUT/CUT/REM/MV operations with [path#TAG] sections" — so a fresh agent assembling its first patch cannot succeed without reading Rust source. In practice I hit 5 distinct failures in one round-trip session, several of which the tool surface actively invites (it prints tags that are then rejected).
What worked (after reading the parser)
Round-trip on a test file: PUT 25: (text) → CUT 13.=14 @frag → PUT >$ @frag → MV name.md → REM. All five ops function; tags rotate per mutation.
Evidence: first-use failures (v0.51.2, exact responses)
- Text PUT needs a trailing colon that is never stated.
PUT 25 + body rows fails with "PUT body rows must begin with +; unexpected content at line 3". Reason (from parser): PUT 25 is the register-copy form (anonymous register) which accepts no body; the text form is the subtly different PUT 25: (colon). The colon is a one-ASCII-char rune no surface documents.
- Trailing newline in the patch breaks parsing.
PUT 25: + +row → error "PUT body rows must begin with +; unexpected content at line 4" when the patch string ends with \n — the empty final line is treated as a body row. Empty lines inside a body must be written + alone; this is only discoverable from source.
PUT <line> REPLACES the line rather than inserting. PUT 25: deleted the existing line 25 and substituted the body (data loss of the old line). Insert-before/after require gap forms <25 / >25. A plain line address is a replacement — nowhere documented, and the semantic is surprising for a PUT.
- Tags printed by edit responses are unusable. After
CUT, the response printed [path#D0E7] — reusing that tag for the next edit fails with "gap boundary line 23 was not retained by the tagged read". Only tags from an actual tagged read retain eligibility; the response tag is a dead end, yet it looks like the same currency.
steering never surfaces; stale rejection is opaque. Rejection structs carry a steering hint ("perform a ranged tagged re-read because required boundary context changed", "re-read the current tagged content before editing"…), but the tool response returns only the message — e.g. a stale known tag yields "addressed content no longer matches the Phase-1 baseline" with no tag name and no "re-read" hint.
- REM/MV right after MV is rejected. The MV response again showed
[path#47BA] as the destination tag, but REM with it fails with "REM and MV require a tagged read that retained the whole source file" — needs yet another read.
Suggested fixes (any subset, in priority order)
- Document the grammar in the tool description (primary ask — the agent sees nothing else). Include at minimum:
- Address forms:
0 (BOF), <N / >N (gap before/after), N (span), ranges N.=M (aliases N..=M/N..M), blocks N* / >N*, $ / $-K (EOF-relative).
- PUT: text form
PUT <addr>: + +-prefixed body rows (empty rows as bare +); register form PUT <addr> @name (no body). Register-name rules (@ + ascii alnum/_/-).
- CUT:
CUT <addr> [@name]; REM: bare REM (whole file); MV: MV "<dest path>" (must be the only op in the section).
- Semantics: plain line address = replace;
</> = gap inserts; ops apply per section; *** Begin Patch/*** End Patch envelope accepted.
- "The tag must come from a tagged read, not from an edit response."
- Surface
steering in the tool response (or at least tag + "re-read" hint) — it already exists and is being discarded.
- Stop printing a
[path#TAG] for post-mutation state in edit responses (or label it clearly as un-eligible), since those tags cannot be used in the next patch.
- Accept a tolerated trailing newline at patch end (or document the empty-body-row rule) — a model naturally emits a final
\n.
Happy to contribute: the grammar summary above can be distilled into a docs/hashline.md + an excerpt in the tool description. (Test env: OpenCode plugin build 0.51.2, edit_mode: "hashline".)
Summary
edit_mode: "hashline"(v0.51.2) ships a rich patch language whose grammar lives only incrates/aft/src/hashline/syntax/mod.rs(and its unit tests). The tool description, README, and docs/config.md all say only "PUT/CUT/REM/MV operations with[path#TAG]sections" — so a fresh agent assembling its first patch cannot succeed without reading Rust source. In practice I hit 5 distinct failures in one round-trip session, several of which the tool surface actively invites (it prints tags that are then rejected).What worked (after reading the parser)
Round-trip on a test file:
PUT 25:(text) →CUT 13.=14 @frag→PUT >$ @frag→MV name.md→REM. All five ops function; tags rotate per mutation.Evidence: first-use failures (v0.51.2, exact responses)
PUT 25+ body rows fails with "PUT body rows must begin with +; unexpected content at line 3". Reason (from parser):PUT 25is the register-copy form (anonymous register) which accepts no body; the text form is the subtly differentPUT 25:(colon). The colon is a one-ASCII-char rune no surface documents.PUT 25:++row→ error "PUT body rows must begin with +; unexpected content at line 4" when the patch string ends with\n— the empty final line is treated as a body row. Empty lines inside a body must be written+alone; this is only discoverable from source.PUT <line>REPLACES the line rather than inserting.PUT 25:deleted the existing line 25 and substituted the body (data loss of the old line). Insert-before/after require gap forms<25/>25. A plain line address is a replacement — nowhere documented, and the semantic is surprising for aPUT.CUT, the response printed[path#D0E7]— reusing that tag for the next edit fails with "gap boundary line 23 was not retained by the tagged read". Only tags from an actual tagged read retain eligibility; the response tag is a dead end, yet it looks like the same currency.steeringnever surfaces; stale rejection is opaque. Rejection structs carry asteeringhint ("perform a ranged tagged re-read because required boundary context changed", "re-read the current tagged content before editing"…), but the tool response returns only the message — e.g. a stale known tag yields "addressed content no longer matches the Phase-1 baseline" with no tag name and no "re-read" hint.[path#47BA]as the destination tag, butREMwith it fails with "REM and MV require a tagged read that retained the whole source file" — needs yet another read.Suggested fixes (any subset, in priority order)
0(BOF),<N/>N(gap before/after),N(span), rangesN.=M(aliasesN..=M/N..M), blocksN*/>N*,$/$-K(EOF-relative).PUT <addr>:++-prefixed body rows (empty rows as bare+); register formPUT <addr> @name(no body). Register-name rules (@+ ascii alnum/_/-).CUT <addr> [@name]; REM: bareREM(whole file); MV:MV "<dest path>"(must be the only op in the section).</>= gap inserts; ops apply per section;*** Begin Patch/*** End Patchenvelope accepted.steeringin the tool response (or at least tag + "re-read" hint) — it already exists and is being discarded.[path#TAG]for post-mutation state in edit responses (or label it clearly as un-eligible), since those tags cannot be used in the next patch.\n.Happy to contribute: the grammar summary above can be distilled into a
docs/hashline.md+ an excerpt in the tool description. (Test env: OpenCode plugin build 0.51.2,edit_mode: "hashline".)