diff --git a/CHANGELOG.md b/CHANGELOG.md index f7dda24..3f8fcb5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,19 @@ All notable changes to `tasks`. Format loosely follows [Keep a Changelog](https://keepachangelog.com/); this project uses [semantic versioning](https://semver.org/). +## [1.3.0] — 2026-07-13 + +### Added +- `renumber OLD (NEW | --next) [--refs]` — reassign an item's ID without + hand-editing (#12). Renames the primary line and all `@shadow` copies, + repoints every `@deps=`/`@rel=` list containing the old ID (tag region only — + prose mentions stay untouched, per the #9 rules), and renames the `## ` + header in `# Notes`. Refuses an occupied target (use `--next` for the next + free ID of the same kind), cross-kind moves, and no-ops. `--refs` prints a + read-only report of remaining mentions across git-tracked files (canonical + and short forms) — repointing those is deliberately left to the operator. + Motivated by the multi-branch ID-collision incident; pairs with #13/#14. + ## [1.2.0] — 2026-07-13 ### Added diff --git a/README.md b/README.md index d58ee87..5e1789e 100644 --- a/README.md +++ b/README.md @@ -96,7 +96,8 @@ Highlights: | `start \| done \| skip \| defer \| reopen ID` | Status transitions (`start` enforces a single active task) | | `tree` / `list [filters]` / `show ID [--full]` | Views | | `next` | Next actionable task (highest prio, unblocked, in `Now`) | -| `mv` / `set` / `link` | Move sections, set fields, edit deps/relations | +| `mv` / `set` / `link` | Move sections, set fields (incl. `--title`), edit deps/relations | +| `renumber OLD (NEW \| --next)` | Reassign an ID; repoints `@deps`/`@rel` + notes header (`--refs` reports repo mentions) | | `milestone [ID]` | Per-milestone rollup, or one milestone's detail (`--table` for a milestone×features view) | | `migrate-tags-to-milestone TAG` | Rewrite an interim `@tags=TAG` into `@milestone=TAG` | | `sync ID` | Reconcile a task with its linked GitHub issue (`@issue=`; needs the `gh` CLI) | diff --git a/docs/superpowers/specs/2026-07-13-renumber-design.md b/docs/superpowers/specs/2026-07-13-renumber-design.md new file mode 100644 index 0000000..16e25dc --- /dev/null +++ b/docs/superpowers/specs/2026-07-13-renumber-design.md @@ -0,0 +1,71 @@ +# Design: `renumber` verb (issue #12) + +**Status:** approved (interactive brainstorm, 2026-07-03; scope re-confirmed 2026-07-13) +**Motivation:** the byteorb three-way `T-2006` collision — with no `renumber`, +resolving an ID collision meant hand-editing `TASKS.md` (the documented +anti-pattern, see #5) and manually repointing every reference. + +## Decisions (made with the user) + +1. **Scope:** renumber only, this round. Allocation (#13), ref-integrity (#14), + and the merge driver (#15) are separate rounds. +2. **External references:** `renumber` never rewrites anything outside + `TASKS.md`. An optional `--refs` flag *detects and reports* remaining + mentions in tracked files, read-only — fixing them is the operator's/agent's + job. (Auto-rewriting prose is the #9 false-positive class; explicitly out.) +3. **Kinds:** both `T-` and `F-`. Features also rename every `@shadow` copy. + Same-kind only (`T-→T-`, `F-→F-`). +4. **Target availability:** refuse if the target ID exists (clear error, no + `--force`); `renumber OLD --next` moves to the next free ID of the same + kind. Safest, fully backward-compatible (new verb, no existing behavior + changes). + +## Command surface + +``` +tasks renumber OLD NEW [--refs] # rename OLD → explicit NEW +tasks renumber OLD --next [--refs] # rename OLD → next free ID (same kind) +``` + +Short ID forms accepted (`T-7`, `F-1`), normalized as everywhere else. + +## Behavior (inside TASKS.md — always) + +- Rename the `(OLD)` token on the item's primary line **and** every `@shadow` + copy (features). +- Repoint every other item's `@deps=` / `@rel=` list containing `OLD` → `NEW`. + Lists are read/written through the anchored tag-region helpers, so a prose + mention of the ID in a *title* is untouched (consistent with #9/1.1.1). + Short-form hand-written deps (`@deps=T-12`) are normalized on rewrite. +- Rename the `## OLD` header in `# Notes` → `## NEW` (body text untouched). +- Print a summary: primary/shadow line count, `@deps`/`@rel` repoint counts, + notes-header rename. + +## `--refs` (opt-in, read-only) + +After the rename, scan git-tracked files (`git ls-files`) in Python for +remaining `\b-0*\b` mentions (catches short forms like `T-12` for +`T-0012` without matching `T-00123`), and print `file:line: excerpt`. Never +modifies any file. Outside a git repo (or git unavailable): print a one-line +"skipped" note. Mentions remaining inside `TASKS.md` itself (prose titles, +notes bodies) are reported the same way. + +## Guards (hard refusal, `SystemExit` with a clear message) + +- `OLD` not found. +- `NEW` already exists → suggests `--next`. +- Cross-kind (`T-` → `F-` or vice versa). +- `OLD == NEW`. +- Both `NEW` and `--next` given, or neither. + +## Non-goals (v1) + +External rewrites; `--id`/`reserve` allocation (#13); repurposed-ID detection +(#14); merge driver (#15); `--force` in any form. + +## Testing + +New e2e scenarios: task rename + `@deps`/`@rel` repoint + notes header + +`show`/`validate`; guards (taken, cross-kind, missing, same-id); `--next`; +feature rename incl. `@shadow` sync; `--refs` reporting (git repo case: file +listed, file *not* modified) and the not-a-repo skip. diff --git a/tasks.py b/tasks.py index 57e475d..7e11696 100755 --- a/tasks.py +++ b/tasks.py @@ -149,6 +149,10 @@ set ID --system TAG Set @system= (sprint-*, milestone-*, phase-*, epic-*) set ID --milestone ID Set @milestone= (id/alias; "", "clear", or sentinel name clears) set ID --title "New title" Replace the descriptive title (ID/status/tags preserved) + renumber OLD (NEW | --next) [--refs] + Reassign an item's ID: renames the line (+ shadows), + repoints @deps/@rel and the # Notes header. --refs + reports remaining repo mentions (read-only) milestone [ID] [--table] Milestone rollup; `milestone ID` for detail; `--table` for a milestone x features x status table migrate-tags-to-milestone TAG Rewrite interim @tags=TAG into @milestone=TAG @@ -191,7 +195,7 @@ from pathlib import Path from typing import List, Tuple, Optional, Dict, Set, Iterator -__version__ = "1.2.0" +__version__ = "1.3.0" # Canonical source for `selfupdate` — the published raw URL. `selfupdate` # overwrites this very script with the fetched content, so a NON-default source @@ -2868,6 +2872,160 @@ def cmd_migrate_tags_to_milestone(args: List[str] = None): eprint(f" ⚠️ skipped {iid}: keeps existing @milestone={existing} (conflicts with '{tag}'); resolve by hand.") +def _report_external_refs(old_id: str) -> None: + """Read-only scan of git-tracked files for remaining mentions of old_id. + + Matches the canonical form AND short forms (T-12 for T-0012) without + matching longer numbers (T-00123). Never modifies any file — repointing + external references is the operator's job (see #12 design). Outside a git + repo, prints a skip note instead of failing. + """ + try: + result = subprocess.run(["git", "ls-files", "-z"], capture_output=True, text=True, check=True) + except (subprocess.CalledProcessError, FileNotFoundError): + eprint("--refs: skipped (not a git repository, or git unavailable)") + return + + kind, num_str = old_id.split("-") + pat = re.compile(rf'\b{kind}-0*{int(num_str)}\b') + hits: List[str] = [] + for fname in result.stdout.split("\0"): + if not fname: + continue + try: + text = Path(fname).read_text(encoding="utf-8") + except (OSError, UnicodeDecodeError, ValueError): + continue # binary/unreadable files are not reference sources + for lineno, line in enumerate(text.splitlines(), 1): + if pat.search(line): + hits.append(f" {fname}:{lineno}: {line.strip()[:120]}") + + if hits: + print(f"--refs: {len(hits)} remaining mention(s) of {old_id} (not modified):") + for h in hits: + print(h) + print(f" Repoint these by hand (or via your agent), then re-run with --refs to confirm.") + else: + print(f"--refs: no remaining mentions of {old_id} in tracked files.") + + +def cmd_renumber(args: List[str]): + """Reassign an item's ID and repoint structured references inside TASKS.md. + + In-file: renames the primary line and all @shadow copies, repoints every + @deps=/@rel= list containing the old ID, and renames the `## ` header + in # Notes. Prose mentions (titles, note bodies) are never rewritten — + `--refs` reports them (and any repo-wide mentions) read-only instead. + """ + usage = 'usage: renumber OLD (NEW | --next) [--refs]' + if not args: + raise SystemExit(usage) + + old_raw = args[0] + new_raw = None + use_next = False + report_refs = False + for a in args[1:]: + if a == "--next": + use_next = True + elif a == "--refs": + report_refs = True + elif a.startswith("--"): + raise SystemExit(f"Unknown arg: {a}. {usage}") + elif new_raw is None: + new_raw = a + else: + raise SystemExit(f"Unexpected extra argument '{a}'. {usage}") + + if use_next and new_raw is not None: + raise SystemExit("renumber: give either NEW or --next, not both.") + if not use_next and new_raw is None: + raise SystemExit(usage) + + old_id = normalize_id(old_raw) + lines = load() + + old_idxs = find_all_item_lines(lines, old_id) + if not old_idxs: + raise SystemExit(f"Item {old_id} not found") + + kind = old_id.split("-")[0] + if use_next: + new_id = next_id(lines, kind) + else: + new_id = normalize_id(new_raw) + if new_id.split("-")[0] != kind: + raise SystemExit(f"Cannot renumber {old_id} to {new_id}: kind must match ({kind}- stays {kind}-).") + if new_id == old_id: + raise SystemExit(f"renumber: {old_id} and {new_id} are the same id.") + if find_all_item_lines(lines, new_id): + raise SystemExit(f"{new_id} already exists; pick a free id or use: renumber {old_id} --next") + + # 1. Rename the item's own lines (primary first, then shadows). + shadows = 0 + for i in old_idxs: + parsed = parse_item(lines[i]) + assert parsed is not None + indent, box, iid, prio, status, rest = parsed + if is_shadow(lines[i]): + shadows += 1 + lines[i] = build_item_line(indent, new_id, prio, status, rest) + + # 2. Repoint @deps=/@rel= lists on every other item (tag region only — + # prose mentions in titles stay untouched, per the #9 parsing rules). + deps_repointed = 0 + rel_repointed = 0 + for i, line in iter_content_lines(lines): + parsed = parse_item(line) + if not parsed: + continue + indent, box, iid, prio, status, rest = parsed + new_rest = rest + cur_deps = parse_id_list(_get_tag_value(rest, TAG_DEPS_RE) or "") + if old_id in cur_deps: + new_rest = _set_or_remove_tag(new_rest, "deps", + sort_ids([new_id if x == old_id else x for x in cur_deps])) + deps_repointed += 1 + cur_rel = parse_id_list(_get_tag_value(new_rest, TAG_REL_RE) or "") + if old_id in cur_rel: + new_rest = _set_or_remove_tag(new_rest, "rel", + sort_ids([new_id if x == old_id else x for x in cur_rel])) + rel_repointed += 1 + if new_rest != rest: + lines[i] = build_item_line(indent, iid, prio, status, new_rest) + + # 3. Rename the `## ` notes header inside # Notes. + notes_renamed = False + notes_start = None + notes_end = len(lines) + for i, line in enumerate(lines): + if line.rstrip() == NOTES_HEADER: + notes_start = i + elif notes_start is not None and line.startswith("# "): + notes_end = i + break + if notes_start is not None: + for i in range(notes_start + 1, notes_end): + if lines[i].rstrip() == f"## {old_id}": + lines[i] = lines[i].replace(f"## {old_id}", f"## {new_id}", 1) + notes_renamed = True + break + + save(lines) + print(f"Renumbered {old_id} -> {new_id}") + detail = [f"{len(old_idxs) - shadows} primary line" + (f" + {shadows} shadow(s)" if shadows else "")] + if deps_repointed: + detail.append(f"{deps_repointed} @deps repointed") + if rel_repointed: + detail.append(f"{rel_repointed} @rel repointed") + if notes_renamed: + detail.append("notes header renamed") + print(" " + ", ".join(detail)) + + if report_refs: + _report_external_refs(old_id) + + def _version_tuple(v: str) -> Tuple[int, ...]: parts = re.findall(r'\d+', v) if not parts: @@ -3089,6 +3247,8 @@ def main(): cmd_milestone(sys.argv[2:]) elif cmd == "migrate-tags-to-milestone": cmd_migrate_tags_to_milestone(sys.argv[2:]) + elif cmd == "renumber": + cmd_renumber(sys.argv[2:]) else: raise SystemExit(f"Unknown command '{cmd}'. Try: ./tools/tasks.py help") diff --git a/tests/test_tasks_e2e.sh b/tests/test_tasks_e2e.sh index ee97154..1b170ea 100755 --- a/tests/test_tasks_e2e.sh +++ b/tests/test_tasks_e2e.sh @@ -1528,6 +1528,108 @@ assert_not_contains "Mid-title lookalike creates no phantom bucket" "$out" " x" out=$(run validate) assert_contains "Validate passes after retitles" "$out" "OK" +# ============================================================ +echo -e "\n${YELLOW}=== SCENARIO 51: renumber — rename ID, repoint deps/rel/notes, guards (issue #12) ===${NC}" +# ============================================================ +setup_test_dir +fresh_init > /dev/null + +run new feature "Renumber home" --prio P1 --section Now > /dev/null +run new task "Base task" --prio P0 --under F-0001 > /dev/null +run new task "Depends on base" --prio P1 --under F-0001 --deps T-0001 > /dev/null +run new task "Related to base" --prio P2 --under F-0001 > /dev/null +run link T-0003 --rel add T-0001 > /dev/null + +cat >> TASKS.md << 'NOTES' + +## T-0001 +Design note that must follow the task through a renumber. +NOTES + +out=$(run renumber T-0001 T-0010) +assert_contains "Renumber reports the move" "$out" "Renumbered T-0001 -> T-0010" + +out=$(run show T-0010) +assert_contains "Item lives at the new id" "$out" "Base task" +out=$(run show T-0001 2>&1 || true) +assert_contains "Old id is gone" "$out" "not found" + +out=$(grep "(T-0002)" TASKS.md) +assert_contains "@deps repointed to the new id" "$out" "@deps=T-0010" +out=$(grep "(T-0003)" TASKS.md) +assert_contains "@rel repointed to the new id" "$out" "@rel=T-0010" + +out=$(run show T-0010 --full) +assert_contains "Notes followed the renumber" "$out" "must follow the task" + +out=$(run validate) +assert_contains "Validate passes after renumber" "$out" "OK" + +# Guards. +out=$(run renumber T-0010 T-0002 2>&1 || true) +assert_contains "Refuses an occupied target" "$out" "already exists" +out=$(run renumber T-0010 F-0009 2>&1 || true) +assert_contains "Refuses cross-kind renumber" "$out" "kind must match" +out=$(run renumber T-0999 T-0998 2>&1 || true) +assert_contains "Refuses a missing source" "$out" "not found" +out=$(run renumber T-0010 T-0010 2>&1 || true) +assert_contains "Refuses a no-op renumber" "$out" "same id" +out=$(run renumber T-0010 T-0011 --next 2>&1 || true) +assert_contains "Refuses NEW plus --next together" "$out" "not both" + +# --next picks the next free id of the same kind (max is T-0010 -> T-0011). +out=$(run renumber T-0010 --next) +assert_contains "--next moves to the next free id" "$out" "Renumbered T-0010 -> T-0011" +out=$(grep "(T-0002)" TASKS.md) +assert_contains "@deps follows the --next renumber too" "$out" "@deps=T-0011" + +# ============================================================ +echo -e "\n${YELLOW}=== SCENARIO 52: renumber — feature shadows + --refs read-only report ===${NC}" +# ============================================================ +setup_test_dir +fresh_init > /dev/null + +run new feature "Shadowed renumber" --prio P1 --section Backlog > /dev/null +run new task "Child" --prio P0 --under F-0001 > /dev/null +run start T-0001 > /dev/null # creates @shadow of F-0001 in Now + +out=$(run renumber F-0001 F-0010) +assert_contains "Feature renumber reports the move" "$out" "Renumbered F-0001 -> F-0010" + +new_count=$(grep -c "(F-0010).*Shadowed renumber" TASKS.md || true) +old_count=$(grep -c "(F-0001).*Shadowed renumber" TASKS.md || true) +if [ "$new_count" -eq 2 ] && [ "$old_count" -eq 0 ]; then + PASS=$((PASS + 1)); echo -e " ${GREEN}PASS${NC}: primary + shadow both renamed (2 lines at F-0010, 0 at F-0001)" +else + FAIL=$((FAIL + 1)); ERRORS="${ERRORS}\n FAIL: shadow rename (new=$new_count old=$old_count)" + echo -e " ${RED}FAIL${NC}: primary + shadow both renamed (new=$new_count old=$old_count)" +fi + +out=$(run validate) +assert_contains "Validate passes after feature renumber" "$out" "OK" + +# --refs outside a git repo: skipped, not an error. +out=$(run renumber T-0001 T-0005 --refs 2>&1) +assert_contains "--refs skips gracefully outside git" "$out" "skipped" + +# --refs inside a git repo: reports remaining mentions read-only. +git init -q . +mkdir -p docs +cat > docs/ref.md << 'DOC' +The estimator work is tracked as T-0005 (formerly T-5 in shorthand). +DOC +git add docs/ref.md TASKS.md +before=$(cksum < docs/ref.md) +out=$(run renumber T-0005 T-0007 --refs 2>&1) +assert_contains "--refs lists the referencing file" "$out" "docs/ref.md:1" +after=$(cksum < docs/ref.md) +if [ "$before" = "$after" ]; then + PASS=$((PASS + 1)); echo -e " ${GREEN}PASS${NC}: --refs is read-only (ref.md unchanged)" +else + FAIL=$((FAIL + 1)); ERRORS="${ERRORS}\n FAIL: --refs modified docs/ref.md" + echo -e " ${RED}FAIL${NC}: --refs is read-only (ref.md unchanged)" +fi + # ============================================================ # Summary # ============================================================