Skip to content

feat(cards): move and copy cards to other projects - #973

Merged
robzolkos merged 26 commits into
mainfrom
card-moves-cross-project
Oct 28, 2026
Merged

robzolkos merged 26 commits into
mainfrom
card-moves-cross-project

Conversation

@robzolkos

@robzolkos robzolkos commented Oct 27, 2026 •

Copy link
Copy Markdown
Collaborator

Why

On the web, a card's Move… and Copy… menus send it to a card table in any project. The CLI could only cross projects through a wormhole someone had already set up. It couldn't copy a card, and after a wormhole move it never said where the card landed. CLI users miss all three.

What changed

  • cards move <card> --to-project <project> (or --to-card-table) moves the card to that project's card table. cards copy <card> copies it onto its own table or, with --to-project, another project's, and --include-comments brings the comments. Both wait for Basecamp to finish and print the new card. As on the web, the card lands in Triage, or in Done if it's completed.
  • --to-wormhole now prints the card that landed, and --to with another project's column points to --to-project.
  • cards show, cards steps and todos show follow a moved card or to-do from its old id to its new one.

Scope

  • Uses basecamp-sdk v0.31.0. v0.30.0 is the release of feat(recordings): move and copy recordings to another project basecamp-sdk#1053 and also adds the SDK's project-logo operations. v0.31.0 moves the API version to 2026-10-27 and rewrites the move and copy docs; it changes no operation, so the built-in MCP catalog keeps its 286 operations and only reposition_todo's description changes. The built-in MCP server serves remove_project_logo and skips the upload, as it skips the other uploads. CLI logo commands come in a separate PR stacked on this one.
  • The bump also adds move_recording, copy_recording and get_filing to the built-in MCP server's catalog.

Tradeoffs

A copy is never retried. A copy that fails to start with a 5xx, or that's still running when the wait ends, says "don't copy again" and is not marked retryable, because Basecamp may already be making it.

Verification

  • bin/ci passes.
  • A local bc3 harness ran each web card path through the CLI: columns, On hold, Done, another table, wormhole, move to a project, and copy to a project and to its own table. Every path works, and a column from another project still fails. By hand, --include-comments carried a comment and a completed card landed in Done.
  • An independent GPT-6.1-sol review converged after 4 rounds. Each finding it raised has a test that fails without the fix.

Summary by cubic

Adds cross-project moves and copies to the CLI for every recording type the web's Move… and Copy… menus handle, plus filings show to read a move or copy still running, and projects logo to set and remove a project's logo.

  • cards, todos, todolists/todolistgroups, messages, files (and docs/uploads/vaults), schedule, checkins question, forwards, and tools gain move/copy with --to-project and a destination flag. A copy with no destination goes next to the original; --include-comments carries comments. Cards land in Triage, or Done if completed, under a new id. todos move --list still repositions a to-do out of its list by the project's To-dos; --to-list refuses a To-dos id.
  • Basecamp runs each move or copy in the background; the command waits with bounded backoff and prints the new record with its source_id, or reports in_progress naming the filing when the wait ends first. filings show <id> reads where the filing got to, carrying the original --profile/--account into its follow-ups. A recurring project event's schedule show follow-up names the schedule when the project has several.
  • A copy is never retried unless Basecamp answered 4xx, because a 5xx, dropped connection, or wait time-out may already have started it; rate limits stay retryable. Copy recovery points at one listing per type and status and matches on the original's title, since a copy keeps its dates.
  • Sources, URLs, and destinations must belong to the session account on a trusted host; an explicit --in/--project must match the record's own project, a record that reads back as another type is refused, a destination URL must name an existing id rather than a title, a destination whose tool is turned off says how to turn it back on, and container flags a move or copy would ignore are refused.

Scope
Bumps basecamp-sdk to v0.31.0 (move, copy, and filing operations; API version 2026-10-27). The built-in MCP server serves remove_project_logo, skips the multipart upload, and keeps its catalog at 286 operations. projects logo set/remove follow projects wallpaper: the file is checked for size and type before upload, and each answers with the project read back.

Written for commit 4aa1277. Summary will update on new commits.

View guided diff Turn on auto-fix

…rd table name two tables share

A 5xx or dropped connection on the copy request can follow a copy Basecamp
already started, so the error is no longer retryable and says how to check.
A rate limit stays retryable: Basecamp refuses it before copying. A
--to-card-table name that matches two tables now lists both instead of
taking the first.
…mit gate

output.AsError renders a gate error found anywhere in the cause chain in place
of the error around it, so a gate trip while polling turned "do not copy it
again" into a retryable rate limit. The in-progress and read-back errors now
keep their cause as text.
Reading a moved record's old id redirects to the record at its new id, but
cards show, cards steps and todos show kept using the old id for the full step
list, comments and follow-up commands, which then 404.
@robzolkos
robzolkos requested a review from a team as a code owner October 27, 2026 16:57
Copilot AI balanced review requested due to automatic review settings October 27, 2026 16:57
@github-actions github-actions Bot added commands CLI command implementations sdk SDK wrapper and provenance tests Tests (unit and e2e) output Output formatting and presentation docs deps 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

URL validation and explicit source-project checks remain incomplete, and uncertain copy failures can omit duplicate-copy warnings.

4 open issues
What changed in this PR

Adds cross-project card moves and copies to the CLI, including background-operation tracking and support for reading moved records through their old IDs.

Changes:

  • Adds cards copy and destination flags for card moves, with optional comment copying.
  • Reports landed cards after moves and follows updated IDs when reading cards, to-dos and steps.
  • Updates the SDK dependency, MCP catalog, documentation and tests. The SDK release and repinning remain prerequisites for merging.

[!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
nix/​package.nix Updates the dependency vendor hash.
internal/​version/​sdk-provenance.json Records the pinned SDK revision.
internal/​output/​codes.go Adds the background-work status code.
internal/​mcpserver/​testdata/​catalog_snapshot.txt Lists three new recording actions.
internal/​mcpserver/​model/​PROVENANCE.json Updates model source provenance.
internal/​mcpserver/​model/​openapi.json Adds move, copy and filing schemas.
internal/​mcpserver/​model/​behavior-model.json Adds operation behavior metadata.
internal/​mcpserver/​catalog_test.go Updates the served operation count.
internal/​commands/​todos.go Uses moved to-dos’ new IDs.
internal/​commands/​subtasks_test.go Covers step reads after record moves.
internal/​commands/​commands.go Registers the copy action in the catalog.
internal/​commands/​cards.go Implements card filing, polling and destination resolution.
internal/​commands/​cards_test.go Tests landed and pending wormhole moves.
internal/​commands/​cards_filing_test.go Covers filing destinations, failures and polling.
go.sum Updates SDK checksums.
go.mod Pins the SDK commit providing filing APIs.
e2e/​smoke/​smoke_cards_write.bats Adds a card-copy smoke test.
API-COVERAGE.md Documents filing endpoint coverage.
.surface Records the new command and flags.

Review effort: Balanced

Comment thread internal/commands/cards.go Outdated
Comment thread internal/commands/cards.go Outdated
Comment thread internal/commands/cards.go Outdated
Comment thread internal/commands/cards.go Outdated
…ther project

cards move --to-project/--to-card-table and cards copy took the card id and
ignored an explicit --in, --project or card URL bucket, so a card in project
123 moved even when the caller said 888. They now check those against the
card's own project before filing it, as the wormhole move already did. The
check is renamed rejectCardProjectConflict and its hint no longer names
wormholes. Config and interactive defaults are not constraints.
…ss-table moves and copies

cards copy and cards move --to-project/--to-card-table reduced a pasted URL
to its trailing number, so a look-alike host, another account's card, a to-do
or a cards listing filed whichever card in this account had that number. The
URL must now be a single card on a trusted Basecamp host in the session's
account, checked before any request, as files replace and boost update do.
…o-project

A destination card table URL was reduced to its number without checking the
host or account, and its project was ignored when --to-project named another.
It is now held to the same checks as the card URL, a project that disagrees
with --to-project is refused, and the destination is resolved before the card
is read so a bad URL is refused before any request.
A dropped connection or an unreadable answer to the copy request came back as
a plain error, which skipped the "may have started the copy" warning because
only retryable errors got it. The rule is inverted: every failed copy request
gets the warning and is not retryable, except a 4xx answer, which shows
Basecamp refused it, and a rate limit, which stays retryable. Moves are
unchanged.
Copilot AI balanced review requested due to automatic review settings October 27, 2026 17:53

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

URL-only destination resolution can move or copy a card into a project different from the one named in the supplied URL.

1 open issue
4 resolved since last review

Review effort: Balanced

Comment thread internal/commands/cards.go Outdated
A moved message's old id redirects through the card endpoint to the message,
so the read alone does not prove the id names a card.
Copilot AI balanced review requested due to automatic review settings October 27, 2026 18:22
Copilot AI dismissed their stale review, a newer Copilot review was requested October 28, 2026 04:39
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 card move and copy changes.

No open issues 🎉

Review effort: Balanced

@robzolkos

Copy link
Copy Markdown
Collaborator Author

Repinned to the released basecamp-sdk v0.31.0 (6e584039). The release changes the API version to 2026-10-27 and rewrites the doc text for moves and copies, with no Go API change. The served MCP catalog stays at 286 operations; only reposition_todo's description line moves in catalog_snapshot.txt, and .surface is unchanged. v0.31.0 also registers the limits.can_archive_and_trash_projects API gap, which the SDK has not absorbed yet, so the CLI has nothing to read. bin/ci passes, the card harness gives the same result on every path as the v0.30.0 run, and GPT-6.1-sol reports no findings on the repin. #974 to #977 were rebased onto it with no conflicts.

…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.
Copilot AI balanced review requested due to automatic review settings October 28, 2026 05:28
Copilot AI dismissed their stale review, a newer Copilot review was requested October 28, 2026 05:29
robzolkos and others added 2 commits October 28, 2026 00:34
* Share the wallpaper's file check and project read-back with other project images

The size cap and the noun in its refusal become arguments, and the
helpers lose their wallpaper names, so a project logo can use them as
they are.

* Add projects logo set and remove

bc3#14174 gives a project a logo, the image beside its name, through
PUT and DELETE /projects/:id/logo.json, and SDK v0.30.0 wraps both.
They follow projects wallpaper: the file is checked for size (5 MB) and
image type before the upload, the project comes from --project, --in or
the default, and each command answers with the project read back, which
now carries .logo {url} when one is attached.

* Say on a project's detail view whether it has a logo

The logo's only field is a signed-in redirect URL, no use to read in a
terminal, so the view says the logo is set and leaves the URL to JSON.

* Document project logos in the skill and API coverage
* origin/main:
  Fail when Basecamp drops a requested assignee (#981)
  Point the skill at config show for project context (#980)
  Emit "data": [] for a list that found nothing (#979)
  Delete Campfire lines through their Campfire, and explain Ping refusals (#970)
@github-actions github-actions Bot added the skills Agent skills label 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.

🟡 Changes recommended

Unresolved regressions prevent moves to to-do sets, break recurring-event navigation in multi-schedule projects, and fetch series comments instead of occurrence comments.

3 open issues

Review effort: Balanced

Comment thread internal/commands/schedule.go
Comment thread internal/commands/schedule.go Outdated
Comment thread internal/commands/todos.go
Copilot AI balanced review requested due to automatic review settings October 28, 2026 05:36
…list

Moving or copying a to-do into a project whose To-dos tool is turned off said
"has no to-do list" and hinted `todolists create`, which fails there too.
It now gives the shared turned-off message and its `tools enable` hint, as
every other family does.
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 CLI workflow and project-logo changes.

3 open issues

Review effort: Balanced

Copilot AI balanced review requested due to automatic review settings October 28, 2026 05:43
Copilot AI dismissed their stale review, a newer Copilot review was requested October 28, 2026 05:43
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

There are no active review issues requiring changes.

3 open issues

Review effort: Balanced

A recurring event's parent is its series, not its schedule, so in a
project the entries follow-up from schedule show (and show --date) left
out --schedule. With several schedules, running it failed with "Multiple
schedules found". It now looks the schedule up for project events as it
already did for calendar events: one project read, plus a listing per
schedule only when the project has several. Main resolved the project
on every show, so this costs no more than main did, and only for
recurring events.
On main, todos move <id> --to <n> --list <To-dos id> passed the id to
the reposition, which takes a project's To-dos and moves the to-do out
of its list. The same-project check read the destination as a list, so
it 404ed and refused that move. It now falls back to reading To-dos,
keeps the same-project check for it, and refuses To-dos in another
project with the command that does move a to-do there: into a list.

--to-list names a list for Basecamp's generic move and copy, which file
a to-do or group into a list and never onto To-dos. A To-dos id there
used to read as "not found"; it is now refused as To-dos, with the
command that lists the lists in it.
Copilot AI balanced review requested due to automatic review settings October 28, 2026 06:01
Copilot AI dismissed their stale review, a newer Copilot review was requested October 28, 2026 06:01

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 blocking approval.

No open issues 🎉

3 resolved since last review

Review effort: Balanced

@robzolkos
robzolkos merged commit 6e80ca7 into main Oct 28, 2026
42 checks passed
@robzolkos
robzolkos deleted the card-moves-cross-project branch October 28, 2026 06:09
@robzolkos robzolkos mentioned this pull request Oct 28, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

commands CLI command implementations deps docs output Output formatting and presentation sdk SDK wrapper and provenance skills Agent skills tests Tests (unit and e2e)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants