Skip to content

feat(todos): move and copy to-dos, lists and groups to other projects - #976

Merged
robzolkos merged 17 commits into
filing-helperfrom
todo-moves
Oct 28, 2026
Merged

robzolkos merged 17 commits into
filing-helperfrom
todo-moves

Conversation

@robzolkos

@robzolkos robzolkos commented Oct 27, 2026 •

Copy link
Copy Markdown
Collaborator

Why

The web can move or copy a to-do, a to-do list or a group to another project. The CLI couldn't. todos move only repositioned within a project, and it gave a bare validation error when pointed at another project's list. bc3 #14188, now deployed, lets API clients file to-dos and groups into another project's lists.

What changed

  • todos move <id>... --to-project <project> --to-list <list> moves to-dos to another project, and todos copy <id>... copies them, optionally with --include-comments. Both take several ids, as the web's bulk Move and Copy do. Loose to-dos work too.
  • todolists move|copy <id> --to-project … handles both kinds:
    • A top-level list goes into a project's To-dos (--to-todoset picks one).
    • A group goes into a to-do list (--to-list) and stays a group.
    • The same commands are on every alias group (todolist, todolistgroups, tlgroup, tlgroups, todolistgroup).
  • Passing another project's list with --list now refuses and names the --to-project command to use.
  • Every move and copy command refuses a container flag it inherits but would ignore, before anything is sent. todos copy 456 --todolist 901 used to copy into the to-do's own list and report success. One guard, guardFilingFlags, declares per command which container flags it reads; it replaces the separate refusals for todolistgroups copy --list, cards copy --card-table and files move --vault/--folder. A test walks the whole command tree and runs every move or copy command with every container flag it does not read.
  • Bulk reports each item and exits non-zero if any didn't finish. It only says to re-run items Basecamp refused, never a copy that was made or may have been.
  • Copy recovery for to-dos, lists, groups and cards points at one listing, recordings list <Type> --in … --status … --all. It finds the copy whatever its state. A copy keeps its original's dates, so the hint says to look for the original's title in the destination rather than at the top of the list.
  • todolists show <id> no longer needs a project. It reads the list by its account-wide id.

Scope

  • This is stacked on refactor(commands): share the move and copy flow across recording types #975, the shared filing helper. Every other family (messages, Docs & Files, columns, schedule, check-ins, forwards, tools) comes in the next PR.
  • Existing same-project todos move --list, todolists move and group repositioning behave as before, except that a container flag they ignored is now refused (todos move --to 1 --todolist …, todolistgroups position --list …).

Verification

  • bin/ci passes.
  • Against a local bc3 with #14188, these all landed where expected when read back:
    • to-dos moved and copied across projects by name, and a bulk move;
    • loose to-dos moved and copied;
    • lists and groups moved and copied;
    • an old id plus its new id refused before anything was filed.
  • The recovery listing found a copied completed to-do and a copied archived group.
  • An independent GPT-6.1-sol review converged after 5 rounds. Each finding it raised has a test that fails without the fix.

Summary by cubic

Enables moving and copying to-dos, to-do lists, groups, messages, Docs & Files items, card table columns, schedule entries, check-in questions, email forwards, and dock tools to another project, matching the web's Move and Copy actions. Builds on the shared filing helper (#975) and bc3 #14188, now deployed, which lets API clients file recordings into another project's lists and tools.

New Features

  • todos move <id>... --to-project --to-list moves to-dos to another project; todos copy <id>... copies them with optional --include-comments. Both accept several ids and work for loose to-dos.
  • todolists move|copy <id> --to-project files a top-level list into a project's To-dos (--to-todoset picks one) and a group into a to-do list (--to-list), keeping it a group; all group aliases reach the same commands.
  • messages, files, cards column, schedule, checkins question, and forwards gain move and copy with --to-project and their own --to-<container> flags; several ids file one by one, and files copy --to-folder targets a nested folder.
  • tools move <id> --to-project moves a whole dock tool with everything in it; tools are not copied. tools move --position still reorders the dock.
  • filings show <id> reads a move or copy that is still running when the command's wait ends first.
  • Container flags a command would ignore are refused before anything is sent, with the command's own --to-<container> flags as the hint; a recording named twice by its old and new ids is refused before anything is filed.
  • Bulk operations report every item and fail the command if any didn't finish; only Basecamp-refused items are re-runnable, and a 401 keeps the remedy for the credential that failed.
  • Copy recovery lists recordings list <Type> --in … --status … --all unsorted and says to find the copy by the original's title, since a copy keeps its source's dates; where no listing shows the copy's status, the hint names the destination's app_url. Every command a filing prints carries the run's --profile and --account.
  • todolists show, files show, schedule show, cards column show, checkins question show, and forwards show no longer require a project; show commands follow up on the id their read returns.

Written for commit 01bd61f. Summary will update on new commits.

View guided diff Turn on auto-fix

Copilot AI balanced review requested due to automatic review settings October 27, 2026 23:42
@robzolkos
robzolkos requested a review from a team as a code owner October 27, 2026 23:42
@github-actions github-actions Bot added commands CLI command implementations tests Tests (unit and e2e) docs labels Oct 27, 2026

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Recovery after an ambiguous copy failure omits account/profile flags, potentially checking the wrong account and missing a copy that was already created.

3 open issues
What changed in this PR

Extends the shared filing flow from #975 to support moving and copying to-dos, lists, and groups across projects.

Changes:

  • Adds destination selection, bulk to-do filing, and copy commands across list/group aliases.
  • Reports partial failures and provides recovery guidance without encouraging duplicate copies.
  • Adds tests, command snapshots, and API coverage documentation; removes the project requirement from todolists show.

[!TIP]
If you aren't ready for review, convert to a draft PR.
Click "Convert to draft" or run gh pr ready --undo.
Click "Ready for review" or run gh pr ready to reengage.

File Description
skill-evals/​cases/​todos-move-copy-project.yml Checks cross-project to-do move syntax.
skill-evals/​cases/​todos-move-copy-list.yml Checks list move syntax.
skill-evals/​cases/​todos-move-copy-group.yml Checks group move destinations.
skill-evals/​cases/​todos-move-copy-copy.yml Checks copying with comments.
skill-evals/​cases/​todos-move-copy-bulk.yml Checks bulk move syntax.
internal/​commands/​todos.go Adds bulk move/copy and destination validation.
internal/​commands/​todos_test.go Updates cross-project guard expectations.
internal/​commands/​todos_filing_test.go Tests filing, bulk outcomes, and recovery.
internal/​commands/​todolists.go Adds list/group filing and project-free reads.
internal/​commands/​todolists_filing_test.go Tests destinations, aliases, and copy behavior.
internal/​commands/​todolistgroups.go Exposes group filing commands and flags.
internal/​commands/​todolistgroups_test.go Adapts reposition tests to constructor changes.
internal/​commands/​filing.go Adds bulk outcome reporting and recovery listings.
internal/​commands/​commands.go Registers copy actions in the catalog.
internal/​commands/​cards.go Uses the shared copy-recovery listing.
internal/​commands/​cards_filing_test.go Updates recovery-hint expectations.
e2e/​smoke/​smoke_todos_write.bats Adds to-do and list copy smoke tests.
e2e/​smoke/​smoke_todolistgroups_write.bats Adds a group copy smoke test.
e2e/​errors.bats Updates the missing-argument expectation.
API-COVERAGE.md Documents expanded filing coverage.
.surface-breaking Records replaced single-item argument signatures.
.surface Captures new commands, flags, and arguments.

Review effort: Balanced

Comment thread internal/commands/filing.go Outdated
Comment thread internal/commands/filing.go
Comment thread internal/commands/todolists.go Outdated
Copilot AI previously approved these changes Oct 28, 2026

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Approved

No active review issues remain for the cross-project move and copy changes.

No open issues 🎉

3 resolved since last review

Review effort: Balanced

Copilot AI previously approved these changes Oct 28, 2026

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Approved

No active review issues are reported for the cross-project move and copy changes.

No open issues 🎉

Review effort: Balanced

@robzolkos
robzolkos requested a balanced review from Copilot October 28, 2026 03:10
Copilot AI dismissed their stale review, a newer Copilot review was requested October 28, 2026 03:10
Copilot AI previously approved these changes Oct 28, 2026

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Approved

No active review issues remain for the cross-project move and copy changes.

No open issues 🎉

Review effort: Balanced

robzolkos added a commit that referenced this pull request Oct 28, 2026
…er spell its plural

A cross-project to-do list move into a project with two To-dos tools
refused with "Dest has 2 To-doss that match": the To-dos container set
no plural, so the message appended "s" to "To-dos". The container came
in with todo-moves (#976); it is fixed here, at the top of the stack,
rather than on that branch.

The container now says "To-dos tools". The fallback that appended "s"
is gone: every filingContainer spells its plural out, and a test reads
the package's source and fails on any filingContainer literal without
one, so the next container cannot fall into the same message.
… passing

todos move (todos position) gains --to-project and --to-list, which file
to-dos into a to-do list in any project through Basecamp's recording
moves, as the web's Move… does. --to-list takes a list ID, URL, or a name
looked up in --to-project; --to-project alone needs a project with one
list, and several lists or a name two lists share are refused with their
IDs. A group or an archived list is refused, as Basecamp refuses it, and so
is the list the to-do is already in (or the list holding its group), with
the reorder command as the hint. Several to-dos file one by one, as the
web's bulk Move does: every one is read before any is filed, each is
reported, and any failure fails the command naming what moved by its new id.

A --list naming a list in another project used to reach the reposition
endpoint, which older Basecamp answered with success while nothing moved
and current Basecamp answers with a bare 422. The command now reads the
to-do and the list first and refuses, naming the todos move --to-project
--to-list command that does it. Reordering, and moving to another list in
the same project with --to/--list, are unchanged; --to with several to-dos
is refused instead of silently using the first.

The shared filing gains fileRecording, which files one recording and
returns what it made, and runFilings, which files several and reports each.
todos copy files copies of to-dos into a to-do list, as the web's Copy…
does, with the same --to-project and --to-list as todos move. With no
destination each copy goes into its to-do's own list; a to-do in a group
is copied into the group's list, since Basecamp files into lists, not
groups. --include-comments copies the comments too. Several to-dos copy one
by one and each is reported; no copy is ever retried, and one that fails
without a 4xx says where to look before copying again.
todolists move (todolists position) and todolistgroups move (todolistgroups
position) gain --to-project, --to-todoset and --to-list, which file a list
or a group through Basecamp's recording moves, as the web's Move… does.
Which it is decides where it goes: a top-level list into a project's To-dos
(the only enabled one, or --to-todoset when there are several), a group into
a to-do list, where it stays a group. --to-list for a list and --to-todoset
for a group are refused with the flag that fits, as are the reorder flags
alongside them and the group's own --list. Moving onto the To-dos or list
it is already in is refused with the reorder command. A project with its
To-dos turned off is named, with the tools enable command.

Every alias group (todolist, todolistgroup, tlgroups, tlgroup) reaches the
same command. Reordering without the new flags is unchanged.
todolists copy, reached from every to-do list and group alias, files a copy
of a list or a group with its to-dos, as the web's Copy… does. A list goes
into a project's To-dos, a group into a to-do list, with the same flags as
todolists move; with no destination the copy goes next to the original.
--include-comments copies the comments too, and a copy is never retried.
… evals

.surface takes the new flags and copy leaves. todos move/position/reorder
now take several IDs, so their old single-ID ARG lines are acknowledged in
.surface-breaking, and the e2e usage error now reads "<id|url>... required".
API-COVERAGE lists the to-do, list and group filings under recordings moves
and copies. Smoke covers todos copy, todolists copy and todolistgroups copy
with same-project copies on QA data, trashing the to-do and list copies.
Skill-eval cases ask, in plain words, to move a to-do across projects by
name, copy one with comments, move three at once, move a group and move a
list.
A bulk copy labelled a copy that was made but not read back, or one that
may have started, as failed, and its hint said to run the failed ones
again, which makes another copy. Each filing now reports how far it got:
filed, filed but not read back, not settled, or refused before filing.
The hint tells the agent not to repeat the first three, naming each, and
offers a rerun only for the refusals, by id.
A loose to-do sits on its project's To-dos rather than in a list, and
reading its parent as a to-do list answered 404 before anything was
filed. A loose to-do now has no list to exclude, so it moves or copies
into any list, as Basecamp allows. A copy with no destination has no
list of its own to go into, so it is refused with the --to-list command
to run.
A moved to-do's old id reads as its new one, so after a move from 111 to
222, copying 111 and 222 filed to-do 222 twice: the duplicate check
compared the ids as typed. The check now compares the to-dos read before
anything is filed, and names both arguments.
A copy keeps the source's completion and status, and the list commands show
only incomplete, active items one page at a time unless asked. The hint now
asks for every page in the source's state, so a copy that did land is found.
…tatus

Patching each list command for completed, archived and trashed copies kept
missing a state. Basecamp's recordings listing filters by type and status,
so a copy of a to-do, list, group or card is found newest first whatever
state it was made in.
todolists show read the list by its account-wide ID but still demanded a
project, so the filing recovery that points at it failed with no default
project configured.
todolistgroups copy 311 --list 901 copied the group next to the
original and ignored --list, so a caller who meant list 901 got a
copy somewhere else and a success. --list names the group's own
list on that command group, so a filing now refuses it whether or
not a destination is set, with the --to-list hint, before anything
is read or created. cards copy already refuses --card-table the
same way.
A copy run with --account or --profile that failed ambiguously told
the caller to check a recordings listing that, pasted into a fresh
shell, ran against the default profile and account. It could miss a
copy Basecamp had made and invite a second one.

The filing helper now takes the follow-up scope, read before the
command resolves its account as the template copy does, and adds it
to every command it prints: the copy recovery listing, the show
commands in failure, still-running and not-read-back hints, the bulk
report, and the breadcrumbs of what it filed. runFiling and
runFilings require the scope, so a family that files through them
cannot leave it out.
A 401 while filing several to-dos ended in a report that kept only
each failure's text, so App.Err no longer saw the SDK error and left
the bare "Run: basecamp auth login" in the hint. Under --profile work
that logs into the default profile; under BASECAMP_TOKEN no login
helps.

runFilings now settles the remedy on each failure before the report
drops the cause, through App.WithAuthRemedy, which App.Err already
used. The report stays non-retryable.

A 401 does not stop the remaining to-dos. Every to-do is read before
any is filed, so a rejected credential fails there, before the loop;
one rejected mid-run is refused before anything is filed for each
later to-do too, and the report already asks to run those again.
…ates

A copy whose start got no answer told the caller to list the
destination's recordings sorted by created_at, newest first, as if the
copy would top the list. Basecamp's Recording::Copier copies created_at
from the original, so the copy sorts where the original would, and on
a busy list or card table the caller looked at the top, saw nothing,
and could copy again.

The hint now lists the destination's recordings in the source's status
without a sort, then says to look for the original's title in the
destination container by name and id. The --profile and --account
scope stays on the listing command, ahead of that sentence.
todos copy 456 --todolist 901 copied the to-do into its own list and
reported success: --todolist is a root flag todos copy never reads.
todolistgroups copy --list failed the same way and was fixed on its
own, as were cards copy --card-table and files move --vault.

Every command that moves or copies recordings now goes through
guardFilingFlags, which names the container flags the command
inherits and reads. Any other one that was set, the root's --todolist
or a flag the command's group adds (--card-table, --vault, --folder,
--list), is refused before anything is read or sent, with the
command's own --to-<container> flags as the hint. The three one-off
refusals go. --project and --in stay with each command, which holds a
filed recording to them.

The same-project modes are covered too: todos move --to 1 --todolist
and todolistgroups position --list, which ignored the flag, now refuse
it. cards move and cards column move read --card-table, so they keep it.

The test walks the whole command tree: every move or copy command
declares what it reads, and each container flag it inherits but does
not read is refused with no request sent.
robzolkos added a commit that referenced this pull request Oct 28, 2026
…er spell its plural

A cross-project to-do list move into a project with two To-dos tools
refused with "Dest has 2 To-doss that match": the To-dos container set
no plural, so the message appended "s" to "To-dos". The container came
in with todo-moves (#976); it is fixed here, at the top of the stack,
rather than on that branch.

The container now says "To-dos tools". The fallback that appended "s"
is gone: every filingContainer spells its plural out, and a test reads
the package's source and fails on any filingContainer literal without
one, so the next container cannot fall into the same message.
@robzolkos
robzolkos requested a balanced review from Copilot October 28, 2026 05:08
Copilot AI dismissed their stale review, a newer Copilot review was requested October 28, 2026 05:08

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Approved

No active review issues are identified for the cross-project move and copy changes.

No open issues 🎉

Review effort: Balanced

…orwards to other projects (#977)

* Generalize the recording filing for every family the web moves and copies

The next families (messages, Docs & Files, columns, schedule entries,
check-in questions, email forwards) file the same way cards and to-do
lists do, into a tool on a project's dock. The filing helper grows what
they share:

- resolveFilingTarget finds the container named by --to-project and the
  family's own --to-<container> flag: a project's only enabled tool of the
  kind, one picked by ID or title, or an ID or URL alone. Ambiguous and
  duplicate titles list the choices; a project without one is named, and a
  disabled one says how to turn it on. The To-dos tool a to-do list goes
  into is found the same way, so resolveFilingTodoset is now a container
  description over it.
- filingFamily describes a family as data (its noun, types, URL kinds,
  destination, read), and newFilingCmd builds its move and copy commands
  from it: own-container flags are refused, a move onto the container the
  recording is in is refused, a copy with no destination goes onto it.
  Several recordings go through runFilings, as to-dos do.
- A filing carries parent_id for a folder inside Docs & Files, and
  parseFilingRefOf accepts a URL of several kinds.

* Move and copy messages to another project's message board

messages move <id|url> --to-project <project> [--to-board <board>] files
a message onto a message board in another project, as the web's Move...
does, and messages copy does the same as Copy..., onto the message's own
board when no destination is given, with --include-comments.

Both wait for Basecamp's background filing and print the new message
with its source_id. A move onto the board the message is on, the group's
--message-board (which names the message's own board), an id that reads
back as another type, and a project the --in flag contradicts are
refused before anything is sent. The old id redirects to the new one,
so the helper's move-still-running hint holds for messages.

* Move and copy Docs & Files items to another project

files move <id|url>... --to-project <project> [--to-folder <folder>]
files documents, uploads, cloud files, Google documents and folders into
another project's Docs & Files, as the web's Move... does, and files copy
does the same as Copy..., into the original's own folder when no
destination is given. Several IDs file one by one, as the web's bulk
Move/Copy does, and the run fails naming what each became if any failed.

--to-folder takes any folder by ID or URL, nested ones included: the
filing goes to the project's Docs & Files with the folder as parent_id,
found by walking the folder's parents to the docked vault. With
--to-project it also takes a top-level folder's title, and a number that
names no folder is read as a title, since folders can be called 2026.

files move --to keeps moving within the project in place, unchanged. A
--to folder in another project now points at --to-folder instead of the
web's Move menu, and --to-folder in the item's own project points back at
--to, which keeps the item's ID. The same commands are on uploads, docs,
vaults and their aliases, so an agent reaching for any of them lands on
them. The old ids of moved documents and uploads redirect, so the
move-still-running hint holds.

* Move and copy card table columns to another project

cards column move <id|url> --to-project <project> [--to-card-table
<table>] moves a column with its cards onto a card table in another
project, as the web's Move... does, and cards column copy does the same
as Copy..., onto the column's own table when no destination is given.

--position keeps repositioning a column within its table, unchanged, and
cannot be combined with the new flags. Triage, Not now, Done and On hold,
which Basecamp refuses to file alone, are refused before the request
with a pointer at moving their cards; moving onto the column's own table
points at --position. The moved column's old id redirects, so the
move-still-running hint holds.

* Move and copy schedule entries to another project's schedule

schedule move <id|url> --to-project <project> [--to-schedule <schedule>]
moves an event onto a schedule in another project, as the web's Move...
does, and schedule copy does the same as Copy..., onto the event's own
schedule when no destination is given.

A recurring event moves and copies as a whole series. Basecamp answers
its read with an occurrence (redirecting, and minting one), whose parent
is the event, so the event files by that parent and the output names the
new event by its id with the occurrence read as occurrence_id; its own
schedule is its project's. An occurrence URL files its series. The group's
--schedule, which names the event's own schedule, is refused. The old id
of a moved event redirects, so the move-still-running hint holds.

* Move and copy check-in questions to another project's check-ins

checkins question move <id|url> --to-project <project>
[--to-questionnaire <id|url>] moves a question, with its answers, into
another project's Automatic Check-ins, as the web's Move... does, and
checkins question copy does the same as Copy..., into the question's own
Automatic Check-ins when no destination is given.

The group's --questionnaire, which names the question's own check-ins,
is refused, as is a move into the check-ins the question is in. The old
id of a moved question redirects, so the move-still-running hint holds.

* Move and copy email forwards to another project's Email Forwards

forwards move <id|url> --to-project <project> [--to-inbox <id|url>]
moves an email forward, with its replies, into another project's Email
Forwards, as the web's Move... does, and forwards copy does the same as
Copy..., into the forward's own Email Forwards when no destination is
given.

Email Forwards must be turned on in the destination. When it is off, the
command says so and names the tools enable command that turns it on,
rather than reporting that the project has none. The group's --inbox,
which names the forward's own Email Forwards, is refused, as is a move
into the one it is in. The old id of a moved forward redirects, so the
move-still-running hint holds.

* Move a whole dock tool to another project with tools move --to-project

tools move <id|url> --to-project <project> moves a Message Board, Card
Table, To-dos, Docs & Files, Schedule, Automatic Check-ins, Chat or Email
Forwards, with everything in it, to another project's dock, as the web's
Move... does, sending destination_project_id. Tools are not copied, as on
the web.

tools move was an alias of tools reposition. It is now its own command:
with --position (and no --to-project) it still reorders the dock exactly
as before, and the two cannot be combined. Doors, which Basecamp refuses
to move, a nested folder that is not on the dock, a move to the tool's
own project, and an old id whose tool already moved are refused before
the request.

A moved tool's old id does not redirect: /dock/tools/<old> reads it as
deleted. So the filing gains movedHint, and a tool move still running
points at the destination project's dock rather than at tools show
<old id>, which would never find it.

* Refuse a recording named twice in any bulk move or copy

files copy 456 456 --to-project Dest made two copies, and naming a moved
file by its old id beside its new one filed it twice: only to-dos checked
for duplicates. The check to-dos made, comparing the recordings each
argument read as rather than the arguments themselves, is now
filingNames, shared by to-dos and every family's move and copy, and runs
before anything is filed.

* Find a copied cloud file or Google document, and every copy in any status

files show probed only folders, uploads and documents, and files list
lists only those, so a cloud file or Google document that a move or copy
made could not be shown by the breadcrumb the command printed, and the
"check before copying again" listing left the copy out. files show now
also reads cloud files and Google documents (and takes them as --type).
A copy of a cloud file is looked for in its project's timeline, every
page, where it shows as reposted. No API listing or timeline shows a
Google document, so a copy of one points at its folder's app_url to
check in Basecamp.

A copy keeps its source's status, and the listings the other families
pointed at showed only active items one page at a time. Messages, Docs &
Files items and schedule entries now point at recordingCopyLookFor in the
source's status. Check-in questions and email forwards, which the
recordings listing does not serve, ask for every page of their own
listing, or the timeline for an archived one, which their listing leaves
out. The Docs & Files help no longer tells agents to copy again what
"failed": only what Basecamp refused.

* File a recurring event from the schedule it is actually on

A recurring event's read answers with an occurrence, which names the event
but not its schedule, and the schedule was taken from the project's dock
through /projects/<bucket>.json. An employee calendar is a Calendar bucket,
not a project, so a recurring calendar event could not be moved or
copied, and filing a recurring event into a calendar failed at the
read-back after Basecamp had already moved it. A project with several
schedules gave no schedule, so a copy with no destination was refused.

seriesSchedule now reads a calendar's schedule from the calendar, and of
a project's several schedules picks the one whose entries list the event.
A copy into a calendar is looked for in that schedule's entries, since
the recordings listing does not serve calendars.

* Refuse a --to-folder title shared by the Docs & Files and a folder

--to-folder "<title>" with --to-project matched the Docs & Files' own
title before looking at its folders, so a folder named like the tool
was never picked and the item silently landed at the top. The Docs &
Files is now one candidate among the top-level folders: one match files
there, several are refused listing each ID, the top marked as such.

* Follow up every item a bulk move or copy filed, not only the first

With no destination, files copy puts each copy in its own original's
folder, so a bulk run can land items in several folders. The summary
already names where each landed; the breadcrumbs came from the first
item alone, which pointed the next step at one folder. They now gather
each filed item's follow-ups, without repeats.

* Find an archived recurring series' schedule among several

A recurring event's read names no schedule, so with several schedules in
its project the series' schedule is the one whose entries list it. Those
listings show active entries unless asked for another status, so an
archived (or trashed) series was found on none of them, and copying or
moving it without a destination failed with "Could not tell which
schedule". The lookup now lists entries in the event's own status.

* Look for a drafted copy among drafts, not in an unsupported listing

A copy keeps its source's status, and the copy recovery hints listed the
destination's recordings in that status. The recordings listing serves
only active, archived and trashed, so a drafted message's or document's
copy that timed out pointed at a command Basecamp answers with 400.
recordingCopyLookFor now emits only those three statuses and sends a
drafted message, document or upload to basecamp drafts list --all, where
the copy shows. No API listing shows a drafted schedule entry, so its
copy, on a project schedule or a calendar, is to be checked in Basecamp.

* Show a filed record by its ID without a project

files show, schedule show, cards column show, checkins question show and
forwards show read the record by its account-wide ID but still demanded
a project, so the move and copy hints that point at them ("once it
lands, … show <id> returns it", "show it with: …") failed with
"Project ID required" when no default project was configured. Each now
reads by ID alone and takes the project its breadcrumbs name from the
record, as todolists show does.

* Follow up a moved record on the id its show read back

Reading a moved record's old id redirects to the record at its new id, but
cards column show, files show, schedule show, checkins question show,
forwards show, messages show and todolists show still named the requested
old id in their follow-ups and comment reads, and the old id takes no
writes. Each now uses the id the read returned, as cards show and todos show
already do; a recurring event's follow-ups name the event, not the
occurrence its read answers with.

* Add filings show to read a move or copy by its filing

A move or copy runs in the background, and when the command's wait ends
first there was no way to read where it got to. filings show reads
GET /filings/:id with no project and reports the status, type and source,
and once completed the recording it made with the command that shows it.
A failed move reads as safe to try again, as Basecamp's docs say a move is;
a failed copy does not, since the docs say nothing of what one leaves
behind.

* Check a still-running move or copy by its filing, not by a listing

Three review rounds in a row found a copy-recovery listing that missed some
status for some type. When a move or copy is still running as the wait
ends, Basecamp has already named its filing, which says where it got to
whatever the recording's type and status, so every family's in_progress
hint now names it and says to check it with basecamp filings show <id>.
That replaces the per-type listings there, the moved-tool dock hint and the
copy landing text.

Listings remain only where no filing is known: a copy whose start request
went unanswered. Columns, check-in questions and email forwards keep their
own listings for an active copy and the timeline for an archived question
or forward; where no listing shows the copy's status (an archived column, a
trashed question or forward) the hint says to open the destination in
Basecamp at its app_url.

* Find an unanswered copy from one table of where each copy shows

A copy whose start got no answer is checked for before copying again, and
each family chose its own listing for that. Four review rounds each found a
type and status whose listing could not show the copy; the latest was a
trashed cloud file sent to the timeline, which leaves out trashed
recordings. Checking the rest against bc3 found another: the timeline never
shows a copied email forward, archived or not.

copyListings in filing.go now holds every answer: for a recordable type, the
status the copy keeps, and whether it is on a calendar, a listing known to
show that copy, with the reason beside each listing. Every other place, a
new type or status included, says to open the destination in Basecamp at its
app_url, which for a nested folder is now the folder's own. The families'
lookFor functions are gone.

TestEveryCopyRecoveryCanShowTheCopy walks every type each family files in
every status, on a project and a calendar, and accepts only the app_url form
or a listing whose own evidence covers that place.

* Refuse a destination URL that names nothing, never reading it as a title

files copy --to-project Dest --to-folder https://3.basecampapi.com/99999/vaults/2026.json
filed into Dest's folder titled "2026" when vault 2026 answered 404: the
fallback to a title was kept from URLs that name a project, and a flat API
URL names none. The same reading happened without any 404 in the two other
resolvers that take an ID, URL or title with --to-project: a URL's id was
matched against dock tool titles (--to-board, --to-schedule,
--to-questionnaire, --to-inbox, --to-todoset, a column's --to-card-table)
and against card table titles (a card's --to-card-table).

A URL now names only an id. When that id is not a destination, the command
fails with not_found naming the URL, and files nothing.

* Tell a copy apart by its title, since it keeps its original's dates

Basecamp's copier keeps the original's created_at and updated_at (read
back live for a to-do, cloud file, question and email forward), so the
recovery listing's --sort created_at --direction desc never put the copy
first, and reading its top row as the copy was wrong. The listing drops the
sort, and every recovery, listing or app_url, ends with what to match on:
the original's title in the container the copy lands in.

* Say once in a bulk line not to move or copy a still-running item again

A still-running item's bulk line read "Do not move #11 again yet. Do not
move it again. Check it with: basecamp filings show 11…": the bulk prefix
repeated what the filing's own hint already says. A line whose hint already
says it (a still-running filing's) now names the item and gives that hint:
"#11: Do not move it again. Check it with: basecamp filings show 11…".
Other unsettled lines keep their prefix.

* Carry --profile and --account into what filings show prints

A move or copy still running when its wait ended pointed at basecamp
filings show <id>, and that command's own follow-ups (check again, or
show the record it made) named neither profile nor account. Pasted
into a fresh shell they read the default account, where the filing is
not found, and a caller could take that as nothing having started.

filings show now reads the follow-up scope before resolving its
account, as every filing command does, and adds it to its breadcrumbs.
The still-running hints already carry it, and each listing in the copy
recovery table carries it ahead of the title to look for; the
destination-page fallback needs none. The follow-ups test covers a
message family's copy recovery and a still-running move and copy, and
the table enumeration checks the scope on every listing.

* Take a moved file's kind from what its old id reads back as

files show <old id> of a moved document reported a folder. Basecamp
redirects every kind's read of a moved item's old id to the item at
its new id, so the folder read files show tried first answered with
the document, decoded as a vault. The show dropped the document's
content and advertised a folder-contents breadcrumb, and moved items
are read by their old id whenever a caller follows up on a move.

Auto-detection now goes through getFileItem, which the move and copy
commands already use: it tries each kind and keeps the one whose
record comes back as that kind.

* Send a drafted copy missing from drafts list to the destination too

A drafted message, document or upload whose copy got no answer was
looked for in basecamp drafts list --all, which holds only the 250
most recently updated drafts. A copy keeps its original's updated_at,
so the copy of an older draft can sit past that cap: the listing
showed nothing, and a caller could copy again.

A row of copyListings can now be marked capped. The drafts row is, and
its hint adds that a copy missing from the listing is checked for on
the destination's page before copying again. The table enumeration
asserts that only the drafts listing carries it.

* Refuse a container flag every web-parity move or copy would ignore

Each family's move and copy refused the group flags in its own
ownFlags list (--message-board, --schedule, --inbox, --questionnaire,
--vault/--folder, --card-table) and let the root's --todolist through,
which messages copy ignored and still reported success.

newFilingCmd and tools move now go through guardFilingFlags, which
refuses every container flag a command inherits but does not read, so
ownFlags and its loop go. cards column move reads --card-table to
reposition a column, so it keeps a refusal of that flag when moving to
another card table, shared with cards move.

The guard's table test now covers all 28 move and copy commands.

* Name a calendar event's schedule in its entries follow-up

schedule show of an event in an employee calendar suggested
schedule entries --project <calendar>, which looks for the schedule on
a dock the calendar does not have, and fails. The follow-up now names
the schedule: the event's parent, or for a recurring event, whose
parent is its series, the schedule the calendar links to. A project's
events keep the follow-up as it was.

* Ask projects show for a project ID in tools move's help and hints

tools move's help, agent notes and two refusals sent agents to
basecamp projects show <project>, but projects show takes only a
numeric project ID. In a live agent eval, agents passed project names
there and failed five times.

The help and notes now say projects show <id> and point at
basecamp projects list for the ID. The refusal of a recording that is
not a dock tool names its project's ID, which the read already has. The
refusal of a deleted or moved tool cannot know where the tool went, so
it says <id> and names projects list too.

* Say a card table is turned off, not missing, on a cross-project card move

cards move and copy with --to-project into a project whose card table
is turned off said the project has no card table and to add one there
first, so an agent following it would make a second card table. Every
other family's move and copy says the tool is turned off and how to
turn it back on with basecamp tools enable.

resolveFilingTable, written for cards before the shared filing helper
existed, now goes through resolveFilingTarget with the card table
container that cards column move already uses, then reads the card
table in full as before. Cards get the shared refusals: turned off
versus absent, ambiguous and unmatched names. Naming a card table by ID
or URL alone now reads it twice, once to place it and once in full.

* Name several To-dos tools without a doubled s, and make every container spell its plural

A cross-project to-do list move into a project with two To-dos tools
refused with "Dest has 2 To-doss that match": the To-dos container set
no plural, so the message appended "s" to "To-dos". The container came
in with todo-moves (#976); it is fixed here, at the top of the stack,
rather than on that branch.

The container now says "To-dos tools". The fallback that appended "s"
is gone: every filingContainer spells its plural out, and a test reads
the package's source and fails on any filingContainer literal without
one, so the next container cannot fall into the same message.

* Say what the schedule commands do when a project has several schedules

The schedule group's agent notes said each project has one schedule.
A project can have several Schedule tools, and the live agent eval met
one. With one, the commands find it; with several, they refuse and list
the schedule IDs to pass with --schedule, or --to-schedule for move and
copy. The notes now say so.

* Name the schedule in an event's entries command whenever its parent is one

A project can have several Schedule tools, so after a --to-schedule move the
suggested `schedule entries --project P` failed with "Multiple schedules
found". The calendar-only lookup for a recurring event's schedule is unchanged.
@robzolkos
robzolkos merged commit 8ab7620 into filing-helper Oct 28, 2026
25 checks passed
@robzolkos
robzolkos deleted the todo-moves branch October 28, 2026 05:28
robzolkos added a commit that referenced this pull request Oct 28, 2026
…es (#975)

* Extract the move and copy flow into a shared recording filing

Cards move and copy start a filing through Basecamp's generic recording
moves and copies, wait for it, and read back what it made. None of that
is specific to cards, and the next families (to-dos, messages, Docs &
Files, schedule entries, dock tools) file the same way.

recordingFiling now carries one filing as data: kind, source id and noun,
destination container or (for a dock tool) destination project, include
comments, and the per-type strings and read-back hook. runFiling owns the
flow and its invariants: start once, a copy that fails without a 4xx or
rate limit is reported non-retryable with where to look, a bounded wait,
an in_progress error whose cause cannot turn retryable, and a read-back
that names the new id when it fails. parseFilingRef and the project
conflict check move with it; the conflict check takes the noun.

Cards keep their output, errors, hints and exit codes unchanged.

* feat(todos): move and copy to-dos, lists and groups to other projects (#976)

* Move to-dos into a list in another project, and stop a foreign --list passing

todos move (todos position) gains --to-project and --to-list, which file
to-dos into a to-do list in any project through Basecamp's recording
moves, as the web's Move… does. --to-list takes a list ID, URL, or a name
looked up in --to-project; --to-project alone needs a project with one
list, and several lists or a name two lists share are refused with their
IDs. A group or an archived list is refused, as Basecamp refuses it, and so
is the list the to-do is already in (or the list holding its group), with
the reorder command as the hint. Several to-dos file one by one, as the
web's bulk Move does: every one is read before any is filed, each is
reported, and any failure fails the command naming what moved by its new id.

A --list naming a list in another project used to reach the reposition
endpoint, which older Basecamp answered with success while nothing moved
and current Basecamp answers with a bare 422. The command now reads the
to-do and the list first and refuses, naming the todos move --to-project
--to-list command that does it. Reordering, and moving to another list in
the same project with --to/--list, are unchanged; --to with several to-dos
is refused instead of silently using the first.

The shared filing gains fileRecording, which files one recording and
returns what it made, and runFilings, which files several and reports each.

* Copy to-dos into a list in this or another project

todos copy files copies of to-dos into a to-do list, as the web's Copy…
does, with the same --to-project and --to-list as todos move. With no
destination each copy goes into its to-do's own list; a to-do in a group
is copied into the group's list, since Basecamp files into lists, not
groups. --include-comments copies the comments too. Several to-dos copy one
by one and each is reported; no copy is ever retried, and one that fails
without a 4xx says where to look before copying again.

* Move a to-do list or group to another project

todolists move (todolists position) and todolistgroups move (todolistgroups
position) gain --to-project, --to-todoset and --to-list, which file a list
or a group through Basecamp's recording moves, as the web's Move… does.
Which it is decides where it goes: a top-level list into a project's To-dos
(the only enabled one, or --to-todoset when there are several), a group into
a to-do list, where it stays a group. --to-list for a list and --to-todoset
for a group are refused with the flag that fits, as are the reorder flags
alongside them and the group's own --list. Moving onto the To-dos or list
it is already in is refused with the reorder command. A project with its
To-dos turned off is named, with the tools enable command.

Every alias group (todolist, todolistgroup, tlgroups, tlgroup) reaches the
same command. Reordering without the new flags is unchanged.

* Copy a to-do list or group to this or another project

todolists copy, reached from every to-do list and group alias, files a copy
of a list or a group with its to-dos, as the web's Copy… does. A list goes
into a project's To-dos, a group into a to-do list, with the same flags as
todolists move; with no destination the copy goes next to the original.
--include-comments copies the comments too, and a copy is never retried.

* Record the to-do moves and copies in the surface, coverage, smoke and evals

.surface takes the new flags and copy leaves. todos move/position/reorder
now take several IDs, so their old single-ID ARG lines are acknowledged in
.surface-breaking, and the e2e usage error now reads "<id|url>... required".
API-COVERAGE lists the to-do, list and group filings under recordings moves
and copies. Smoke covers todos copy, todolists copy and todolistgroups copy
with same-project copies on QA data, trashing the to-do and list copies.
Skill-eval cases ask, in plain words, to move a to-do across projects by
name, copy one with comments, move three at once, move a group and move a
list.

* Ask to run a bulk move or copy again only for what Basecamp refused

A bulk copy labelled a copy that was made but not read back, or one that
may have started, as failed, and its hint said to run the failed ones
again, which makes another copy. Each filing now reports how far it got:
filed, filed but not read back, not settled, or refused before filing.
The hint tells the agent not to repeat the first three, naming each, and
offers a rerun only for the refusals, by id.

* Move and copy loose to-dos into a list

A loose to-do sits on its project's To-dos rather than in a list, and
reading its parent as a to-do list answered 404 before anything was
filed. A loose to-do now has no list to exclude, so it moves or copies
into any list, as Basecamp allows. A copy with no destination has no
list of its own to go into, so it is refused with the --to-list command
to run.

* Refuse a to-do named twice by its old and new ids

A moved to-do's old id reads as its new one, so after a move from 111 to
222, copying 111 and 222 filed to-do 222 twice: the duplicate check
compared the ids as typed. The check now compares the to-dos read before
anything is filed, and names both arguments.

* Point copy recovery at a listing that shows the copy

A copy keeps the source's completion and status, and the list commands show
only incomplete, active items one page at a time unless asked. The hint now
asks for every page in the source's state, so a copy that did land is found.

* Point every copy recovery at one listing that finds the copy in any status

Patching each list command for completed, archived and trashed copies kept
missing a state. Basecamp's recordings listing filters by type and status,
so a copy of a to-do, list, group or card is found newest first whatever
state it was made in.

* Show a to-do list by its ID without a project

todolists show read the list by its account-wide ID but still demanded a
project, so the filing recovery that points at it failed with no default
project configured.

* Refuse a group's --list on a copy with no destination

todolistgroups copy 311 --list 901 copied the group next to the
original and ignored --list, so a caller who meant list 901 got a
copy somewhere else and a success. --list names the group's own
list on that command group, so a filing now refuses it whether or
not a destination is set, with the --to-list hint, before anything
is read or created. cards copy already refuses --card-table the
same way.

* Carry --profile and --account into every command a filing prints

A copy run with --account or --profile that failed ambiguously told
the caller to check a recordings listing that, pasted into a fresh
shell, ran against the default profile and account. It could miss a
copy Basecamp had made and invite a second one.

The filing helper now takes the follow-up scope, read before the
command resolves its account as the template copy does, and adds it
to every command it prints: the copy recovery listing, the show
commands in failure, still-running and not-read-back hints, the bulk
report, and the breadcrumbs of what it filed. runFiling and
runFilings require the scope, so a family that files through them
cannot leave it out.

* Keep the auth remedy for the credential sent when several filings fail

A 401 while filing several to-dos ended in a report that kept only
each failure's text, so App.Err no longer saw the SDK error and left
the bare "Run: basecamp auth login" in the hint. Under --profile work
that logs into the default profile; under BASECAMP_TOKEN no login
helps.

runFilings now settles the remedy on each failure before the report
drops the cause, through App.WithAuthRemedy, which App.Err already
used. The report stays non-retryable.

A 401 does not stop the remaining to-dos. Every to-do is read before
any is filed, so a rejected credential fails there, before the loop;
one rejected mid-run is refused before anything is filed for each
later to-do too, and the report already asks to run those again.

* Find an unanswered copy by its title, since it keeps its original's dates

A copy whose start got no answer told the caller to list the
destination's recordings sorted by created_at, newest first, as if the
copy would top the list. Basecamp's Recording::Copier copies created_at
from the original, so the copy sorts where the original would, and on
a busy list or card table the caller looked at the top, saw nothing,
and could copy again.

The hint now lists the destination's recordings in the source's status
without a sort, then says to look for the original's title in the
destination container by name and id. The --profile and --account
scope stays on the listing command, ahead of that sentence.

* Refuse a container flag that a move or copy would ignore

todos copy 456 --todolist 901 copied the to-do into its own list and
reported success: --todolist is a root flag todos copy never reads.
todolistgroups copy --list failed the same way and was fixed on its
own, as were cards copy --card-table and files move --vault.

Every command that moves or copies recordings now goes through
guardFilingFlags, which names the container flags the command
inherits and reads. Any other one that was set, the root's --todolist
or a flag the command's group adds (--card-table, --vault, --folder,
--list), is refused before anything is read or sent, with the
command's own --to-<container> flags as the hint. The three one-off
refusals go. --project and --in stay with each command, which holds a
filed recording to them.

The same-project modes are covered too: todos move --to 1 --todolist
and todolistgroups position --list, which ignored the flag, now refuse
it. cards move and cards column move read --card-table, so they keep it.

The test walks the whole command tree: every move or copy command
declares what it reads, and each container flag it inherits but does
not read is refused with no request sent.

* feat: move and copy messages, files, columns, events, check-ins and forwards to other projects (#977)

* Generalize the recording filing for every family the web moves and copies

The next families (messages, Docs & Files, columns, schedule entries,
check-in questions, email forwards) file the same way cards and to-do
lists do, into a tool on a project's dock. The filing helper grows what
they share:

- resolveFilingTarget finds the container named by --to-project and the
  family's own --to-<container> flag: a project's only enabled tool of the
  kind, one picked by ID or title, or an ID or URL alone. Ambiguous and
  duplicate titles list the choices; a project without one is named, and a
  disabled one says how to turn it on. The To-dos tool a to-do list goes
  into is found the same way, so resolveFilingTodoset is now a container
  description over it.
- filingFamily describes a family as data (its noun, types, URL kinds,
  destination, read), and newFilingCmd builds its move and copy commands
  from it: own-container flags are refused, a move onto the container the
  recording is in is refused, a copy with no destination goes onto it.
  Several recordings go through runFilings, as to-dos do.
- A filing carries parent_id for a folder inside Docs & Files, and
  parseFilingRefOf accepts a URL of several kinds.

* Move and copy messages to another project's message board

messages move <id|url> --to-project <project> [--to-board <board>] files
a message onto a message board in another project, as the web's Move...
does, and messages copy does the same as Copy..., onto the message's own
board when no destination is given, with --include-comments.

Both wait for Basecamp's background filing and print the new message
with its source_id. A move onto the board the message is on, the group's
--message-board (which names the message's own board), an id that reads
back as another type, and a project the --in flag contradicts are
refused before anything is sent. The old id redirects to the new one,
so the helper's move-still-running hint holds for messages.

* Move and copy Docs & Files items to another project

files move <id|url>... --to-project <project> [--to-folder <folder>]
files documents, uploads, cloud files, Google documents and folders into
another project's Docs & Files, as the web's Move... does, and files copy
does the same as Copy..., into the original's own folder when no
destination is given. Several IDs file one by one, as the web's bulk
Move/Copy does, and the run fails naming what each became if any failed.

--to-folder takes any folder by ID or URL, nested ones included: the
filing goes to the project's Docs & Files with the folder as parent_id,
found by walking the folder's parents to the docked vault. With
--to-project it also takes a top-level folder's title, and a number that
names no folder is read as a title, since folders can be called 2026.

files move --to keeps moving within the project in place, unchanged. A
--to folder in another project now points at --to-folder instead of the
web's Move menu, and --to-folder in the item's own project points back at
--to, which keeps the item's ID. The same commands are on uploads, docs,
vaults and their aliases, so an agent reaching for any of them lands on
them. The old ids of moved documents and uploads redirect, so the
move-still-running hint holds.

* Move and copy card table columns to another project

cards column move <id|url> --to-project <project> [--to-card-table
<table>] moves a column with its cards onto a card table in another
project, as the web's Move... does, and cards column copy does the same
as Copy..., onto the column's own table when no destination is given.

--position keeps repositioning a column within its table, unchanged, and
cannot be combined with the new flags. Triage, Not now, Done and On hold,
which Basecamp refuses to file alone, are refused before the request
with a pointer at moving their cards; moving onto the column's own table
points at --position. The moved column's old id redirects, so the
move-still-running hint holds.

* Move and copy schedule entries to another project's schedule

schedule move <id|url> --to-project <project> [--to-schedule <schedule>]
moves an event onto a schedule in another project, as the web's Move...
does, and schedule copy does the same as Copy..., onto the event's own
schedule when no destination is given.

A recurring event moves and copies as a whole series. Basecamp answers
its read with an occurrence (redirecting, and minting one), whose parent
is the event, so the event files by that parent and the output names the
new event by its id with the occurrence read as occurrence_id; its own
schedule is its project's. An occurrence URL files its series. The group's
--schedule, which names the event's own schedule, is refused. The old id
of a moved event redirects, so the move-still-running hint holds.

* Move and copy check-in questions to another project's check-ins

checkins question move <id|url> --to-project <project>
[--to-questionnaire <id|url>] moves a question, with its answers, into
another project's Automatic Check-ins, as the web's Move... does, and
checkins question copy does the same as Copy..., into the question's own
Automatic Check-ins when no destination is given.

The group's --questionnaire, which names the question's own check-ins,
is refused, as is a move into the check-ins the question is in. The old
id of a moved question redirects, so the move-still-running hint holds.

* Move and copy email forwards to another project's Email Forwards

forwards move <id|url> --to-project <project> [--to-inbox <id|url>]
moves an email forward, with its replies, into another project's Email
Forwards, as the web's Move... does, and forwards copy does the same as
Copy..., into the forward's own Email Forwards when no destination is
given.

Email Forwards must be turned on in the destination. When it is off, the
command says so and names the tools enable command that turns it on,
rather than reporting that the project has none. The group's --inbox,
which names the forward's own Email Forwards, is refused, as is a move
into the one it is in. The old id of a moved forward redirects, so the
move-still-running hint holds.

* Move a whole dock tool to another project with tools move --to-project

tools move <id|url> --to-project <project> moves a Message Board, Card
Table, To-dos, Docs & Files, Schedule, Automatic Check-ins, Chat or Email
Forwards, with everything in it, to another project's dock, as the web's
Move... does, sending destination_project_id. Tools are not copied, as on
the web.

tools move was an alias of tools reposition. It is now its own command:
with --position (and no --to-project) it still reorders the dock exactly
as before, and the two cannot be combined. Doors, which Basecamp refuses
to move, a nested folder that is not on the dock, a move to the tool's
own project, and an old id whose tool already moved are refused before
the request.

A moved tool's old id does not redirect: /dock/tools/<old> reads it as
deleted. So the filing gains movedHint, and a tool move still running
points at the destination project's dock rather than at tools show
<old id>, which would never find it.

* Refuse a recording named twice in any bulk move or copy

files copy 456 456 --to-project Dest made two copies, and naming a moved
file by its old id beside its new one filed it twice: only to-dos checked
for duplicates. The check to-dos made, comparing the recordings each
argument read as rather than the arguments themselves, is now
filingNames, shared by to-dos and every family's move and copy, and runs
before anything is filed.

* Find a copied cloud file or Google document, and every copy in any status

files show probed only folders, uploads and documents, and files list
lists only those, so a cloud file or Google document that a move or copy
made could not be shown by the breadcrumb the command printed, and the
"check before copying again" listing left the copy out. files show now
also reads cloud files and Google documents (and takes them as --type).
A copy of a cloud file is looked for in its project's timeline, every
page, where it shows as reposted. No API listing or timeline shows a
Google document, so a copy of one points at its folder's app_url to
check in Basecamp.

A copy keeps its source's status, and the listings the other families
pointed at showed only active items one page at a time. Messages, Docs &
Files items and schedule entries now point at recordingCopyLookFor in the
source's status. Check-in questions and email forwards, which the
recordings listing does not serve, ask for every page of their own
listing, or the timeline for an archived one, which their listing leaves
out. The Docs & Files help no longer tells agents to copy again what
"failed": only what Basecamp refused.

* File a recurring event from the schedule it is actually on

A recurring event's read answers with an occurrence, which names the event
but not its schedule, and the schedule was taken from the project's dock
through /projects/<bucket>.json. An employee calendar is a Calendar bucket,
not a project, so a recurring calendar event could not be moved or
copied, and filing a recurring event into a calendar failed at the
read-back after Basecamp had already moved it. A project with several
schedules gave no schedule, so a copy with no destination was refused.

seriesSchedule now reads a calendar's schedule from the calendar, and of
a project's several schedules picks the one whose entries list the event.
A copy into a calendar is looked for in that schedule's entries, since
the recordings listing does not serve calendars.

* Refuse a --to-folder title shared by the Docs & Files and a folder

--to-folder "<title>" with --to-project matched the Docs & Files' own
title before looking at its folders, so a folder named like the tool
was never picked and the item silently landed at the top. The Docs &
Files is now one candidate among the top-level folders: one match files
there, several are refused listing each ID, the top marked as such.

* Follow up every item a bulk move or copy filed, not only the first

With no destination, files copy puts each copy in its own original's
folder, so a bulk run can land items in several folders. The summary
already names where each landed; the breadcrumbs came from the first
item alone, which pointed the next step at one folder. They now gather
each filed item's follow-ups, without repeats.

* Find an archived recurring series' schedule among several

A recurring event's read names no schedule, so with several schedules in
its project the series' schedule is the one whose entries list it. Those
listings show active entries unless asked for another status, so an
archived (or trashed) series was found on none of them, and copying or
moving it without a destination failed with "Could not tell which
schedule". The lookup now lists entries in the event's own status.

* Look for a drafted copy among drafts, not in an unsupported listing

A copy keeps its source's status, and the copy recovery hints listed the
destination's recordings in that status. The recordings listing serves
only active, archived and trashed, so a drafted message's or document's
copy that timed out pointed at a command Basecamp answers with 400.
recordingCopyLookFor now emits only those three statuses and sends a
drafted message, document or upload to basecamp drafts list --all, where
the copy shows. No API listing shows a drafted schedule entry, so its
copy, on a project schedule or a calendar, is to be checked in Basecamp.

* Show a filed record by its ID without a project

files show, schedule show, cards column show, checkins question show and
forwards show read the record by its account-wide ID but still demanded
a project, so the move and copy hints that point at them ("once it
lands, … show <id> returns it", "show it with: …") failed with
"Project ID required" when no default project was configured. Each now
reads by ID alone and takes the project its breadcrumbs name from the
record, as todolists show does.

* Follow up a moved record on the id its show read back

Reading a moved record's old id redirects to the record at its new id, but
cards column show, files show, schedule show, checkins question show,
forwards show, messages show and todolists show still named the requested
old id in their follow-ups and comment reads, and the old id takes no
writes. Each now uses the id the read returned, as cards show and todos show
already do; a recurring event's follow-ups name the event, not the
occurrence its read answers with.

* Add filings show to read a move or copy by its filing

A move or copy runs in the background, and when the command's wait ends
first there was no way to read where it got to. filings show reads
GET /filings/:id with no project and reports the status, type and source,
and once completed the recording it made with the command that shows it.
A failed move reads as safe to try again, as Basecamp's docs say a move is;
a failed copy does not, since the docs say nothing of what one leaves
behind.

* Check a still-running move or copy by its filing, not by a listing

Three review rounds in a row found a copy-recovery listing that missed some
status for some type. When a move or copy is still running as the wait
ends, Basecamp has already named its filing, which says where it got to
whatever the recording's type and status, so every family's in_progress
hint now names it and says to check it with basecamp filings show <id>.
That replaces the per-type listings there, the moved-tool dock hint and the
copy landing text.

Listings remain only where no filing is known: a copy whose start request
went unanswered. Columns, check-in questions and email forwards keep their
own listings for an active copy and the timeline for an archived question
or forward; where no listing shows the copy's status (an archived column, a
trashed question or forward) the hint says to open the destination in
Basecamp at its app_url.

* Find an unanswered copy from one table of where each copy shows

A copy whose start got no answer is checked for before copying again, and
each family chose its own listing for that. Four review rounds each found a
type and status whose listing could not show the copy; the latest was a
trashed cloud file sent to the timeline, which leaves out trashed
recordings. Checking the rest against bc3 found another: the timeline never
shows a copied email forward, archived or not.

copyListings in filing.go now holds every answer: for a recordable type, the
status the copy keeps, and whether it is on a calendar, a listing known to
show that copy, with the reason beside each listing. Every other place, a
new type or status included, says to open the destination in Basecamp at its
app_url, which for a nested folder is now the folder's own. The families'
lookFor functions are gone.

TestEveryCopyRecoveryCanShowTheCopy walks every type each family files in
every status, on a project and a calendar, and accepts only the app_url form
or a listing whose own evidence covers that place.

* Refuse a destination URL that names nothing, never reading it as a title

files copy --to-project Dest --to-folder https://3.basecampapi.com/99999/vaults/2026.json
filed into Dest's folder titled "2026" when vault 2026 answered 404: the
fallback to a title was kept from URLs that name a project, and a flat API
URL names none. The same reading happened without any 404 in the two other
resolvers that take an ID, URL or title with --to-project: a URL's id was
matched against dock tool titles (--to-board, --to-schedule,
--to-questionnaire, --to-inbox, --to-todoset, a column's --to-card-table)
and against card table titles (a card's --to-card-table).

A URL now names only an id. When that id is not a destination, the command
fails with not_found naming the URL, and files nothing.

* Tell a copy apart by its title, since it keeps its original's dates

Basecamp's copier keeps the original's created_at and updated_at (read
back live for a to-do, cloud file, question and email forward), so the
recovery listing's --sort created_at --direction desc never put the copy
first, and reading its top row as the copy was wrong. The listing drops the
sort, and every recovery, listing or app_url, ends with what to match on:
the original's title in the container the copy lands in.

* Say once in a bulk line not to move or copy a still-running item again

A still-running item's bulk line read "Do not move #11 again yet. Do not
move it again. Check it with: basecamp filings show 11…": the bulk prefix
repeated what the filing's own hint already says. A line whose hint already
says it (a still-running filing's) now names the item and gives that hint:
"#11: Do not move it again. Check it with: basecamp filings show 11…".
Other unsettled lines keep their prefix.

* Carry --profile and --account into what filings show prints

A move or copy still running when its wait ended pointed at basecamp
filings show <id>, and that command's own follow-ups (check again, or
show the record it made) named neither profile nor account. Pasted
into a fresh shell they read the default account, where the filing is
not found, and a caller could take that as nothing having started.

filings show now reads the follow-up scope before resolving its
account, as every filing command does, and adds it to its breadcrumbs.
The still-running hints already carry it, and each listing in the copy
recovery table carries it ahead of the title to look for; the
destination-page fallback needs none. The follow-ups test covers a
message family's copy recovery and a still-running move and copy, and
the table enumeration checks the scope on every listing.

* Take a moved file's kind from what its old id reads back as

files show <old id> of a moved document reported a folder. Basecamp
redirects every kind's read of a moved item's old id to the item at
its new id, so the folder read files show tried first answered with
the document, decoded as a vault. The show dropped the document's
content and advertised a folder-contents breadcrumb, and moved items
are read by their old id whenever a caller follows up on a move.

Auto-detection now goes through getFileItem, which the move and copy
commands already use: it tries each kind and keeps the one whose
record comes back as that kind.

* Send a drafted copy missing from drafts list to the destination too

A drafted message, document or upload whose copy got no answer was
looked for in basecamp drafts list --all, which holds only the 250
most recently updated drafts. A copy keeps its original's updated_at,
so the copy of an older draft can sit past that cap: the listing
showed nothing, and a caller could copy again.

A row of copyListings can now be marked capped. The drafts row is, and
its hint adds that a copy missing from the listing is checked for on
the destination's page before copying again. The table enumeration
asserts that only the drafts listing carries it.

* Refuse a container flag every web-parity move or copy would ignore

Each family's move and copy refused the group flags in its own
ownFlags list (--message-board, --schedule, --inbox, --questionnaire,
--vault/--folder, --card-table) and let the root's --todolist through,
which messages copy ignored and still reported success.

newFilingCmd and tools move now go through guardFilingFlags, which
refuses every container flag a command inherits but does not read, so
ownFlags and its loop go. cards column move reads --card-table to
reposition a column, so it keeps a refusal of that flag when moving to
another card table, shared with cards move.

The guard's table test now covers all 28 move and copy commands.

* Name a calendar event's schedule in its entries follow-up

schedule show of an event in an employee calendar suggested
schedule entries --project <calendar>, which looks for the schedule on
a dock the calendar does not have, and fails. The follow-up now names
the schedule: the event's parent, or for a recurring event, whose
parent is its series, the schedule the calendar links to. A project's
events keep the follow-up as it was.

* Ask projects show for a project ID in tools move's help and hints

tools move's help, agent notes and two refusals sent agents to
basecamp projects show <project>, but projects show takes only a
numeric project ID. In a live agent eval, agents passed project names
there and failed five times.

The help and notes now say projects show <id> and point at
basecamp projects list for the ID. The refusal of a recording that is
not a dock tool names its project's ID, which the read already has. The
refusal of a deleted or moved tool cannot know where the tool went, so
it says <id> and names projects list too.

* Say a card table is turned off, not missing, on a cross-project card move

cards move and copy with --to-project into a project whose card table
is turned off said the project has no card table and to add one there
first, so an agent following it would make a second card table. Every
other family's move and copy says the tool is turned off and how to
turn it back on with basecamp tools enable.

resolveFilingTable, written for cards before the shared filing helper
existed, now goes through resolveFilingTarget with the card table
container that cards column move already uses, then reads the card
table in full as before. Cards get the shared refusals: turned off
versus absent, ambiguous and unmatched names. Naming a card table by ID
or URL alone now reads it twice, once to place it and once in full.

* Name several To-dos tools without a doubled s, and make every container spell its plural

A cross-project to-do list move into a project with two To-dos tools
refused with "Dest has 2 To-doss that match": the To-dos container set
no plural, so the message appended "s" to "To-dos". The container came
in with todo-moves (#976); it is fixed here, at the top of the stack,
rather than on that branch.

The container now says "To-dos tools". The fallback that appended "s"
is gone: every filingContainer spells its plural out, and a test reads
the package's source and fails on any filingContainer literal without
one, so the next container cannot fall into the same message.

* Say what the schedule commands do when a project has several schedules

The schedule group's agent notes said each project has one schedule.
A project can have several Schedule tools, and the live agent eval met
one. With one, the commands find it; with several, they refuse and list
the schedule IDs to pass with --schedule, or --to-schedule for move and
copy. The notes now say so.

* Name the schedule in an event's entries command whenever its parent is one

A project can have several Schedule tools, so after a --to-schedule move the
suggested `schedule entries --project P` failed with "Multiple schedules
found". The calendar-only lookup for a recurring event's schedule is unchanged.
robzolkos added a commit that referenced this pull request Oct 28, 2026
Lands the CLI moves stack as one change:

- #973 cards move --to-project/--to-card-table, cards copy, wormhole moves answer the card's new id
- #975 one shared move and copy flow for every recording type
- #976 todos move --to-project/--to-list (bulk), todos copy, todolists and todolistgroups move/copy
- #977 messages, Docs & Files (bulk, --to-folder), card table columns, schedule entries, check-in questions, email forwards, whole tools; basecamp filings show; a guard refusing container flags a move or copy would ignore
- #974 projects logo set/remove

Pins basecamp-sdk v0.31.0.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

commands CLI command implementations docs tests Tests (unit and e2e)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants