@@ -218,25 +218,58 @@ impl TaskLines {
218218 ( start, j)
219219 }
220220
221- /// Locate the line index of the `@key:` line within the annotation
222- /// block, if present.
221+ /// End (exclusive) of the task's whole body region: every line up to
222+ /// the next checkbox line or `## ` heading. The parser merges `@key:`
223+ /// lines found past free-text body lines into the most recent task as
224+ /// orphaned annotations (see `parse_task_section_internal`), so an edit
225+ /// that only searched the contiguous annotation block would miss such
226+ /// an annotation and write a duplicate `@key:` line that lint then
227+ /// rejects (GitHub issue #74).
228+ fn task_body_end ( & self ) -> usize {
229+ let mut j = self . task_idx + 1 ;
230+ while j < self . lines . len ( ) {
231+ let trimmed = self . lines [ j] . trim ( ) ;
232+ if trimmed. starts_with ( "- [" ) || trimmed. starts_with ( "## " ) {
233+ break ;
234+ }
235+ j += 1 ;
236+ }
237+ j
238+ }
239+
240+ /// Locate the line index of the `@key:` line belonging to this task, if
241+ /// present: within the annotation block, or anywhere later in the
242+ /// task's body (an orphaned annotation after free-text body lines,
243+ /// which the parser still attributes to this task).
223244 fn find_annotation_line ( & self , key : & str ) -> Option < usize > {
224- let ( start, end) = self . annotation_block_range ( ) ;
245+ let start = self . task_idx + 1 ;
246+ let end = self . task_body_end ( ) ;
225247 let prefix = format ! ( "@{key}:" ) ;
226248 ( start..end) . find ( |& i| self . lines [ i] . trim_start ( ) . starts_with ( & prefix) )
227249 }
228250
229251 /// `[start, end)` range of continuation lines directly following an
230- /// annotation start line at `idx` (its multi-line value), stopping at
231- /// the next annotation line or the end of the block.
252+ /// annotation start line at `idx` (its multi-line value). Within the
253+ /// annotation block, any non-blank non-`@` line continues the value
254+ /// (mirroring the parser's lookahead); for an orphaned annotation past
255+ /// the block, only lines indented deeper than the annotation itself
256+ /// count, so trailing body text is never swallowed.
232257 fn continuation_range ( & self , idx : usize ) -> ( usize , usize ) {
233258 let ( _, block_end) = self . annotation_block_range ( ) ;
259+ let body_end = self . task_body_end ( ) ;
260+ let annotation_indent = leading_space_count ( & self . lines [ idx] ) ;
234261 let mut j = idx + 1 ;
235- while j < block_end {
236- let trimmed = self . lines [ j] . trim ( ) ;
262+ while j < body_end {
263+ let line = & self . lines [ j] ;
264+ let trimmed = line. trim ( ) ;
237265 if trimmed. is_empty ( ) || trimmed. starts_with ( '@' ) {
238266 break ;
239267 }
268+ if j >= block_end
269+ && ( trimmed. starts_with ( "- " ) || leading_space_count ( line) <= annotation_indent)
270+ {
271+ break ;
272+ }
240273 j += 1 ;
241274 }
242275 ( idx + 1 , j)
@@ -298,7 +331,7 @@ impl TaskLines {
298331 return ;
299332 } ;
300333 let ( _, cont_end) = self . continuation_range ( idx) ;
301- let continuation_indent = " " . repeat ( self . task_indent + 4 ) ;
334+ let continuation_indent = " " . repeat ( leading_space_count ( & self . lines [ idx ] ) + 2 ) ;
302335 let new_lines: Vec < String > = text
303336 . lines ( )
304337 . map ( |l| format ! ( "{continuation_indent}{l}" ) )
@@ -643,6 +676,100 @@ mod tests {
643676 assert ! ( cont_idx < extra_idx) ;
644677 }
645678
679+ /// GitHub issue #74: free-text body lines between the checkbox and the
680+ /// `@agent-note` must not hide the note from `--append-agent-note` —
681+ /// appending used to insert a second `@agent-note:` line that lint then
682+ /// rejected as a duplicate annotation.
683+ fn sample_with_body_text ( ) -> & ' static str {
684+ "# Tasks\n \
685+ \n \
686+ @id: tasks\n \
687+ \n \
688+ ## Tasks\n \
689+ \n \
690+ - [ ] Beta task with body text #demo\n \
691+ Some free-text body line recorded earlier.\n \
692+ @agent-note: Route: original first line.\n \
693+ Second line of the note.\n "
694+ }
695+
696+ #[ test]
697+ fn append_agent_note_past_body_text_extends_existing_note ( ) {
698+ let content = sample_with_body_text ( ) ;
699+ let ln = line_number_of ( content, "Beta task" ) ;
700+ let mut tl = TaskLines :: from_content ( content, ln) ;
701+ tl. append_agent_note ( "APPEND ONE: first appended line." ) ;
702+ let rendered = tl. render ( ) ;
703+ assert_eq ! ( rendered. matches( "@agent-note:" ) . count( ) , 1 ) ;
704+ // Appended line lands after the note's existing continuation, at
705+ // continuation indent.
706+ let second_idx = rendered. find ( "Second line of the note." ) . unwrap ( ) ;
707+ let appended_idx = rendered. find ( "APPEND ONE" ) . unwrap ( ) ;
708+ assert ! ( second_idx < appended_idx) ;
709+ assert ! ( rendered. contains( "\n APPEND ONE: first appended line." ) ) ;
710+ }
711+
712+ #[ test]
713+ fn set_agent_note_past_body_text_replaces_in_place ( ) {
714+ let content = sample_with_body_text ( ) ;
715+ let ln = line_number_of ( content, "Beta task" ) ;
716+ let mut tl = TaskLines :: from_content ( content, ln) ;
717+ tl. set_agent_note ( "Replacement note" ) ;
718+ let rendered = tl. render ( ) ;
719+ assert_eq ! ( rendered. matches( "@agent-note:" ) . count( ) , 1 ) ;
720+ assert ! ( rendered. contains( "@agent-note: Replacement note" ) ) ;
721+ assert ! ( !rendered. contains( "Second line of the note." ) ) ;
722+ // The body text before the note survives untouched.
723+ assert ! ( rendered. contains( "Some free-text body line recorded earlier." ) ) ;
724+ }
725+
726+ #[ test]
727+ fn set_single_annotation_past_body_text_replaces_in_place ( ) {
728+ let content = "- [ ] Task\n \
729+ Body text line.\n \
730+ @owner: alice\n ";
731+ let mut tl = TaskLines :: from_content ( content, 1 ) ;
732+ tl. set_single_annotation ( "owner" , Some ( "bob" ) ) ;
733+ let rendered = tl. render ( ) ;
734+ assert_eq ! ( rendered. matches( "@owner:" ) . count( ) , 1 ) ;
735+ assert ! ( rendered. contains( "@owner: bob" ) ) ;
736+ }
737+
738+ #[ test]
739+ fn find_past_body_text_does_not_reach_next_task ( ) {
740+ let content = "- [ ] First task\n \
741+ Body text line.\n \
742+ - [ ] Second task\n \
743+ @agent-note: Belongs to second task.\n ";
744+ let mut tl = TaskLines :: from_content ( content, 1 ) ;
745+ tl. append_agent_note ( "Note for first task." ) ;
746+ let rendered = tl. render ( ) ;
747+ // A new note is created for the first task; the second task's note
748+ // is untouched.
749+ assert_eq ! ( rendered. matches( "@agent-note:" ) . count( ) , 2 ) ;
750+ assert ! ( rendered. contains( "@agent-note: Note for first task." ) ) ;
751+ let first_note = rendered. find ( "Note for first task." ) . unwrap ( ) ;
752+ let second_task = rendered. find ( "- [ ] Second task" ) . unwrap ( ) ;
753+ assert ! ( first_note < second_task) ;
754+ }
755+
756+ #[ test]
757+ fn append_past_body_text_does_not_swallow_trailing_body_text ( ) {
758+ let content = "- [ ] Task\n \
759+ Body text before.\n \
760+ @agent-note: The note.\n \
761+ Body text after, same indent as the note.\n ";
762+ let mut tl = TaskLines :: from_content ( content, 1 ) ;
763+ tl. append_agent_note ( "Appended line." ) ;
764+ let rendered = tl. render ( ) ;
765+ assert_eq ! ( rendered. matches( "@agent-note:" ) . count( ) , 1 ) ;
766+ // Appended continuation goes directly after the note line, before
767+ // the same-indent trailing body text.
768+ let appended_idx = rendered. find ( "Appended line." ) . unwrap ( ) ;
769+ let trailing_idx = rendered. find ( "Body text after" ) . unwrap ( ) ;
770+ assert ! ( appended_idx < trailing_idx) ;
771+ }
772+
646773 #[ test]
647774 fn append_agent_note_creates_when_absent ( ) {
648775 let content = "- [ ] Task\n @id: task-1\n " ;
0 commit comments