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

## [1.4.0] — 2026-07-15

### Added
- Collision-resistant ID allocation on `new` (#13) — the prevention leg of the
multi-branch ID-collision incident (`renumber` in 1.3.0 is the cleanup):
- `new … --id T-42` — create with an explicit ID (short forms normalized).
Refused if the ID is already taken or the kind doesn't match; no `--force`
(a forced duplicate ID is file corruption, not a feature).
- `new … --base <git-ref>` — auto-allocate the next ID considering *both*
the local file and `TASKS.md` at the ref (`max(local, base) + 1`), so a
branch forked from `main` can't re-issue IDs `main` already handed out.
Fails closed on any git problem rather than silently falling back to
local-only allocation. Combined with `--id`, the explicit ID must be free
at the ref too.
- `reserve N` (persisted ranges) stays deferred — it would add a new concept
to the file format.

## [1.3.0] — 2026-07-13

### Added
Expand Down
18 changes: 18 additions & 0 deletions LEARNINGS.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,24 @@
Insights, gotchas, and decisions worth remembering — newest first. For *what*
changed see `CHANGELOG.md`; this file records *why* and what it taught us.

## 2026-07-15 — v1.4.0 (collision-resistant allocation)

- **Fail closed when the flag *is* the safety.** `new --base <ref>` exists to
prevent cross-branch ID collisions; if git fails, falling back to local-only
allocation would silently reintroduce exactly what the user opted into
preventing — so it errors instead. Contrast with `renumber --refs`, which
degrades gracefully outside a repo: that one is a best-effort *report*, not a
correctness guarantee. The rule: graceful degradation is for observability
features, hard failure is for safety features.
- **A `--force` sketched in triage isn't a commitment.** The #13 triage note
said "refuse if taken unless `--force`", but forcing a duplicate ID is file
corruption in every case — there is no valid use. Dropped it and recorded
the deviation in the design doc rather than shipping a footgun for symmetry.
- **Test needles must not look like flags.** `assert_contains … "--base"` broke
because the harness greps for the needle and grep ate it as an option.
Assert on message *words* ("cannot read"), not flag-shaped substrings — or
the assertion tests grep's argv parsing instead of the CLI.

## 2026-07-13 — v1.3.0 (`renumber`)

- **Mutate structure, report prose.** The renumber design's key call (made
Expand Down
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@ Highlights:
| Command | What |
|---|---|
| `init` | Create a starter `TASKS.md` |
| `new feature\|task "Title" [...]` | Create an item (`--prio`/`--priority`, `--under ID`, `--section`, `--effort`, `--tags`, `--deps`, `--status`) |
| `new feature\|task "Title" [...]` | Create an item (`--prio`/`--priority`, `--under ID`, `--section`, `--effort`, `--tags`, `--deps`, `--status`; `--id T-42` for an explicit ID — refused if taken; `--base REF` to also allocate past `TASKS.md` at a git ref) |
| `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`) |
Expand Down Expand Up @@ -122,6 +122,10 @@ Highlights:
and anything untagged falls into an implicit `default` bucket. An optional
`# Milestones` registry unlocks aliases (`@milestone=alpha` ≡ `@milestone=m1`),
statuses, and titles. See the [milestone workflow](#milestones) below.
- **Parallel branches.** Two branches that each run `new` allocate the same
next ID and collide at merge. Prevent it with `--base`
(`new task "..." --base origin/main` allocates past `main`'s max too) or
disjoint explicit `--id` ranges; clean up any survivor with `renumber`.
- **Commit your `TASKS.md`.** It's meant to be versioned with your code.
(This repo's `.gitignore` ignores a root `TASKS.md` only because it's the
tool's own test scratch — that does not apply to your project.)
Expand Down
75 changes: 75 additions & 0 deletions docs/superpowers/specs/2026-07-15-id-allocation-design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
# Design: collision-resistant ID allocation on `new` (issue #13)

**Status:** approved (triage recommendation on #13, confirmed 2026-07-15)
**Motivation:** the byteorb three-way `T-2006` collision — parallel branches each
ran `new`, each allocated the same next ID from their local `TASKS.md`, and the
merge produced duplicates. `renumber` (#12, v1.3.0) is the cleanup; this is the
prevention.

## Scope (per triage, split by cost)

1. **`new … --id <ID>`** — create with an explicit ID instead of auto-allocating.
Trivial, high-value: colliding branches can take disjoint ranges up front.
2. **`new … --base <git-ref>`** — auto-allocate the next ID considering *both*
the local file and `TASKS.md` at a git ref (`max(local, base) + 1`). Covers
the fork-from-`main` case that caused the incident without any manual range
planning.
3. **`reserve N`** — **deferred.** Needs persisted reservation state, which
introduces a new concept into the `TASKS.md` format. Not in this round.

## Command surface

```
tasks new task "Title" --id T-42 # explicit ID (short forms fine)
tasks new feature "Title" --base origin/main # next = max(local, ref) + 1
tasks new task "Title" --id T-42 --base origin/main # also verify vs ref
```

## Decisions

- **No `--force` on a taken `--id`.** The triage sketch mentioned one, but a
forced duplicate ID is file corruption, never a feature. Refuse with a clear
message (same principle as renumber's refuse-if-taken). Deviation recorded
here deliberately.
- **`--base` fails closed.** If git is unavailable, the ref doesn't resolve, or
`TASKS.md` doesn't exist at the ref, `new` errors out — it does **not**
silently fall back to local-only allocation. A silent fallback would quietly
reintroduce the exact collision this flag exists to prevent (see the
migrate-helper learning: never report safety you didn't provide). Contrast
with `renumber --refs`, which *degrades* gracefully — that one is a
best-effort report, not a correctness guarantee.
- **`--id` + `--base` compose.** With both, the explicit ID must be free in the
base ref too — checking only locally would miss the incident case.
- **Kind must match.** `new task --id F-7` is refused (same rule as renumber).
- **Format unchanged.** This is CLI allocation behavior only; `schema.md` and
the file format are untouched. Fully backward compatible: both flags are
opt-in, `new` without them behaves exactly as before.

## Behavior details

- Explicit `--id` is normalized like every other ID input (`T-7` → `T-0007`).
- "Taken" means any item line carrying the ID (primary or `@shadow`), via the
same lookup the rest of the tool uses (`find_all_item_lines`).
- `--base` resolves the tasks file's repo-relative path
(`git rev-parse --show-toplevel` + relpath) and reads
`git show <ref>:<relpath>`; the fetched content is scanned with the same
fence-skipping iteration as the local file (the `# Meta` template example
contains a literal `(F-0001)` inside a code fence — a naive scan would
count it).
- Errors (all `SystemExit`, clear message): ID taken locally ("pick a free id
or omit --id"), ID taken at ref, kind mismatch, invalid ID, not a git repo,
unreadable `<ref>:TASKS.md`, tasks file outside the repo.

## Non-goals (v1)

`reserve` / persisted ranges; scanning *all* branches for a global max
(heavier, more edge cases — an explicit single `--base` covers the incident
pattern); applying `--base` to `renumber --next` (can follow if wanted).

## Testing

New e2e scenarios: `--id` create + normalization + guards (taken, cross-kind,
invalid); `--base` allocation picks up a higher max from the ref (committed
file ahead of working tree), fail-closed on missing ref / outside a repo /
file absent at ref; `--id --base` refuses an ID taken only in the ref;
plain `new` unchanged.
61 changes: 57 additions & 4 deletions tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,8 @@
next [--milestone ID] Show next actionable task (highest prio, unblocked, in Now)
new (feature|task) "Title" [...] Create item (--prio|--priority, --status, --under ID,
--section NAME, --effort HOURS, --tags TAG1,TAG2, --deps ID1,ID2,
--milestone ID)
--milestone ID, --id T-42 explicit ID (refused if taken),
--base REF allocate past TASKS.md at a git ref too)
mv ID (--section NAME|--under ID) Move item subtree (auto-sorts Now by priority)
start|done|skip|defer ID Status transitions (start enforces single WIP)
reopen ID Reset terminal status (done/skipped/deferred) back to todo
Expand Down Expand Up @@ -178,6 +179,8 @@
./tools/tasks.py tree # Merged Feature display with section labels
./tools/tasks.py list --system sprint-1 --status todo
./tools/tasks.py new task "Estimator" --under F-1 --milestone m1
./tools/tasks.py new task "Parallel work" --id T-42 # explicit ID; refused if taken
./tools/tasks.py new task "Branch work" --base origin/main # allocate past the ref's max too
./tools/tasks.py set T-7 --milestone alpha # stored as-is; shows as M1 on read
./tools/tasks.py list --milestone m1 # alias-resolved; 'default' = unassigned
./tools/tasks.py milestone # per-milestone rollup
Expand All @@ -195,7 +198,7 @@
from pathlib import Path
from typing import List, Tuple, Optional, Dict, Set, Iterator

__version__ = "1.3.0"
__version__ = "1.4.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 @@ -2154,9 +2157,32 @@ def cmd_update(item_id: str, *, status: Optional[str] = None, prio: Optional[str
print(f"Updated {item_id}")


def _lines_from_git_ref(ref: str) -> List[str]:
"""Return the tasks file's lines as of a git ref, for `new --base`.

Fails closed (SystemExit) on any git problem — a silent fallback to
local-only allocation would quietly reintroduce the collisions --base
exists to prevent (see the #13 design doc).
"""
try:
top = subprocess.run(["git", "rev-parse", "--show-toplevel"],
capture_output=True, text=True, check=True).stdout.strip()
except (subprocess.CalledProcessError, FileNotFoundError):
raise SystemExit("--base: not a git repository (or git unavailable)")
rel = os.path.relpath(os.path.abspath(TASK_FILE), top).replace(os.sep, "/")
if rel.startswith(".."):
raise SystemExit(f"--base: {TASK_FILE} is outside this git repository")
show = subprocess.run(["git", "show", f"{ref}:{rel}"], capture_output=True, text=True)
if show.returncode != 0:
detail = (show.stderr or "").strip().splitlines()
suffix = f" ({detail[0]})" if detail else ""
raise SystemExit(f"--base: cannot read {rel} at '{ref}'{suffix}")
return show.stdout.splitlines(keepends=True)


def cmd_new(args: List[str]):
if len(args) < 2:
raise SystemExit('usage: new (feature|task) "Title" [--prio P1] [--status todo] [--under ID | --section Backlog] [--effort 4h] [--tags x,y] [--deps T-1,T-2]')
raise SystemExit('usage: new (feature|task) "Title" [--prio P1] [--status todo] [--under ID | --section Backlog] [--effort 4h] [--tags x,y] [--deps T-1,T-2] [--id T-42] [--base REF]')

kind_word = args[0].lower()
kind = "F" if kind_word == "feature" else ("T" if kind_word == "task" else None)
Expand All @@ -2172,6 +2198,8 @@ def cmd_new(args: List[str]):
tags = None
deps = None
milestone = None
explicit_id = None
base_ref = None

i = 2
while i < len(args):
Expand Down Expand Up @@ -2215,6 +2243,16 @@ def cmd_new(args: List[str]):
raise SystemExit("--milestone requires an id or alias (e.g., m1, alpha)")
milestone = args[i + 1]
i += 2
elif args[i] == "--id":
if i + 1 >= len(args):
raise SystemExit("--id requires an item ID (e.g., T-42)")
explicit_id = normalize_id(args[i + 1])
i += 2
elif args[i] == "--base":
if i + 1 >= len(args):
raise SystemExit("--base requires a git ref (e.g., origin/main)")
base_ref = args[i + 1]
i += 2
else:
raise SystemExit(f"Unknown arg: {args[i]}")

Expand All @@ -2224,7 +2262,22 @@ def cmd_new(args: List[str]):
raise SystemExit(f"Invalid priority '{prio}'")

lines = load()
iid = next_id(lines, kind)
base_lines = _lines_from_git_ref(base_ref) if base_ref else None

if explicit_id is not None:
if explicit_id.split("-")[0] != kind:
raise SystemExit(f"Cannot create a {kind_word} with id {explicit_id}: kind must match ({kind}-).")
if find_all_item_lines(lines, explicit_id):
raise SystemExit(f"{explicit_id} already exists; pick a free id or omit --id for the next free one.")
if base_lines is not None and find_all_item_lines(base_lines, explicit_id):
raise SystemExit(f"{explicit_id} already exists at '{base_ref}'; pick a free id.")
iid = explicit_id
else:
iid = next_id(lines, kind)
if base_lines is not None:
base_next = next_id(base_lines, kind)
if int(base_next.split("-")[1]) > int(iid.split("-")[1]):
iid = base_next

box = "x" if status == "done" else " "
suffix = f" @done={date.today()}" if status == "done" else ""
Expand Down
66 changes: 66 additions & 0 deletions tests/test_tasks_e2e.sh
Original file line number Diff line number Diff line change
Expand Up @@ -1630,6 +1630,72 @@ else
echo -e " ${RED}FAIL${NC}: --refs is read-only (ref.md unchanged)"
fi

# ============================================================
echo -e "\n${YELLOW}=== SCENARIO 53: new --id — explicit ID allocation + guards (issue #13) ===${NC}"
# ============================================================
setup_test_dir
fresh_init > /dev/null

out=$(run new feature "Explicit feature" --id F-100 --section Now)
assert_contains "Explicit feature id is normalized and used" "$out" "Created F-0100"
out=$(run new task "Explicit task" --id T-42 --under F-0100)
assert_contains "Explicit task id is used" "$out" "Created T-0042"
out=$(run show T-0042)
assert_contains "Item lives at the explicit id" "$out" "Explicit task"

# Auto-allocation continues from the explicit high-water mark.
out=$(run new task "Auto after explicit" --under F-0100)
assert_contains "Auto id continues past explicit ids" "$out" "Created T-0043"

# Guards.
out=$(run new task "Dup" --id T-42 2>&1 || true)
assert_contains "Refuses a taken --id" "$out" "already exists"
out=$(run new task "Wrong kind" --id F-7 2>&1 || true)
assert_contains "Refuses cross-kind --id" "$out" "kind must match"
out=$(run new task "Bad id" --id banana 2>&1 || true)
assert_contains "Refuses an invalid --id" "$out" "Invalid id"

out=$(run validate)
assert_contains "Validate passes after explicit ids" "$out" "OK"

# ============================================================
echo -e "\n${YELLOW}=== SCENARIO 54: new --base — ref-aware allocation, fail closed (issue #13) ===${NC}"
# ============================================================
setup_test_dir
fresh_init > /dev/null

# Build the incident shape: the ref (HEAD) knows tasks the local file doesn't.
run new feature "Alloc home" --prio P1 > /dev/null
run new task "First" --under F-0001 > /dev/null # T-0001
cp TASKS.md TASKS.fork # branch forks here
run new task "Second" --under F-0001 > /dev/null # T-0002
run new task "Third" --under F-0001 > /dev/null # T-0003
git init -q .
git add TASKS.md
git -c user.name=e2e -c user.email=e2e@test commit -q -m "base with T-0003"
mv TASKS.fork TASKS.md # local max back to T-0001

# Local-only allocation would hand out T-0002 and collide with the ref.
out=$(run new task "Base-aware" --under F-0001 --base HEAD)
assert_contains "--base allocates past the ref's max" "$out" "Created T-0004"

# --id + --base: refuse an id that is free locally but taken at the ref.
out=$(run new task "Ref dup" --id T-2 --base HEAD 2>&1 || true)
assert_contains "Refuses an --id taken at the base ref" "$out" "already exists"

# Fail closed: a bad ref is an error, not a silent local-only fallback.
out=$(run new task "Bad ref" --base no-such-ref 2>&1 || true)
assert_contains "Refuses an unresolvable ref" "$out" "cannot read"

out=$(run validate)
assert_contains "Validate passes after --base allocation" "$out" "OK"

# Fail closed outside a git repo entirely.
setup_test_dir
fresh_init > /dev/null
out=$(run new task "No repo" --base HEAD 2>&1 || true)
assert_contains "Refuses --base outside a git repository" "$out" "not a git repository"

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