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
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `## <OLD>`
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
Expand Down
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) |
Expand Down
71 changes: 71 additions & 0 deletions docs/superpowers/specs/2026-07-13-renumber-design.md
Original file line number Diff line number Diff line change
@@ -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<KIND>-0*<n>\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.
162 changes: 161 additions & 1 deletion tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 `## <OLD>` 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 `## <OLD>` 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:
Expand Down Expand Up @@ -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")

Expand Down
Loading
Loading