Skip to content

task-done: one canonical body + tracker modules, deleting 7 near-identical copies (#236) - #244

Merged
martin-conur merged 3 commits into
mainfrom
task/loadout-hook-inversion
Oct 2, 2026
Merged

martin-conur merged 3 commits into
mainfrom
task/loadout-hook-inversion

Conversation

@martin-conur

@martin-conur martin-conur commented Oct 2, 2026 •

Copy link
Copy Markdown
Owner

Closes #236. Epic #122 Phase 2, first half. Sub-issue #237 (task-work) builds on the module loader and parity suite this adds.

What changed

task-done was a three-variant file wearing seven copies:

4fafb54a  claude-gh == claude-notion == kiro-gh == kiro-notion
2bfd7c11  claude-local == kiro-local
8557e738  claude-jira    — 7 lines different from claude-gh

1525 lines expressing ~249 distinct ones. It is now one canonical bin/task-done composing one tracker module.

before after
task-done files 7 1 + 4 tracker modules (2 of which override nothing)
lines 1525 ~250 canonical + ~90 modules
check-drift groups 35 25
tests run 1380 1362 (32 retired, 14 added)

Net −1021 lines across 31 files.

The part that genuinely differed was the part with no sentinel on it

The gh pr view / gh pr create block (claude-gh/bin/task-done:87-97) is the only tracker-specific code in the file, and it sat between two drift-guarded regions while being guarded by neither — so jira's Jira-key uppercasing could have broken in one copy with nothing to say so. Separately, the local board-regen region was nested inside confirm-and-cleanup, and extract_region (tools/check-drift.sh:52) is a plain awk window: the outer extract swallowed the inner sentinels and body, and passed only because both local copies happened to be identical.

There is no agent axis, and that shaped the design

diff claude-gh/bin/task-done kiro-gh/bin/task-done was empty; neither copy ever mentioned claude or kiro-cli. Removing a worktree, sweeping radio state and closing a tab does not depend on which agent was in the tab.

So a loadout is {tracker × agent} and the modules are keyed on the axes, not the combo — lib/trackers/{gh,jira,notion,local}.sh × lib/agents/{claude,kiro}.sh, four files and two, not seven of each. #122's body says "per-loadout hooks.sh" in one place and "{tracker module × agent module}: 4 + 2 small files" in another; the second is right. Per-loadout hooks would have kept the file count and kept the gh verbs duplicated between claude-gh and kiro-gh. kiro-jira (#92) now reuses jira.sh × kiro.sh with zero new module files for this tool.

lib/trackers/_default.sh holds the behaviour the four byte-identical copies actually had, so gh.sh and notion.sh override nothing rather than restating an identical PR section — otherwise seven copies would merely have become four.

No behaviour changes — including one the spec said was unavoidable

The spec declared the aw_regenerate_board sibling-fallback resolution as an accepted change. It was wrong: lib/board-regen.sh:30 defaults self to ${BASH_SOURCE[1]}, which from a tracker module resolves to a file with no task-board beside it, so the sibling fallback silently finds nothing. The two local board tests failed on the first run. aw_tracker_post_cleanup now takes the impl and names that loadout's own task-done, keeping $PATH-first-then-sibling resolution byte-identical. #238 moves that line when it consolidates the two task-board copies.

Everything else is preserved byte-for-byte: jira's uppercasing, local's five-step usage text, every message and exit code.

How the port was verified

Two steps, on purpose. First retarget all 67 task_done cases at the canonical script with AW_IMPL and confirm 1392/1392 green — proving the port behaviour-preserving while the duplication was still there. Only then retire. So behaviour preservation is an observation, not a hope.

tests/loadout_modules.bats (14 new cases) proves every module LOADS. Derived from aw_all_impls, never a second copy of the list. It fails on a missing file, on an orphan file, and on a module that loads but forgot a hook.

tests/task_done.bats proves the right one RAN. That is #206's lesson restated — a region can be byte-identical and still inert, and structural parity cannot see it.

Both halves were demonstrated to fail, not assumed to:

$ mv lib/trackers/notion.sh /tmp/            # 4 parity cases fail, 37 behavioural
$ touch lib/trackers/linear.sh               # the orphan assertion fails

That demonstration is the floor that makes a falling test count trustworthy; a retirement rule without it is an honour system.

Test count: 1380 → 1362

32 of the 67 task_done cases asserted shared-body behaviour two or three times over, once per loadout. Each was retired only where an identical assertion survives. Note the raw diff shows 51 removed @test lines — 19 of those are the kiro: → shared: rename, not retirements.

Retired, with surviving counterpart

retired survives as
claude-jira: reviewer worktree force-deletes branch (PR_NUMBER set) claude-gh: reviewer worktree force-deletes branch (PR_NUMBER set)
claude-local: --remove-worktree sweeps its own radio mailbox claude-gh: --remove-worktree sweeps its own radio mailbox
claude-local: ambient PR_NUMBER export does NOT trigger force-delete on worker claude-gh: ambient PR_NUMBER export does NOT trigger force-delete on worker
claude-local: reviewer worktree force-deletes branch (PR_NUMBER set) claude-gh: reviewer worktree force-deletes branch (PR_NUMBER set)
claude-notion: --remove-worktree --force skips all prompts shared: --remove-worktree --force skips all prompts
claude-notion: --remove-worktree alone exits 0 without reading stdin shared: --remove-worktree alone exits 0 without reading stdin
claude-notion: --remove-worktree skips PR section shared: --remove-worktree skips PR section
claude-notion: deletes .info file after removal shared: deletes .info file after removal
claude-notion: deletes local branch when fully merged (no new commits) shared: deletes local branch when fully merged (no new commits)
claude-notion: fails when run from main repo shared: fails when run from main repo
claude-notion: keeps local branch when it has unmerged commits shared: keeps local branch when it has unmerged commits
claude-notion: no prompt when --force is set shared: no prompt when --force is set
claude-notion: reads custom BASE_BRANCH from .info file shared: reads custom BASE_BRANCH from .info file
claude-notion: removes worktree containing initialized submodules without warning shared: removes worktree containing initialized submodules without warning
claude-notion: removes worktree directory shared: removes worktree directory
claude-notion: reviewer worktree force-deletes branch (PR_NUMBER set) claude-gh: reviewer worktree force-deletes branch (PR_NUMBER set)
claude-notion: shows branch and base branch shared: shows branch and base branch
claude-notion: shows commit count ahead of base shared: shows commit count ahead of base
claude-notion: shows existing PR URL instead of create command shared: shows existing PR URL instead of create command
claude-notion: shows gh pr create with correct --base when no PR exists shared: shows gh pr create with correct --base when no PR exists
claude-notion: skips zellij close-tab when no radio session (no \$ZELLIJ env) shared: skips zellij close-tab when no radio session (no \$ZELLIJ env)
claude-notion: warns about uncommitted changes shared: warns about uncommitted changes
jira: --remove-worktree alone exits 0 without reading stdin shared: --remove-worktree alone exits 0 without reading stdin
jira: --remove-worktree skips PR section shared: --remove-worktree skips PR section
jira: deletes local branch when fully merged (no new commits) shared: deletes local branch when fully merged (no new commits)
jira: fails when run from main repo shared: fails when run from main repo
jira: keeps local branch when it has unmerged commits shared: keeps local branch when it has unmerged commits
jira: removes worktree containing initialized submodules without warning shared: removes worktree containing initialized submodules without warning
jira: shows branch and base branch shared: shows branch and base branch
kiro-gh: reviewer worktree force-deletes branch (PR_NUMBER set) claude-gh: reviewer worktree force-deletes branch (PR_NUMBER set)
kiro-local: reviewer worktree force-deletes branch (PR_NUMBER set) claude-gh: reviewer worktree force-deletes branch (PR_NUMBER set)
kiro: (kiro-notion) reviewer worktree force-deletes branch (PR_NUMBER set) claude-gh: reviewer worktree force-deletes branch (PR_NUMBER set)

Kept, because it distinguishes an impl

  • jira: PR title uppercases Jira key slug / jira: PR title uses raw slug for non-Jira branches — the only tracker override in the file.
  • claude-local: / kiro-local: task-done regenerates tasks/_board.md — the local module's board + state.json work.
  • <impl>: task-done unregisters the radio session — deliberately kept at seven rows. It is the only place every impl runs the full script end to end, so it is what would catch a module that loads but never fires. A new combo must add a row.

Surviving total: 35 (19 shared: + 16 impl-specific).

Green

./run_tests.sh          exit 0 — 1362 ok, 0 not ok   (bats --count agrees: 1362)
tools/check-drift.sh    25 group(s) checked  (was 35)
shellcheck -x           clean over the full CI glob + lib/trackers/ + lib/agents/

.github/workflows/ci.yml needed one fix: the lint job globbed lib/*.sh, which would never have reached the new subdirectories. Both are now linted.

Also in this PR

The install label in all seven installers: task-done (shared dispatcher) is no longer true, so it reads (canonical + tracker modules) with the explanatory comment updated. The install-shared-symlinks stanza is drift-guarded, so an incomplete edit would have failed CI; verified all seven identical.

Upgrading: no task-init re-run needed. All seven installers already symlink $SCRIPT_DIR/../bin/task-done and never their own copy, so no installer-written artifact changes.

🤖 Generated with Claude Code


Review round 1 (commit fd4d254)

Three items from the clean-with-nits verdict, all fixed in this PR per the tight-PR norm.

1. Stale comment (tests/task_done_dispatcher.bats:56) — said "kiro-notion's task-done prints branch/base lines", true pre-#236 and not after. Now says what the test pins: detection reached the notion module and the shared body produced those lines.

2. Test count parity. The two durable records disagreed — PR body 1380 → 1360, CHANGELOG 1381 → 1361. Nets agreed at −20, absolute baselines did not. Cause found rather than a side picked: bats --count and grep -c '^@test' differ by one, because tests/radio_home_isolation.bats:105 has an @test inside a heredoc that writes a generated probe.bats fixture — grep counts a test bats never runs. The measured count is authoritative; the CHANGELOG now names the tool and shows the arithmetic.

3. The call-site double error — not the nit it looked like. Now two lines:

TRACKER_MODULE=$(aw_tracker_module "$AW_ROOT" "$TRACKER") || exit 1
source "$TRACKER_MODULE"

Why the existing test missed it: aw_tracker_module names the missing file rather than letting source fail passed either way, because it exercised the helper directly and never went through the call site — a name promising more than it checked. Both helper tests are renamed to say they test the helper, and two new cases run the real bin/task-done against a checkout with a module removed and refute the shell noise (one for gh, one sweeping all four trackers).

Both were verified to fail against the old call site before landing — and the first draft did not. It asserted source: : not found, the message the review described, rather than what bash actually emits:

task-done: line 69: : No such file or directory

So the first draft passed against the very call site it was written to reject — the same defect the review was asking me to fix, reproduced inside the fix. Corrected, then confirmed 14/14 with the fix and tests 13–14 failing without it.

Counts after this round

1380 baseline − 32 retired + 14 added = 1362
bats --count tests/*.bats             = 1362
./run_tests.sh                        exit 0 — 1362 ok, 0 not ok
tools/check-drift.sh                  25 group(s) checked

One scope note: the call-site fix changes stderr on a broken checkout, so it does not widen the "no behaviour changes" claim, which is relative to pre-#236 where no module existed to be missing. That distinction is recorded in the CHANGELOG rather than glossed.

@martin-conur

Copy link
Copy Markdown
Owner Author

Spec compliance

Issue #236 is fully satisfied. Checking every deliverable from the spec:

Deliverable Status
lib/detect-impl.sh: aw_tracker_module / aw_agent_module + aw_all_trackers / aw_all_agents ✅
lib/trackers/_default.sh, gh.sh, jira.sh, local.sh, notion.sh ✅
lib/agents/claude.sh, kiro.sh (empty, for parity coverage) ✅
bin/task-done: canonical body replacing the exec dispatcher ✅
Seven */bin/task-done copies deleted ✅
tools/check-drift.sh: 10 task-done entries retired → 25 groups ✅
tests/helpers/common.bash: 6 *_TASK_DONE vars retired ✅
tests/loadout_modules.bats: new parity suite ✅
tests/task_done.bats + tests/task_done_close_tab.bats: retargeted ✅
README.md + CHANGELOG.md updated ✅
CI glob fix (lib/*.sh → also lib/trackers/*.sh lib/agents/*.sh) ✅
CHANGELOG: "no task-init re-run needed" stated explicitly ✅

Spec had one acknowledged accepted-change that the implementation then designed out: aw_regenerate_board's ${BASH_SOURCE[1]} default would resolve to local.sh rather than a task-done sibling, silently finding no task-board there. The fix — passing the impl's own task-done as the self arg — keeps the resolution byte-identical. The two local board tests caught it on the first run, which is exactly how a behaviour-preserving refactor is supposed to go. ✅

Code-review findings

Correctness: CI ran and passed on head SHA 877671e (workflow CI, conclusion success). Exit 0, 1360 tests. No correctness blockers found.

Nit 1 — stale comment in tests/task_done_dispatcher.bats:56

# kiro-notion's task-done prints branch/base lines

This was accurate pre-#236, but the canonical bin/task-done prints those lines now, not the kiro-notion copy. Fix: # canonical bin/task-done prints branch/base lines. The test below it is correct; the comment just says the wrong thing.

Nit 2 — test count off-by-one between PR body and CHANGELOG

PR body table: 1380 → 1360 (net −20). CHANGELOG: 1381 to 1361 (net −20). The nets agree; the absolute numbers differ by 1. Likely the baseline was measured before vs. after one test was renamed. No coverage consequence, but the two authoritative places in the repo should agree on the same numbers.

Observation (UX, not a bug) — double error on missing module

bin/task-done composes the tracker module as:

source "$(aw_tracker_module "$AW_ROOT" "$TRACKER")" || exit 1

When the module is absent, _aw_module prints its helpful message to stderr and exits 1. The $() captures nothing (stdout was empty), so source "" fires and adds its own bash: source: : not found noise before the || exit 1 catches it. The user sees both messages. The aw_tracker_module parity test (aw_tracker_module names the missing file rather than letting source fail) passes because it exercises the helper directly, not through the call-site pattern.

Cleaner two-liner that avoids the double error:

TRACKER_MODULE=$(aw_tracker_module "$AW_ROOT" "$TRACKER") || exit 1
source "$TRACKER_MODULE"

This is a nice-to-have, not a blocker — the helpful message still reaches the user and the script still exits.

Positive observation — two-step verification approach

Retargeting all 67 tests at the canonical script first (1392/1392 green) before retiring the duplication turns behaviour preservation from a hope into an observation. The failure demonstration (deleting notion.sh → 41 failures; adding linear.sh → orphan failure) is the floor that makes a falling test count trustworthy. This is the right way to do this class of refactor.

Verdict

clean-with-nits

Two cosmetic nits (stale comment, off-by-one in documented numbers) and one UX nicety (double error on missing module). None block merge. Fix all three in this PR if possible — they're small enough — but they don't affect correctness.

martin-conur added a commit that referenced this pull request Oct 2, 2026
…ment, count parity (#244)

Three items from the clean-with-nits review on PR #244.

1. tests/task_done_dispatcher.bats:56 said "kiro-notion's task-done prints
   branch/base lines", true pre-#236 and not after. The comment now says what
   the test pins: detection reached the notion module and the SHARED body
   produced those lines, there being exactly one task-done since #236.

2. The test count disagreed between the two durable records -- PR body
   1380 -> 1360, CHANGELOG 1381 -> 1361. Nets agreed at -20, absolute
   baselines did not, which undercuts the credibility the retirement table
   exists for. Cause found rather than a side picked: `bats --count` and
   `grep -c '^@test'` differ by one because tests/radio_home_isolation.bats:105
   has an @test inside a heredoc that writes a generated probe.bats fixture, so
   grep counts a test bats never runs. The measured count is authoritative;
   the CHANGELOG now states which tool produced it and shows the arithmetic.

3. The composition call site was
       source "$(aw_tracker_module "$AW_ROOT" "$TRACKER")" || exit 1
   which reads as resolve-then-source and is not: on a missing module the
   helper writes its diagnosis and exits 1, but the substitution has already
   yielded the empty string, so `source ""` runs and bash appends
   `: No such file or directory` naming a line in task-done before `|| exit 1`
   is reached -- inviting a reader to debug the wrong file. Now two lines.

   The part worth recording is why the existing test missed it. `aw_tracker_module
   names the missing file rather than letting source fail` passed either way,
   because it exercised the helper directly and never went through the call
   site: a name promising more than it checked, so a green suite would have
   told the next reader something untrue. Same shape as the misnamed table row
   this branch caught in its own test rename. Both helper tests are renamed to
   say they test the helper, and two new cases run the real bin/task-done
   against a checkout with a module removed and refute the shell noise -- one
   for gh, one sweeping all four trackers.

   Both were verified to FAIL against the old call site before landing, which
   the first draft did not: it asserted `source: : not found`, the message the
   review described, rather than the one bash actually emits. That draft passed
   against the very call site it was written to reject.

Counts: 1380 baseline - 32 retired + 14 added = 1362, and bats --count returns
1362. Suite exit 0, 1362 ok, 0 not ok. check-drift 25 groups. shellcheck clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
martin-conur and others added 3 commits October 2, 2026 11:36
…tical copies (#236)

task-done was a three-variant file wearing seven copies. claude-gh,
claude-notion, kiro-gh and kiro-notion were byte-identical; claude-local and
kiro-local were byte-identical to each other; claude-jira differed by seven
lines. 1525 lines expressing about 249 distinct ones, held in step by ten of
the 35 groups in tools/check-drift.sh.

The part that genuinely differed was the part with no sentinel on it: the
`gh pr view` / `gh pr create` block sat between two drift-guarded regions and
was guarded by neither, so jira's Jira-key uppercasing could have broken in one
copy unnoticed. The local board-regen region was nested inside
confirm-and-cleanup, and extract_region is a plain awk window, so the outer
extract swallowed the inner sentinels and passed only because both local copies
happened to match.

There is no agent axis here at all, and that shaped the design: removing a
worktree, sweeping radio state and closing a tab never depended on which agent
was in the tab. So a loadout is {tracker x agent} and the modules are keyed on
the axes rather than the combo -- lib/trackers/{gh,jira,notion,local}.sh and
lib/agents/{claude,kiro}.sh, four files and two, not seven of each. kiro-jira
(#92) reuses jira.sh x kiro.sh with zero new module files for this tool.

Three hooks carry it and four of the seven loadouts override none:
aw_tracker_pr_section (jira), aw_tracker_usage_steps and
aw_tracker_post_cleanup (local). lib/trackers/_default.sh holds what the four
byte-identical copies actually had, so gh.sh and notion.sh override nothing
rather than restating an identical PR section -- otherwise seven copies would
merely have become four. aw_tracker_module / aw_agent_module live in
lib/detect-impl.sh beside aw_all_impls, and aw_all_trackers / aw_all_agents
derive the axis names from that list rather than restating them.

No behaviour changes. One candidate was designed out rather than accepted:
aw_regenerate_board defaults its `self` to ${BASH_SOURCE[1]}, which from a
tracker module resolves to a file with no task-board beside it, so the sibling
fallback would silently find nothing. aw_tracker_post_cleanup now takes the
impl and names that loadout's own task-done, keeping $PATH-first-then-sibling
resolution byte-identical. The two local board tests caught it on the first run.

Tests ran 1380 -> 1360: 32 duplicated cases retired, 12 added. The port was
done in two steps on purpose -- retarget all 67 task_done cases at the
canonical script and confirm green first, then retire -- so behaviour
preservation is an observation rather than a hope. tests/loadout_modules.bats
proves every module LOADS (derived from aw_all_impls, failing on a missing
file, an orphan file, or a module missing a hook) and tests/task_done.bats
proves the right one RAN. Both were demonstrated to fail, not assumed to.

Upgrading: no task-init re-run needed. All seven installers already symlink
bin/task-done and never their own copy, so no installer-written artifact
changes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ment, count parity (#244)

Three items from the clean-with-nits review on PR #244.

1. tests/task_done_dispatcher.bats:56 said "kiro-notion's task-done prints
   branch/base lines", true pre-#236 and not after. The comment now says what
   the test pins: detection reached the notion module and the SHARED body
   produced those lines, there being exactly one task-done since #236.

2. The test count disagreed between the two durable records -- PR body
   1380 -> 1360, CHANGELOG 1381 -> 1361. Nets agreed at -20, absolute
   baselines did not, which undercuts the credibility the retirement table
   exists for. Cause found rather than a side picked: `bats --count` and
   `grep -c '^@test'` differ by one because tests/radio_home_isolation.bats:105
   has an @test inside a heredoc that writes a generated probe.bats fixture, so
   grep counts a test bats never runs. The measured count is authoritative;
   the CHANGELOG now states which tool produced it and shows the arithmetic.

3. The composition call site was
       source "$(aw_tracker_module "$AW_ROOT" "$TRACKER")" || exit 1
   which reads as resolve-then-source and is not: on a missing module the
   helper writes its diagnosis and exits 1, but the substitution has already
   yielded the empty string, so `source ""` runs and bash appends
   `: No such file or directory` naming a line in task-done before `|| exit 1`
   is reached -- inviting a reader to debug the wrong file. Now two lines.

   The part worth recording is why the existing test missed it. `aw_tracker_module
   names the missing file rather than letting source fail` passed either way,
   because it exercised the helper directly and never went through the call
   site: a name promising more than it checked, so a green suite would have
   told the next reader something untrue. Same shape as the misnamed table row
   this branch caught in its own test rename. Both helper tests are renamed to
   say they test the helper, and two new cases run the real bin/task-done
   against a checkout with a module removed and refute the shell noise -- one
   for gh, one sweeping all four trackers.

   Both were verified to FAIL against the old call site before landing, which
   the first draft did not: it asserted `source: : not found`, the message the
   review described, rather than the one bash actually emits. That draft passed
   against the very call site it was written to reject.

Counts: 1380 baseline - 32 retired + 14 added = 1362, and bats --count returns
1362. Suite exit 0, 1362 ok, 0 not ok. check-drift 25 groups. shellcheck clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…dy (#236)

Rebase onto origin/main after #243 (#242) merged first. The two PRs overlap on
nine files -- CHANGELOG.md, README.md and all 7 */bin/task-done -- and this one
DELETES those seven, so a resolution that took either side whole would have
lost a P0's work or this one's. Carried forward rather than resolved by
deletion:

1. #243's _TAB_ID_MISS block and the second skip line, which name WHICH source
   came up empty and print the `grep 'tab-id:'` that finds the launch-time log
   line. Seven copies of that became one when task-done was inverted, so it
   belongs in the canonical body.

2. Both README edits: this PR's module-shape section and #243's runbook row.
   #243's nine runbook docs -- 7 templates, README, and this repo's own
   .claude/gh-workflow.md -- are otherwise untouched; verified all nine.

3. Both CHANGELOG entries: this one under Changed, #243's under Fixed.

4. A fourth item not on the carry-forward list: #243's three task-done cases in
   tests/tab_id_capture.bats ran through $CLAUDE_GH_TASK_DONE, a helper var this
   PR retires. Retargeted to `env AW_IMPL=claude-gh "$TASK_DONE"`, the same
   treatment the other suites got. THEIR ASSERTIONS ARE UNCHANGED AND THEY PASS
   AGAINST THIS SHAPE, which is the evidence the port is faithful rather than
   plausible.

Counts move again, and the previous figures were wrong against the wrong
baseline: origin/main is now 1397 because #243 added 17 tests of its own, not
the 1380 this branch had been quoting. Measured by checking origin/main out
into a throwaway worktree and counting it rather than inferring:

  1397 baseline - 32 retired + 14 added = 1379
  bats --count tests/*.bats             = 1379
  ./run_tests.sh                        exit 0 -- 1379 ok, 0 not ok

The CHANGELOG now names the REF as well as the tool, since "1380" was correct
against the wrong baseline -- a third variant of the count-credibility problem
raised in review, after the grep-vs-bats one.

check-drift 25 groups. shellcheck clean on the full CI glob.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@martin-conur
martin-conur force-pushed the task/loadout-hook-inversion branch from fd4d254 to b8550aa Compare October 2, 2026 14:49
@martin-conur
martin-conur merged commit 454c713 into main Oct 2, 2026
4 checks passed
@martin-conur
martin-conur deleted the task/loadout-hook-inversion branch October 2, 2026 23:15
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

task-done: one canonical body + tracker modules, deleting 7 near-identical copies (#122 Phase 2A)

1 participant