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
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,18 @@ All notable changes to `tasks`. Format loosely follows
[Keep a Changelog](https://keepachangelog.com/); this project uses
[semantic versioning](https://semver.org/).

## [1.1.2] — 2026-07-13

### Fixed
- Field edits landed on the first line carrying an ID in file order — which for
a feature with a `@shadow` copy can be the shadow, not the canonical line.
`set F-N --milestone m1` on such a feature tagged only the shadow, so the
feature showed up in both its milestone and the `default` bucket (#17). The
same first-match bug affected `set` (all tags), `link --deps/--rel`, `prio`,
and the status verbs. All of these now apply to the primary line **and** every
shadow copy, keeping the derived copies in sync — consistent with how shadows
are created (copied from the primary, tags included).

## [1.1.1] — 2026-07-01

### Fixed
Expand Down
2 changes: 2 additions & 0 deletions schema.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,8 @@ When a task is skipped/scattered into a section its parent feature isn't in,
`tasks` leaves a lightweight copy of the parent feature line tagged `@shadow` in
that section, so the hierarchy stays readable across sections. Shadows are
created and cleaned up automatically — treat them as derived; don't hand-edit.
Field edits (`set`/`prio`/`link`/status verbs) apply to the primary line **and**
all shadow copies, keeping them in sync.

## Milestones (optional, opt-in)

Expand Down
132 changes: 72 additions & 60 deletions tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,8 @@
- `tree` and `show F-N` display a merged view with [Section] labels
- `backlog F-N` / `now F-N` collapses all shadows back into one section
- Empty shadows are auto-cleaned after moves
- Field edits (set/prio/link/status verbs) apply to the primary line AND all
shadow copies, keeping them in sync
- `validate` warns about empty shadows and errors on orphaned shadows

Milestones (optional, opt-in)
Expand Down Expand Up @@ -188,7 +190,7 @@
from pathlib import Path
from typing import List, Tuple, Optional, Dict, Set, Iterator

__version__ = "1.1.1"
__version__ = "1.1.2"

# Canonical source for `selfupdate` — the published raw URL. `selfupdate`
# overwrites this very script with the fetched content, so a NON-default source
Expand Down Expand Up @@ -1342,76 +1344,89 @@ def _set_single_tag(rest: str, tag: str, value: Optional[str]) -> str:
return _join_title_tags(title, tag_tokens)


def find_all_item_lines(lines: List[str], item_id: str) -> List[int]:
"""All line indices carrying item_id — primary line first, then shadows.

Shadow lines are derived copies of the primary (see Shadow Features), so
field edits (tags, priority, status, links) must be applied to every copy
to keep them in sync — editing only the first match in file order can land
on a shadow and leave the canonical line untouched (#17).
"""
primary: List[int] = []
shadows: List[int] = []
for i, line in iter_content_lines(lines):
p = parse_item(line)
if p and p[2] == item_id:
(shadows if is_shadow(line) else primary).append(i)
return primary + shadows


def edit_links(item_id: str,
deps_op: Optional[str] = None, deps_list: Optional[str] = None,
rel_op: Optional[str] = None, rel_list: Optional[str] = None) -> None:
item_id = normalize_id(item_id)
lines = load()

for i, line in iter_content_lines(lines):
parsed = parse_item(line)
if not parsed:
continue
indent, box, iid, prio, status, rest = parsed
if iid != item_id:
continue

# current
cur_deps = parse_id_list(_get_tag_value(rest, TAG_DEPS_RE) or "")
cur_rel = parse_id_list(_get_tag_value(rest, TAG_REL_RE) or "")

def apply(op: Optional[str], cur: List[str], arg: Optional[str]) -> List[str]:
if not op:
return cur
op = op.lower()
if op == "clear":
return []
if op == "set":
return parse_id_list(arg or "")
if op == "add":
return sort_ids(unique_preserve(cur + parse_id_list(arg or "")))
if op == "rm":
rm = set(parse_id_list(arg or ""))
return [x for x in cur if x not in rm]
raise SystemExit(f"Unknown link op '{op}' (use add|rm|set|clear)")

new_deps = apply(deps_op, cur_deps, deps_list)
new_rel = apply(rel_op, cur_rel, rel_list)
idxs = find_all_item_lines(lines, item_id)
if not idxs:
raise SystemExit(f"Item {item_id} not found")

# Current values come from the primary (first) line; shadows are copies.
src = parse_item(lines[idxs[0]])
assert src is not None # indices come from parsed item lines
cur_deps = parse_id_list(_get_tag_value(src[5], TAG_DEPS_RE) or "")
cur_rel = parse_id_list(_get_tag_value(src[5], TAG_REL_RE) or "")

def apply(op: Optional[str], cur: List[str], arg: Optional[str]) -> List[str]:
if not op:
return cur
op = op.lower()
if op == "clear":
return []
if op == "set":
return parse_id_list(arg or "")
if op == "add":
return sort_ids(unique_preserve(cur + parse_id_list(arg or "")))
if op == "rm":
rm = set(parse_id_list(arg or ""))
return [x for x in cur if x not in rm]
raise SystemExit(f"Unknown link op '{op}' (use add|rm|set|clear)")

new_deps = apply(deps_op, cur_deps, deps_list)
new_rel = apply(rel_op, cur_rel, rel_list)

for i in idxs: # primary + shadow copies stay in sync
parsed = parse_item(lines[i])
assert parsed is not None
indent, box, iid, prio, status, rest = parsed
new_rest = rest
if deps_op:
new_rest = _set_or_remove_tag(new_rest, "deps", new_deps)
if rel_op:
new_rest = _set_or_remove_tag(new_rest, "rel", new_rel)

lines[i] = build_item_line(indent, iid, prio, status, new_rest)
save(lines)
print(f"Linked {item_id}")
return

raise SystemExit(f"Item {item_id} not found")
save(lines)
print(f"Linked {item_id}")


def set_tag(item_id: str, tag: str, value: str) -> None:
"""Set a single-value tag on an item."""
"""Set a single-value tag on an item (primary line and any shadow copies)."""
item_id = normalize_id(item_id)
lines = load()

for i, line in iter_content_lines(lines):
parsed = parse_item(line)
if not parsed:
continue
indent, box, iid, prio, status, rest = parsed
if iid != item_id:
continue
idxs = find_all_item_lines(lines, item_id)
if not idxs:
raise SystemExit(f"Item {item_id} not found")

new_rest = _set_single_tag(rest, tag, value)
lines[i] = build_item_line(indent, iid, prio, status, new_rest)
save(lines)
print(f"Set @{tag}={value} on {item_id}")
return
for i in idxs: # primary + shadow copies stay in sync (#17)
parsed = parse_item(lines[i])
assert parsed is not None
indent, box, iid, prio, status, rest = parsed
lines[i] = build_item_line(indent, iid, prio, status, _set_single_tag(rest, tag, value))

raise SystemExit(f"Item {item_id} not found")
save(lines)
print(f"Set @{tag}={value} on {item_id}")


# -------------------------
Expand Down Expand Up @@ -2103,18 +2118,15 @@ def cmd_update(item_id: str, *, status: Optional[str] = None, prio: Optional[str
if parsed and parsed[2] != item_id and parsed[4] == "doing":
lines[i] = normalize_item_line(line, status="todo")

for i, line in iter_content_lines(lines):
parsed = parse_item(line)
if not parsed:
continue
if parsed[2] != item_id:
continue
lines[i] = normalize_item_line(line, status=status, prio=prio)
save(lines)
print(f"Updated {item_id}")
return
idxs = find_all_item_lines(lines, item_id)
if not idxs:
raise SystemExit(f"Item {item_id} not found")

raise SystemExit(f"Item {item_id} not found")
for i in idxs: # primary + shadow copies stay in sync (#17)
lines[i] = normalize_item_line(lines[i], status=status, prio=prio)

save(lines)
print(f"Updated {item_id}")


def cmd_new(args: List[str]):
Expand Down
62 changes: 62 additions & 0 deletions tests/test_tasks_e2e.sh
Original file line number Diff line number Diff line change
Expand Up @@ -1424,6 +1424,68 @@ assert_contains "Prose @milestone=foo preserved in the title" "$out" "@milestone
out=$(run validate)
assert_contains "Validate still passes with prose look-alikes" "$out" "OK"

# ============================================================
echo -e "\n${YELLOW}=== SCENARIO 49: edits land on the primary line, shadows stay in sync (issue #17) ===${NC}"
# ============================================================
setup_test_dir
fresh_init > /dev/null

cat >> TASKS.md << 'MS'

# Milestones

- M1 alias=alpha status=active North star
MS

# Feature in Backlog; starting its task creates a @shadow in Now — which
# PRECEDES the primary in file order (Now section comes first).
run new feature "Shadowed feature" --prio P1 --section Backlog > /dev/null
run new task "Child task" --prio P0 --under F-0001 > /dev/null
run start T-0001 > /dev/null

out=$(cat TASKS.md)
assert_contains "Shadow exists (precondition)" "$out" "@shadow"

run set F-0001 --milestone m1 > /dev/null

# Anchor greps on the real title — the init template's # Meta code block also
# contains a literal (F-0001) example line that must not pollute assertions.
primary=$(grep "(F-0001).*Shadowed feature" TASKS.md | grep -v "@shadow")
shadow=$(grep "(F-0001).*Shadowed feature" TASKS.md | grep "@shadow")
assert_contains "Primary (canonical) line carries the milestone" "$primary" "@milestone=m1"
assert_contains "Shadow copy stays in sync" "$shadow" "@milestone=m1"

out=$(run list --milestone default)
assert_not_contains "Feature no longer leaks into the default bucket" "$out" "F-0001"

out=$(run milestone --table)
if echo "$out" | grep -F "M1 (alpha)" | grep -qF "F-0001"; then
PASS=$((PASS + 1)); echo -e " ${GREEN}PASS${NC}: table puts F-0001 on the M1 row"
else
FAIL=$((FAIL + 1)); ERRORS="${ERRORS}\n FAIL: F-0001 missing from M1 table row"
echo -e " ${RED}FAIL${NC}: table puts F-0001 on the M1 row"
fi
if echo "$out" | grep -E "^default" | grep -qF "F-0001"; then
FAIL=$((FAIL + 1)); ERRORS="${ERRORS}\n FAIL: F-0001 still on the default table row"
echo -e " ${RED}FAIL${NC}: F-0001 not on the default table row"
else
PASS=$((PASS + 1)); echo -e " ${GREEN}PASS${NC}: F-0001 not on the default table row"
fi

# Same first-match bug class: prio and link on a shadowed feature.
run prio F-0001 P0 > /dev/null
primary=$(grep "(F-0001).*Shadowed feature" TASKS.md | grep -v "@shadow")
shadow=$(grep "(F-0001).*Shadowed feature" TASKS.md | grep "@shadow")
assert_contains "prio updates the primary line" "$primary" "[P0]"
assert_contains "prio keeps the shadow in sync" "$shadow" "[P0]"

run link F-0001 --rel add T-0001 > /dev/null
primary=$(grep "(F-0001).*Shadowed feature" TASKS.md | grep -v "@shadow")
assert_contains "link edits the primary line" "$primary" "@rel=T-0001"

out=$(run validate)
assert_contains "Validate passes after synced edits" "$out" "OK"

# ============================================================
# Summary
# ============================================================
Expand Down
Loading