Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
143 changes: 135 additions & 8 deletions crates/lash-cli/src/commands/update/mutations.rs
Original file line number Diff line number Diff line change
Expand Up @@ -218,25 +218,58 @@
(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<usize> {
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)
Expand Down Expand Up @@ -298,7 +331,7 @@
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<String> = text
.lines()
.map(|l| format!("{continuation_indent}{l}"))
Expand Down Expand Up @@ -643,6 +676,100 @@
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";
Expand Down
87 changes: 87 additions & 0 deletions crates/lash-cli/tests/update_command_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
33 changes: 33 additions & 0 deletions devlog.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
3 changes: 3 additions & 0 deletions tasks/tasks.cli-task-mutation.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading