diff --git a/CHANGELOG.md b/CHANGELOG.md index 3f8fcb5..c9e9b07 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 ` — 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 diff --git a/LEARNINGS.md b/LEARNINGS.md index cbe8aee..26c25bc 100644 --- a/LEARNINGS.md +++ b/LEARNINGS.md @@ -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 ` 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 diff --git a/README.md b/README.md index 5e1789e..4c516c2 100644 --- a/README.md +++ b/README.md @@ -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`) | @@ -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.) diff --git a/docs/superpowers/specs/2026-07-15-id-allocation-design.md b/docs/superpowers/specs/2026-07-15-id-allocation-design.md new file mode 100644 index 0000000..cfc01cc --- /dev/null +++ b/docs/superpowers/specs/2026-07-15-id-allocation-design.md @@ -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 `** — create with an explicit ID instead of auto-allocating. + Trivial, high-value: colliding branches can take disjoint ranges up front. +2. **`new … --base `** — 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 :`; 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 `: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. diff --git a/tasks.py b/tasks.py index 7e11696..a25189c 100755 --- a/tasks.py +++ b/tasks.py @@ -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 @@ -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 @@ -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 @@ -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) @@ -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): @@ -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]}") @@ -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 "" diff --git a/tests/test_tasks_e2e.sh b/tests/test_tasks_e2e.sh index 1b170ea..91edd08 100755 --- a/tests/test_tasks_e2e.sh +++ b/tests/test_tasks_e2e.sh @@ -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 # ============================================================