Skip to content

Commit 2d488a7

Browse files
committed
fix: stop --append-agent-note duplicating @agent-note past body text (#74)
Annotation lookup now scans the task's whole body, mirroring the parser's orphaned-annotation merge, instead of only the contiguous block.
1 parent a2c6a02 commit 2d488a7

4 files changed

Lines changed: 258 additions & 8 deletions

File tree

‎crates/lash-cli/src/commands/update/mutations.rs‎

Lines changed: 135 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -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";

‎crates/lash-cli/tests/update_command_test.rs‎

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -448,6 +448,93 @@ fn test_update_agent_note_replace_and_append() {
448448
assert!(!content.contains("Second note"));
449449
}
450450

451+
/// GitHub issue #74: when free-text body lines sit between the checkbox and
452+
/// the `@agent-note`, `--append-agent-note` used to insert a second
453+
/// `@agent-note:` line directly under the checkbox — reporting success while
454+
/// leaving a file that `lash lint` rejects ("cannot appear multiple times")
455+
/// and `lash index` skips, stranding the file behind E_INDEX_STALE.
456+
#[test]
457+
fn test_update_append_agent_note_with_body_text_before_note_stays_lintable() {
458+
let project = TestProject::builder()
459+
.with_index("test-project", "Test Project")
460+
.with_file(
461+
"tasks.md",
462+
r#"# Tasks
463+
464+
@id: tasks
465+
466+
## Tasks
467+
468+
- [ ] Beta task with body text before the note #demo
469+
Some free-text body line recorded earlier.
470+
@agent-note: Route: original first line of the note.
471+
Second line of the note with more detail.
472+
"#,
473+
)
474+
.build();
475+
476+
run_lash_command()
477+
.arg("--root")
478+
.arg(project.path())
479+
.arg("index")
480+
.assert()
481+
.success();
482+
483+
run_lash_command()
484+
.arg("--root")
485+
.arg(project.path())
486+
.arg("update")
487+
.arg("tasks#beta-task-with-body-text-before-the-note")
488+
.arg("--append-agent-note")
489+
.arg("APPEND ONE: first appended line.")
490+
.assert()
491+
.success();
492+
493+
let content = fs::read_to_string(project.file_path("tasks.md")).unwrap();
494+
// Exactly one @agent-note annotation — no duplicate inserted above the
495+
// body text.
496+
assert_eq!(content.matches("@agent-note:").count(), 1);
497+
// The appended line extends the existing note, after its continuation.
498+
let second_idx = content.find("Second line of the note").unwrap();
499+
let appended_idx = content.find("APPEND ONE").unwrap();
500+
assert!(second_idx < appended_idx);
501+
// The body text survives, still before the note.
502+
assert!(content.contains("Some free-text body line recorded earlier."));
503+
504+
// The write must leave the file lintable and indexable — the corruption
505+
// in issue #74 only surfaced here, far from the reported success.
506+
run_lash_command()
507+
.arg("--root")
508+
.arg(project.path())
509+
.arg("lint")
510+
.assert()
511+
.success();
512+
513+
run_lash_command()
514+
.arg("--root")
515+
.arg(project.path())
516+
.arg("index")
517+
.assert()
518+
.success();
519+
520+
// And later mutations against the file keep working (no E_INDEX_STALE).
521+
run_lash_command()
522+
.arg("--root")
523+
.arg(project.path())
524+
.arg("update")
525+
.arg("tasks#beta-task-with-body-text-before-the-note")
526+
.arg("--append-agent-note")
527+
.arg("APPEND TWO: second appended line.")
528+
.assert()
529+
.success();
530+
531+
let content = fs::read_to_string(project.file_path("tasks.md")).unwrap();
532+
assert_eq!(content.matches("@agent-note:").count(), 1);
533+
let one_idx = content.find("APPEND ONE").unwrap();
534+
let two_idx = content.find("APPEND TWO").unwrap();
535+
assert!(one_idx < two_idx);
536+
}
537+
451538
#[test]
452539
fn test_update_add_depends_on_dangling_is_hard_error_file_untouched() {
453540
let project = TestProject::builder()

‎devlog.md‎

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2832,3 +2832,36 @@ file, not the index line that failed to parse, so the obvious repair is the one
28322832
thing already done. Worth remembering the next time a cross-file rule reports
28332833
an absence: the report points at the symptom, and the parse that produced it is
28342834
never on screen.
2835+
2836+
## `--append-agent-note` duplicated the annotation past body text (#74, 2026-08-30)
2837+
2838+
`lash update --append-agent-note` corrupted any task whose `@agent-note` sat
2839+
below free-text body lines: it wrote a second `@agent-note:` directly under
2840+
the checkbox, printed `appended to @agent-note`, and exited 0. The failure
2841+
surfaced only later — `lash lint` rejects the duplicate annotation, `lash
2842+
index` skips the file, and every subsequent mutation dies on `E_INDEX_STALE`
2843+
until the file is repaired by hand.
2844+
2845+
The root cause was a disagreement between the parser and the editing
2846+
primitives about where a task's annotations can live. `TaskLines` in
2847+
`update/mutations.rs` searched only the contiguous annotation block
2848+
immediately after the checkbox, stopping at the first non-`@` line. The
2849+
parser is more forgiving: a `@key:` line found after intervening body text
2850+
becomes an "orphaned annotation" and is merged into the most recent task
2851+
(`parse_task_section_internal`). So the note belonged to the task in the
2852+
parsed model, but the editor could not see it and concluded the task had
2853+
none — then `append_agent_note` fell through to the create path.
2854+
2855+
`find_annotation_line` now scans the task's whole body region — up to the
2856+
next checkbox line or `## ` heading, the same boundary that ends the
2857+
parser's orphan merging — and `continuation_range` handles the orphaned
2858+
position with a stricter rule (deeper-indented lines only) so trailing
2859+
same-indent body text is never swallowed. This fixes `--agent-note`
2860+
(replace) and the single-value annotations too, which had the same latent
2861+
duplicate-insert path.
2862+
2863+
One gap remains, out of scope here: the parser attaches an orphaned
2864+
annotation as a single line and silently drops its indented continuation
2865+
lines, so `lash show` renders only the first line of a note in this
2866+
position. That predates the fix (the note's original second line was
2867+
already invisible) and is a lash-core parsing issue, not an editing one.

‎tasks/tasks.cli-task-mutation.md‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,3 +31,6 @@ Fixes for the task-mutation and display gaps reported while dogfooding
3131
- [x] `--agent-note` (replace) / `--append-agent-note`
3232
- [x] `--add-depends-on` / `--remove-depends-on`, validated against index
3333
- [x] Tests: each field, ID stability on retitle, dangling-dep rejection
34+
- [x] Fix `--append-agent-note` duplicating `@agent-note` when body text precedes the note (#74)
35+
- [x] Annotation lookup scans the task's whole body (mirrors the parser's orphaned-annotation merge)
36+
- [x] Tests: append/replace past body text, no reach into next task, lint-clean round trip

0 commit comments

Comments
 (0)