diff --git a/crates/lash-cli/src/commands/update/mutations.rs b/crates/lash-cli/src/commands/update/mutations.rs index 11d4f4b..66423dd 100644 --- a/crates/lash-cli/src/commands/update/mutations.rs +++ b/crates/lash-cli/src/commands/update/mutations.rs @@ -218,25 +218,58 @@ impl TaskLines { (start, j) } - /// Locate the line index of the `@key:` line within the annotation - /// block, if present. + /// End (exclusive) of the task's whole body region: every line up to + /// the next checkbox line or `## ` heading. The parser merges `@key:` + /// lines found past free-text body lines into the most recent task as + /// orphaned annotations (see `parse_task_section_internal`), so an edit + /// that only searched the contiguous annotation block would miss such + /// an annotation and write a duplicate `@key:` line that lint then + /// rejects (GitHub issue #74). + fn task_body_end(&self) -> usize { + let mut j = self.task_idx + 1; + while j < self.lines.len() { + let trimmed = self.lines[j].trim(); + if trimmed.starts_with("- [") || trimmed.starts_with("## ") { + break; + } + j += 1; + } + j + } + + /// Locate the line index of the `@key:` line belonging to this task, if + /// present: within the annotation block, or anywhere later in the + /// task's body (an orphaned annotation after free-text body lines, + /// which the parser still attributes to this task). fn find_annotation_line(&self, key: &str) -> Option { - let (start, end) = self.annotation_block_range(); + let start = self.task_idx + 1; + let end = self.task_body_end(); let prefix = format!("@{key}:"); (start..end).find(|&i| self.lines[i].trim_start().starts_with(&prefix)) } /// `[start, end)` range of continuation lines directly following an - /// annotation start line at `idx` (its multi-line value), stopping at - /// the next annotation line or the end of the block. + /// annotation start line at `idx` (its multi-line value). Within the + /// annotation block, any non-blank non-`@` line continues the value + /// (mirroring the parser's lookahead); for an orphaned annotation past + /// the block, only lines indented deeper than the annotation itself + /// count, so trailing body text is never swallowed. fn continuation_range(&self, idx: usize) -> (usize, usize) { let (_, block_end) = self.annotation_block_range(); + let body_end = self.task_body_end(); + let annotation_indent = leading_space_count(&self.lines[idx]); let mut j = idx + 1; - while j < block_end { - let trimmed = self.lines[j].trim(); + while j < body_end { + let line = &self.lines[j]; + let trimmed = line.trim(); if trimmed.is_empty() || trimmed.starts_with('@') { break; } + if j >= block_end + && (trimmed.starts_with("- ") || leading_space_count(line) <= annotation_indent) + { + break; + } j += 1; } (idx + 1, j) @@ -298,7 +331,7 @@ impl TaskLines { return; }; let (_, cont_end) = self.continuation_range(idx); - let continuation_indent = " ".repeat(self.task_indent + 4); + let continuation_indent = " ".repeat(leading_space_count(&self.lines[idx]) + 2); let new_lines: Vec = text .lines() .map(|l| format!("{continuation_indent}{l}")) @@ -643,6 +676,100 @@ mod tests { assert!(cont_idx < extra_idx); } + /// GitHub issue #74: free-text body lines between the checkbox and the + /// `@agent-note` must not hide the note from `--append-agent-note` — + /// appending used to insert a second `@agent-note:` line that lint then + /// rejected as a duplicate annotation. + fn sample_with_body_text() -> &'static str { + "# Tasks\n\ + \n\ + @id: tasks\n\ + \n\ + ## Tasks\n\ + \n\ + - [ ] Beta task with body text #demo\n \ + Some free-text body line recorded earlier.\n \ + @agent-note: Route: original first line.\n \ + Second line of the note.\n" + } + + #[test] + fn append_agent_note_past_body_text_extends_existing_note() { + let content = sample_with_body_text(); + let ln = line_number_of(content, "Beta task"); + let mut tl = TaskLines::from_content(content, ln); + tl.append_agent_note("APPEND ONE: first appended line."); + let rendered = tl.render(); + assert_eq!(rendered.matches("@agent-note:").count(), 1); + // Appended line lands after the note's existing continuation, at + // continuation indent. + let second_idx = rendered.find("Second line of the note.").unwrap(); + let appended_idx = rendered.find("APPEND ONE").unwrap(); + assert!(second_idx < appended_idx); + assert!(rendered.contains("\n APPEND ONE: first appended line.")); + } + + #[test] + fn set_agent_note_past_body_text_replaces_in_place() { + let content = sample_with_body_text(); + let ln = line_number_of(content, "Beta task"); + let mut tl = TaskLines::from_content(content, ln); + tl.set_agent_note("Replacement note"); + let rendered = tl.render(); + assert_eq!(rendered.matches("@agent-note:").count(), 1); + assert!(rendered.contains("@agent-note: Replacement note")); + assert!(!rendered.contains("Second line of the note.")); + // The body text before the note survives untouched. + assert!(rendered.contains("Some free-text body line recorded earlier.")); + } + + #[test] + fn set_single_annotation_past_body_text_replaces_in_place() { + let content = "- [ ] Task\n \ + Body text line.\n \ + @owner: alice\n"; + let mut tl = TaskLines::from_content(content, 1); + tl.set_single_annotation("owner", Some("bob")); + let rendered = tl.render(); + assert_eq!(rendered.matches("@owner:").count(), 1); + assert!(rendered.contains("@owner: bob")); + } + + #[test] + fn find_past_body_text_does_not_reach_next_task() { + let content = "- [ ] First task\n \ + Body text line.\n\ + - [ ] Second task\n \ + @agent-note: Belongs to second task.\n"; + let mut tl = TaskLines::from_content(content, 1); + tl.append_agent_note("Note for first task."); + let rendered = tl.render(); + // A new note is created for the first task; the second task's note + // is untouched. + assert_eq!(rendered.matches("@agent-note:").count(), 2); + assert!(rendered.contains("@agent-note: Note for first task.")); + let first_note = rendered.find("Note for first task.").unwrap(); + let second_task = rendered.find("- [ ] Second task").unwrap(); + assert!(first_note < second_task); + } + + #[test] + fn append_past_body_text_does_not_swallow_trailing_body_text() { + let content = "- [ ] Task\n \ + Body text before.\n \ + @agent-note: The note.\n \ + Body text after, same indent as the note.\n"; + let mut tl = TaskLines::from_content(content, 1); + tl.append_agent_note("Appended line."); + let rendered = tl.render(); + assert_eq!(rendered.matches("@agent-note:").count(), 1); + // Appended continuation goes directly after the note line, before + // the same-indent trailing body text. + let appended_idx = rendered.find("Appended line.").unwrap(); + let trailing_idx = rendered.find("Body text after").unwrap(); + assert!(appended_idx < trailing_idx); + } + #[test] fn append_agent_note_creates_when_absent() { let content = "- [ ] Task\n @id: task-1\n"; diff --git a/crates/lash-cli/tests/update_command_test.rs b/crates/lash-cli/tests/update_command_test.rs index 1dd93c0..f70cc55 100644 --- a/crates/lash-cli/tests/update_command_test.rs +++ b/crates/lash-cli/tests/update_command_test.rs @@ -448,6 +448,93 @@ fn test_update_agent_note_replace_and_append() { assert!(!content.contains("Second note")); } +/// GitHub issue #74: when free-text body lines sit between the checkbox and +/// the `@agent-note`, `--append-agent-note` used to insert a second +/// `@agent-note:` line directly under the checkbox — reporting success while +/// leaving a file that `lash lint` rejects ("cannot appear multiple times") +/// and `lash index` skips, stranding the file behind E_INDEX_STALE. +#[test] +fn test_update_append_agent_note_with_body_text_before_note_stays_lintable() { + let project = TestProject::builder() + .with_index("test-project", "Test Project") + .with_file( + "tasks.md", + r#"# Tasks + +@id: tasks + +## Tasks + +- [ ] Beta task with body text before the note #demo + Some free-text body line recorded earlier. + @agent-note: Route: original first line of the note. + Second line of the note with more detail. +"#, + ) + .build(); + + run_lash_command() + .arg("--root") + .arg(project.path()) + .arg("index") + .assert() + .success(); + + run_lash_command() + .arg("--root") + .arg(project.path()) + .arg("update") + .arg("tasks#beta-task-with-body-text-before-the-note") + .arg("--append-agent-note") + .arg("APPEND ONE: first appended line.") + .assert() + .success(); + + let content = fs::read_to_string(project.file_path("tasks.md")).unwrap(); + // Exactly one @agent-note annotation — no duplicate inserted above the + // body text. + assert_eq!(content.matches("@agent-note:").count(), 1); + // The appended line extends the existing note, after its continuation. + let second_idx = content.find("Second line of the note").unwrap(); + let appended_idx = content.find("APPEND ONE").unwrap(); + assert!(second_idx < appended_idx); + // The body text survives, still before the note. + assert!(content.contains("Some free-text body line recorded earlier.")); + + // The write must leave the file lintable and indexable — the corruption + // in issue #74 only surfaced here, far from the reported success. + run_lash_command() + .arg("--root") + .arg(project.path()) + .arg("lint") + .assert() + .success(); + + run_lash_command() + .arg("--root") + .arg(project.path()) + .arg("index") + .assert() + .success(); + + // And later mutations against the file keep working (no E_INDEX_STALE). + run_lash_command() + .arg("--root") + .arg(project.path()) + .arg("update") + .arg("tasks#beta-task-with-body-text-before-the-note") + .arg("--append-agent-note") + .arg("APPEND TWO: second appended line.") + .assert() + .success(); + + let content = fs::read_to_string(project.file_path("tasks.md")).unwrap(); + assert_eq!(content.matches("@agent-note:").count(), 1); + let one_idx = content.find("APPEND ONE").unwrap(); + let two_idx = content.find("APPEND TWO").unwrap(); + assert!(one_idx < two_idx); +} + #[test] fn test_update_add_depends_on_dangling_is_hard_error_file_untouched() { let project = TestProject::builder() diff --git a/devlog.md b/devlog.md index 3e1df84..8afe3c7 100644 --- a/devlog.md +++ b/devlog.md @@ -2832,3 +2832,36 @@ file, not the index line that failed to parse, so the obvious repair is the one thing already done. Worth remembering the next time a cross-file rule reports an absence: the report points at the symptom, and the parse that produced it is never on screen. + +## `--append-agent-note` duplicated the annotation past body text (#74, 2026-08-30) + +`lash update --append-agent-note` corrupted any task whose `@agent-note` sat +below free-text body lines: it wrote a second `@agent-note:` directly under +the checkbox, printed `appended to @agent-note`, and exited 0. The failure +surfaced only later — `lash lint` rejects the duplicate annotation, `lash +index` skips the file, and every subsequent mutation dies on `E_INDEX_STALE` +until the file is repaired by hand. + +The root cause was a disagreement between the parser and the editing +primitives about where a task's annotations can live. `TaskLines` in +`update/mutations.rs` searched only the contiguous annotation block +immediately after the checkbox, stopping at the first non-`@` line. The +parser is more forgiving: a `@key:` line found after intervening body text +becomes an "orphaned annotation" and is merged into the most recent task +(`parse_task_section_internal`). So the note belonged to the task in the +parsed model, but the editor could not see it and concluded the task had +none — then `append_agent_note` fell through to the create path. + +`find_annotation_line` now scans the task's whole body region — up to the +next checkbox line or `## ` heading, the same boundary that ends the +parser's orphan merging — and `continuation_range` handles the orphaned +position with a stricter rule (deeper-indented lines only) so trailing +same-indent body text is never swallowed. This fixes `--agent-note` +(replace) and the single-value annotations too, which had the same latent +duplicate-insert path. + +One gap remains, out of scope here: the parser attaches an orphaned +annotation as a single line and silently drops its indented continuation +lines, so `lash show` renders only the first line of a note in this +position. That predates the fix (the note's original second line was +already invisible) and is a lash-core parsing issue, not an editing one. diff --git a/tasks/tasks.cli-task-mutation.md b/tasks/tasks.cli-task-mutation.md index 2ecdcea..11a0ad9 100644 --- a/tasks/tasks.cli-task-mutation.md +++ b/tasks/tasks.cli-task-mutation.md @@ -31,3 +31,6 @@ Fixes for the task-mutation and display gaps reported while dogfooding - [x] `--agent-note` (replace) / `--append-agent-note` - [x] `--add-depends-on` / `--remove-depends-on`, validated against index - [x] Tests: each field, ID stability on retitle, dangling-dep rejection +- [x] Fix `--append-agent-note` duplicating `@agent-note` when body text precedes the note (#74) + - [x] Annotation lookup scans the task's whole body (mirrors the parser's orphaned-annotation merge) + - [x] Tests: append/replace past body text, no reach into next task, lint-clean round trip