import: move under data/import and file finished plans away
Plans on disk need a one-time hand move
(docs/plans/2026-09-19-import-under-data-import.md), and statuses
recorded `removed` before the rename still count as done.

Assisted-by: GLM-5.3 via pi
change yrvoloymqupqmrpxomtvssxoqllmxttq
commit 8e6aef48febda3ad01b5aca79673748433be89f1
author Alpha Chen <alpha@kejadlen.dev>
date
parent lzmlurps
diff --git a/AGENTS.md b/AGENTS.md
index e67116c..c2986d7 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -31,13 +31,14 @@ rake console          # irb with the store open on the configured database
                       # safety net for what the session might do there
 rake import:macos:plan # Plan importing this Mac's iCloud contacts
                       # (LIMIT=n; reads Contacts, so leave it to the user)
-rake import:macos:remove # Delete from this Mac the contacts the oldest
-                      # landed plan imported (PLAN=dir for another;
-                      # writes Contacts, so leave it to the user)
+rake import:macos:clear # Clear this Mac of the contacts the oldest
+                      # landed plan imported, filing the plan away
+                      # (PLAN=dir for another; writes Contacts, so
+                      # leave it to the user)
 rake import:execute   # Land the oldest plan still to land on a host
                       # (HOST=https://contacts; PLAN=dir for another)
-rake import:status    # Summarize the imports in data/imports and what
-                      # execute and remove would carry next
+rake import:status    # Summarize the plans in data/import and what
+                      # execute and clear would carry next
 rake profile:install  # Download carddav.mobileconfig from the app and
                       # stage it for approval
 ```
diff --git a/docs/plans/2026-09-19-import-under-data-import.md b/docs/plans/2026-09-19-import-under-data-import.md
new file mode 100644
index 0000000..fc0f0ad
--- /dev/null
+++ b/docs/plans/2026-09-19-import-under-data-import.md
@@ -0,0 +1,44 @@
+# Import collateral under data/import
+
+2026-09-19. The import tasks' whole world moved under `data/import`,
+and the last step of an import stopped being named for its action.
+
+## The layout
+
+```
+data/import/
+├── config.yml   # standing data between plans (ProTacts::Import::Config)
+├── plans/       # the plans in flight
+└── done/        # plans filed away, every contact off this Mac
+```
+
+Before, plans lived in `data/imports` beside nothing. Everything the
+tasks keep now sits under the one directory, plans active under
+`plans/` and finished under `done/`. A machine with plans in the old
+place moves them once by hand:
+
+```bash
+mkdir -p data/import/plans data/import/done
+mv data/imports/* data/import/plans/
+```
+
+## Clearing, not removing
+
+The step that takes a plan's contacts off this Mac was `remove`, an
+action's name, and its result was a contact `removed`. The vocabulary
+now names the state: `rake import:macos:clear` clears this Mac of a
+plan's contacts, and a contact's final status is `cleared` — on the
+host, in its groups, off this Mac. `Import::Remove` is `Import::Clear`.
+
+Statuses already recorded as `removed` read as `cleared` (`Plan`
+carries the old spelling beside the new), so a plan built before the
+rename still finishes and files away; nothing is rewritten on disk.
+
+## A finished plan files away
+
+A plan whose every contact is cleared is done (`Plan#done?`). The
+clear task files one away into `done/` as its last step, and the
+status read sweeps any a dead run left in `plans/` before listing —
+so what status lists is what still has work, and the finished plans
+are out of the way but kept, with their cards and sources, for as
+long as `data/` is.
diff --git a/lib/pro_tacts/import/card.rb b/lib/pro_tacts/import/card.rb
index 6892a24..3907d0f 100644
--- a/lib/pro_tacts/import/card.rb
+++ b/lib/pro_tacts/import/card.rb
@@ -23,7 +23,7 @@ module ProTacts
       KEYS = %w[first last nickname birthday phones emails addresses note photo groups source].freeze #: Array[String]
 
       # What the source gave, kept in the card rather than off to the
-      # side: the fields above are read out of it, and `remove` compares
+      # side: the fields above are read out of it, and `clear` compares
       # the Mac's contact against it before deleting anything, so the two
       # are read together and edited in one place.
       SOURCE_KEYS = %w[identifier vcard note contact].freeze #: Array[String]
diff --git a/lib/pro_tacts/import/remove.rb b/lib/pro_tacts/import/clear.rb
similarity index 82%
rename from lib/pro_tacts/import/remove.rb
rename to lib/pro_tacts/import/clear.rb
index f8df7e7..193f2f9 100644
--- a/lib/pro_tacts/import/remove.rb
+++ b/lib/pro_tacts/import/clear.rb
@@ -2,12 +2,12 @@ require "pro_tacts/import/plan"
 
 module ProTacts
   module Import
-    # Takes the originals off the Mac, once a plan has carried them to a
-    # host (docs/plans/2026-09-16-importing-from-macos.md, "Removing the
-    # originals"). Every check is a reason to keep a contact rather than
-    # to delete one, because a wrong keep costs a second look and a wrong
-    # delete costs the contact.
-    class Remove
+    # Clears this Mac of a plan's originals, once they have landed on
+    # a host (docs/plans/2026-09-16-importing-from-macos.md, "Removing
+    # the originals"). Every check is a reason to keep a contact rather
+    # than to delete one, because a wrong keep costs a second look and a
+    # wrong delete costs the contact.
+    class Clear
       # @rbs @plan: Plan
       # @rbs @client: _Client
       # @rbs @mac: _Mac
@@ -20,7 +20,7 @@ module ProTacts
       # find in Contacts and a line to find in plan.yml. Signed in
       # sig/pro_tacts/import.rbs, being a Data class.
       # @rbs skip
-      Result = Data.define(:removed, :kept)
+      Result = Data.define(:cleared, :kept)
 
       #: (Plan plan, client: _Client, mac: _Mac) -> Result
       def self.call(plan, client:, mac:)
@@ -39,9 +39,9 @@ module ProTacts
         imported = @plan.with_status("imported")
         records = @mac.show(imported.map(&:source_id))
         gone, still_there = imported.partition { !records.key?(it.source_id) }
-        # A contact this Mac no longer has is a contact removed, which is
+        # A contact this Mac no longer has is a contact cleared, which is
         # what a rerun of an interrupted run sees.
-        gone.each { @plan.record(it.id, "removed") }
+        gone.each { @plan.record(it.id, Plan::CLEARED) }
 
         kept = [] #: Array[[String, String, String]]
         going = still_there.select { |contact|
@@ -54,13 +54,13 @@ module ProTacts
         # store refuses is the only one that stays. A run that dies before
         # the plan records the rest finds them gone next time.
         refused = @mac.delete(going.map(&:source_id))
-        removed, stuck = going.partition { !refused.key?(it.source_id) }
-        removed.each { @plan.record(it.id, "removed") }
+        deleted, stuck = going.partition { !refused.key?(it.source_id) }
+        deleted.each { @plan.record(it.id, Plan::CLEARED) }
         stuck.each {
           kept << [it.id, it.name, "this Mac would not delete #{it.source_id}: #{refused.fetch(it.source_id)}"]
         }
 
-        Result.new(removed: gone.size + removed.size, kept:)
+        Result.new(cleared: gone.size + deleted.size, kept:)
       end
 
       private
diff --git a/lib/pro_tacts/import/config.rb b/lib/pro_tacts/import/config.rb
index 0747208..9dbf24e 100644
--- a/lib/pro_tacts/import/config.rb
+++ b/lib/pro_tacts/import/config.rb
@@ -3,10 +3,11 @@ require "yaml"
 module ProTacts
   module Import
     # The standing data the import tasks keep between plans, as a YAML
-    # mapping: data/import/config.yml, beside the plans under
-    # data/imports. Not committed — /data is ignored — because it holds
-    # this deployment's own host and choices. The keys are the tasks'
-    # to grow as they take standing data off the command line.
+    # mapping: data/import/config.yml, at the root of the import
+    # collateral under data/import. Not committed — /data is ignored —
+    # because it holds this deployment's own host and choices. The keys
+    # are the tasks' to grow as they take standing data off the command
+    # line.
     class Config
       # A missing file is no configuration and an empty one is none
       # either; a file whose top level is not a mapping is refused
diff --git a/lib/pro_tacts/import/macos.rb b/lib/pro_tacts/import/macos.rb
index ae8455e..6c5f41c 100644
--- a/lib/pro_tacts/import/macos.rb
+++ b/lib/pro_tacts/import/macos.rb
@@ -68,7 +68,7 @@ module ProTacts
         run("read", *[limit&.to_s].compact)
       end
 
-      # This Mac as `Remove` speaks to it: the contacts it still has, by
+      # This Mac as `Clear` speaks to it: the contacts it still has, by
       # source id, and the delete that takes them off it.
       module Mac
         #: (Array[String] source_ids) -> Hash[String, untyped]
diff --git a/lib/pro_tacts/import/plan.rb b/lib/pro_tacts/import/plan.rb
index ec2d295..5afebfa 100644
--- a/lib/pro_tacts/import/plan.rb
+++ b/lib/pro_tacts/import/plan.rb
@@ -8,7 +8,7 @@ require "pro_tacts/import/card"
 module ProTacts
   module Import
     # One import, as a directory: what a source builder decided, and how
-    # far execute and remove have carried it
+    # far execute and clear have carried it
     # (docs/plans/2026-09-16-importing-from-macos.md).
     class Plan
       # @rbs @dir: Pathname
@@ -22,7 +22,7 @@ module ProTacts
       Entry = Data.define(:id, :source_id, :card)
 
       # A contact as the plan lists it, with the last step it reached:
-      # nil, then landed, imported, and removed, in that order. The name is
+      # nil, then landed, imported, and cleared, in that order. The name is
       # the card's when the plan was built, written down so a person
       # reading plan.yml can tell which line is whose without opening a
       # card; nothing reads it back to decide anything.
@@ -34,6 +34,23 @@ module ProTacts
       # Store::EVERYONE, spelled out so a plan loads no database.
       EVERYONE = "sync:*" #: String
 
+      # A contact's final state: on the host, in its groups, and off
+      # this Mac. A plan whose every contact has reached it is done and
+      # files away under done/ (tasks/import.rake).
+      CLEARED = "cleared" #: String
+
+      # The spelling of CLEARED plans recorded before the state was
+      # named for what it is; read as it so a plan built then files
+      # away now.
+      REMOVED = "removed" #: String
+
+      # Whether the plan has carried every contact through: nothing
+      # left for execute or clear to do.
+      #: () -> bool
+      def done?
+        contacts.all? { [CLEARED, REMOVED].include?(it.status) }
+      end
+
       #: (Pathname dir, source: String, created_at: Time, entries: Array[Entry]) -> Plan
       def self.write(dir, source:, created_at:, entries:)
         FileUtils.mkdir_p(dir.dirname)
diff --git a/sig/pro_tacts/import.rbs b/sig/pro_tacts/import.rbs
index fec56e6..22ec5e3 100644
--- a/sig/pro_tacts/import.rbs
+++ b/sig/pro_tacts/import.rbs
@@ -43,12 +43,12 @@ module ProTacts
       end
     end
 
-    class Remove
+    class Clear
       class Result
-        attr_reader removed: Integer
+        attr_reader cleared: Integer
         attr_reader kept: Array[[String, String, String]]
 
-        def self.new: (removed: Integer, kept: Array[[String, String, String]]) -> instance
+        def self.new: (cleared: Integer, kept: Array[[String, String, String]]) -> instance
       end
     end
 
diff --git a/tasks/import.rake b/tasks/import.rake
index 37b189f..411c4d5 100644
--- a/tasks/import.rake
+++ b/tasks/import.rake
@@ -1,51 +1,64 @@
 # Importing an address book from another source
-# (docs/plans/2026-09-16-importing-from-macos.md).
+# (docs/plans/2026-09-16-importing-from-macos.md, and
+# docs/plans/2026-09-19-import-under-data-import.md for the layout
+# and the vocabulary).
 
+require "fileutils"
 require "pathname"
 
 # The plan a task carries further. A module rather than task-file methods,
 # which rake redefines noisily when a file is loaded twice.
 module ImportTasks
-  DIR = Pathname.new("data/imports")
+  # All the import collateral under data/import: the standing
+  # configuration in config.yml beside the plans, active under plans/
+  # and filed away under done/ once every contact is off this Mac.
+  PLANS = Pathname.new("data/import/plans")
+  DONE = Pathname.new("data/import/done")
 
   # Where the import tasks keep their standing data between plans:
-  # data/import/config.yml (ProTacts::Import::Config reads it). The
-  # singular beside DIR's plural is deliberate — the directory of
-  # plans beside the one file of standing state.
+  # data/import/config.yml (ProTacts::Import::Config reads it).
   CONFIG = Pathname.new("data/import/config.yml")
 
   # What each carrying task waits on — a contact still to carry through
   # that step — named so a status read can say what those tasks would
   # pick next without restating their choice.
   TO_LAND = ->(plan) { plan.with_status(nil).any? || plan.with_status("landed").any? }
-  TO_LEAVE = ->(plan) { plan.with_status("imported").any? }
+  TO_CLEAR = ->(plan) { plan.with_status("imported").any? }
 
   # The plan PLAN names, or the oldest one still waiting for this step:
   # plans are carried in the order they were built, so the next one to
   # take further is the oldest that has not been. A PLAN that is not a
-  # directory is read as a plan's name under DIR, the way the directories
-  # there are named.
+  # directory is read as a plan's name under PLANS, the way the
+  # directories there are named.
   def self.plan(waiting, step)
     named = ENV.fetch("PLAN", nil)&.then { Pathname.new(it) }
-    named = DIR / named if named && !named.directory?
+    named = PLANS / named if named && !named.directory?
     if named
       abort "#{named} holds no plan.yml" unless (named / "plan.yml").file?
       return ProTacts::Import::Plan.read(named)
     end
 
-    ProTacts::Import::Plan.all(DIR).find(&waiting) ||
-      abort("no plan in #{DIR} is waiting #{step}: run rake import:macos:plan, or name one in PLAN")
+    ProTacts::Import::Plan.all(PLANS).find(&waiting) ||
+      abort("no plan in #{PLANS} is waiting #{step}: run rake import:macos:plan, or name one in PLAN")
+  end
+
+  # A finished plan's final state: moved under DONE, nothing left to
+  # carry. The clear task files one away as its last step; the status
+  # read sweeps any a dead run left behind.
+  def self.file_away(plan)
+    FileUtils.mkdir_p(DONE)
+    FileUtils.mv(plan.dir, DONE / plan.dir.basename)
   end
 end
 
 namespace :import do
   namespace :macos do
-    desc "Plan importing this Mac's iCloud contacts into data/imports (LIMIT=n for the first n)"
+    desc "Plan importing this Mac's iCloud contacts into data/import/plans (LIMIT=n for the first n)"
     task :plan do
       require "pro_tacts/import/macos"
 
       created_at = Time.now.utc
-      dir = ImportTasks::DIR / "macos-#{created_at.strftime("%Y%m%dT%H%M%SZ")}"
+      dir = ImportTasks::PLANS / "macos-#{created_at.strftime("%Y%m%dT%H%M%SZ")}"
       limit = ENV.fetch("LIMIT", nil)&.then { Integer(it) }
 
       records = ProTacts::Import::Macos.read(limit:)
@@ -60,61 +73,68 @@ namespace :import do
       abort error.message
     end
 
-    desc "Delete from this Mac the contacts the oldest landed plan imported (PLAN=dir for another)"
-    task :remove do
+    desc "Clear this Mac of the contacts the oldest landed plan imported, filing the plan away once its last contact is off (PLAN=dir for another)"
+    task :clear do
       require "net/http"
       require "uri"
       require "pro_tacts/import/http_client"
       require "pro_tacts/import/macos"
-      require "pro_tacts/import/remove"
+      require "pro_tacts/import/clear"
 
-      plan = ImportTasks.plan(ImportTasks::TO_LEAVE, "to leave this Mac")
+      plan = ImportTasks.plan(ImportTasks::TO_CLEAR, "to clear")
       host = plan.host or abort("#{plan.dir} has not landed on a host")
       uri = URI.parse(host)
-      puts "removing the contacts #{plan.dir} landed on #{host}"
+      puts "clearing the contacts #{plan.dir} landed on #{host}"
 
-      result = nil #: ProTacts::Import::Remove::Result?
+      result = nil #: ProTacts::Import::Clear::Result?
       Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https") do |http|
-        result = ProTacts::Import::Remove.call(
+        result = ProTacts::Import::Clear.call(
           plan, client: ProTacts::Import::HttpClient.new(http), mac: ProTacts::Import::Macos::Mac
         )
       end
       result.kept.each { |id, name, why| puts "kept #{id} #{name}: #{why}" }
-      puts "#{result.removed} contacts removed from this Mac"
+      puts "#{result.cleared} contacts cleared from this Mac"
+      if plan.done?
+        ImportTasks.file_away(plan)
+        puts "filed #{plan.dir.basename} away in #{ImportTasks::DONE}"
+      end
     end
   end
 
-  desc "Summarize the imports in data/imports and what execute and remove would carry next"
+  desc "Summarize the plans in data/import and what execute and clear would carry next"
   task :status do
     require "pro_tacts/import/plan"
 
-    plans = ProTacts::Import::Plan.all(ImportTasks::DIR)
+    # A finished plan files away; this sweeps any a dead run left in
+    # plans/, so what is listed is what still has work — which is also
+    # why nothing here is ever finished: the sweep took it.
+    ProTacts::Import::Plan.all(ImportTasks::PLANS).each do |plan|
+      ImportTasks.file_away(plan) if plan.done?
+    end
+
+    plans = ProTacts::Import::Plan.all(ImportTasks::PLANS)
     if plans.empty?
-      puts "no plans in #{ImportTasks::DIR} yet; run rake import:macos:plan"
+      puts "no plans in #{ImportTasks::PLANS}; run rake import:macos:plan"
       next
     end
 
     # One line per plan, as far along as it is: the fraction counts
     # everything past “planned”, because from “landed” on the card is
-    # on the host — which is the state a glance wants. The marker is
-    # ✅ only once every contact is off the Mac — removed, the last
-    # status — and ⏳ until then, so it can never disagree with the
-    # next lines below. The plan names are the directories',
-    # copy-pasteable into PLAN=.
+    # on the host — which is the state a glance wants. The plan names
+    # are the directories', copy-pasteable into PLAN=.
     plans.each do |plan|
       done = plan.contacts.size - plan.with_status(nil).size
-      marker = ImportTasks::TO_LAND.call(plan) || ImportTasks::TO_LEAVE.call(plan) ? "⏳" : "✅"
-      puts "#{marker} #{plan.dir.basename}  #{done}/#{plan.contacts.size} contacts"
+      puts "#{plan.dir.basename}  #{done}/#{plan.contacts.size} contacts"
     end
 
     puts ""
     landing = plans.find(&ImportTasks::TO_LAND)
-    leaving = plans.find(&ImportTasks::TO_LEAVE)
+    clearing = plans.find(&ImportTasks::TO_CLEAR)
     puts "next: import:execute would land #{landing.dir.basename}" if landing
-    puts "next: import:macos:remove would clear #{leaving.dir.basename}" if leaving
+    puts "next: import:macos:clear would finish #{clearing.dir.basename}" if clearing
   end
 
-  desc "Land the oldest plan in data/imports still to land, or the one in PLAN, on the pro-tacts at HOST, a base URL such as https://contacts"
+  desc "Land the oldest plan in data/import/plans still to land, or the one in PLAN, on the pro-tacts at HOST, a base URL such as https://contacts"
   task :execute do
     require "net/http"
     require "uri"
diff --git a/test/pro_tacts/import/test_remove.rb b/test/pro_tacts/import/test_clear.rb
similarity index 82%
rename from test/pro_tacts/import/test_remove.rb
rename to test/pro_tacts/import/test_clear.rb
index 5560f0c..c7575fb 100644
--- a/test/pro_tacts/import/test_remove.rb
+++ b/test/pro_tacts/import/test_clear.rb
@@ -6,16 +6,16 @@ require "tmpdir"
 
 require "pro_tacts/import/execute"
 require "pro_tacts/import/plan"
-require "pro_tacts/import/remove"
+require "pro_tacts/import/clear"
 require "pro_tacts/web"
 
-class ImportRemoveTest < Minitest::Test
+class ImportClearTest < Minitest::Test
   include ThrowawayContacts
 
   Card = ProTacts::Import::Card
   Plan = ProTacts::Import::Plan
   Execute = ProTacts::Import::Execute
-  Remove = ProTacts::Import::Remove
+  Clear = ProTacts::Import::Clear
 
   HOST = "https://contacts"
   IDS = %w[kmnuqmzxylru vmnlryyvktux].freeze
@@ -52,7 +52,7 @@ class ImportRemoveTest < Minitest::Test
     {"identifier" => source_id(id), "vcard" => vcard(id), "note" => note}
   end
 
-  # A plan already landed on the host, since that is what remove works on.
+  # A plan already landed on the host, since that is what clear works on.
   def with_landed_plan(notes: {}, edit: nil)
     Dir.mktmpdir do |tmp|
       dir = Pathname.new(tmp) / "plan"
@@ -76,12 +76,12 @@ class ImportRemoveTest < Minitest::Test
     with_landed_plan do |plan, _store, client|
       mac = Mac.new(IDS.to_h { [source_id(it), record(it)] })
 
-      result = Remove.call(plan, client:, mac:)
+      result = Clear.call(plan, client:, mac:)
 
-      assert_equal 2, result.removed
+      assert_equal 2, result.cleared
       assert_empty result.kept
       assert_equal IDS.map { source_id(it) }, mac.deleted
-      assert_equal %w[removed removed], Plan.read(plan.dir).contacts.map(&:status)
+      assert_equal %w[cleared cleared], Plan.read(plan.dir).contacts.map(&:status)
     end
   end
 
@@ -91,11 +91,11 @@ class ImportRemoveTest < Minitest::Test
       records[source_id(IDS.first)]["vcard"] = "BEGIN:VCARD\r\nVERSION:3.0\r\nFN:Renamed\r\nEND:VCARD\r\n"
       mac = Mac.new(records)
 
-      result = Remove.call(plan, client:, mac:)
+      result = Clear.call(plan, client:, mac:)
 
       assert_equal [[IDS.first, "Contact #{IDS.first}", "it has changed on this Mac since the plan"]], result.kept
       assert_equal [source_id(IDS.last)], mac.deleted
-      assert_equal ["imported", "removed"], Plan.read(plan.dir).contacts.map(&:status)
+      assert_equal ["imported", "cleared"], Plan.read(plan.dir).contacts.map(&:status)
     end
   end
 
@@ -105,7 +105,7 @@ class ImportRemoveTest < Minitest::Test
       records[source_id(IDS.first)]["note"] = "Analyst. Also a countess."
       mac = Mac.new(records)
 
-      result = Remove.call(plan, client:, mac:)
+      result = Clear.call(plan, client:, mac:)
 
       assert_equal [[IDS.first, "Contact #{IDS.first}", "its note has changed on this Mac since the plan"]], result.kept
       assert_equal [source_id(IDS.last)], mac.deleted
@@ -117,7 +117,7 @@ class ImportRemoveTest < Minitest::Test
       store.delete(IDS.first)
       mac = Mac.new(IDS.to_h { [source_id(it), record(it)] })
 
-      result = Remove.call(plan, client:, mac:)
+      result = Clear.call(plan, client:, mac:)
 
       assert_equal [[IDS.first, "Contact #{IDS.first}", "its card is no longer on #{HOST}"]], result.kept
       assert_equal [source_id(IDS.last)], mac.deleted
@@ -131,22 +131,22 @@ class ImportRemoveTest < Minitest::Test
       refute_includes store.book("test@example.com"), IDS.first
       mac = Mac.new(IDS.to_h { [source_id(it), record(it)] })
 
-      result = Remove.call(plan, client:, mac:)
+      result = Clear.call(plan, client:, mac:)
 
       assert_empty result.kept
       assert_equal IDS.map { source_id(it) }, mac.deleted
     end
   end
 
-  def test_a_contact_already_off_the_mac_counts_as_removed
+  def test_a_contact_already_off_the_mac_counts_as_cleared
     with_landed_plan do |plan, _store, client|
       mac = Mac.new({source_id(IDS.last) => record(IDS.last)})
 
-      result = Remove.call(plan, client:, mac:)
+      result = Clear.call(plan, client:, mac:)
 
-      assert_equal 2, result.removed
+      assert_equal 2, result.cleared
       assert_equal [source_id(IDS.last)], mac.deleted
-      assert_equal %w[removed removed], Plan.read(plan.dir).contacts.map(&:status)
+      assert_equal %w[cleared cleared], Plan.read(plan.dir).contacts.map(&:status)
     end
   end
 
@@ -157,23 +157,23 @@ class ImportRemoveTest < Minitest::Test
     with_landed_plan do |plan, _store, client|
       mac = Mac.new(IDS.to_h { [source_id(it), record(it)] }, refuses: {source_id(IDS.first) => "faulting, 134092"})
 
-      result = Remove.call(plan, client:, mac:)
+      result = Clear.call(plan, client:, mac:)
 
-      assert_equal 1, result.removed
+      assert_equal 1, result.cleared
       assert_equal [[IDS.first, "Contact #{IDS.first}", "this Mac would not delete #{source_id(IDS.first)}: faulting, 134092"]], result.kept
       assert_equal [source_id(IDS.last)], mac.deleted
-      assert_equal ["imported", "removed"], Plan.read(plan.dir).contacts.map(&:status)
+      assert_equal ["imported", "cleared"], Plan.read(plan.dir).contacts.map(&:status)
     end
   end
 
-  def test_a_rerun_removes_nothing_again
+  def test_a_rerun_clears_nothing_again
     with_landed_plan do |plan, _store, client|
       mac = Mac.new(IDS.to_h { [source_id(it), record(it)] })
-      Remove.call(plan, client:, mac:)
+      Clear.call(plan, client:, mac:)
 
-      result = Remove.call(Plan.read(plan.dir), client:, mac:)
+      result = Clear.call(Plan.read(plan.dir), client:, mac:)
 
-      assert_equal 0, result.removed
+      assert_equal 0, result.cleared
       assert_equal IDS.map { source_id(it) }, mac.deleted
     end
   end
diff --git a/test/pro_tacts/import/test_plan.rb b/test/pro_tacts/import/test_plan.rb
index bac9c06..b4dd22b 100644
--- a/test/pro_tacts/import/test_plan.rb
+++ b/test/pro_tacts/import/test_plan.rb
@@ -132,4 +132,25 @@ class ImportPlanTest < Minitest::Test
       assert_equal ["plan.yml"], dir.children.map { it.basename.to_s }.grep(/plan/)
     end
   end
+
+  def test_a_plan_is_done_once_every_contact_is_cleared
+    in_tmpdir do |dir|
+      plan = Plan.write(dir, source: "macos", created_at: CREATED_AT, entries: [entry("kmnuqmzxylru"), entry("vmnlryyvktux")])
+
+      refute plan.done?
+      plan.record("kmnuqmzxylru", Plan::CLEARED)
+      refute plan.done?
+      plan.record("vmnlryyvktux", Plan::CLEARED)
+      assert plan.done?
+    end
+  end
+
+  def test_a_plan_recorded_under_the_old_vocabulary_finishes_too
+    in_tmpdir do |dir|
+      plan = Plan.write(dir, source: "macos", created_at: CREATED_AT, entries: [entry("kmnuqmzxylru")])
+      plan.record("kmnuqmzxylru", "removed")
+
+      assert plan.done?
+    end
+  end
 end
diff --git a/test/tasks/test_import.rb b/test/tasks/test_import.rb
index a46224b..c14f319 100644
--- a/test/tasks/test_import.rb
+++ b/test/tasks/test_import.rb
@@ -14,27 +14,30 @@ class ImportStatusTaskTest < Minitest::Test
 
   def entry(id) = Plan::Entry.new(id:, source_id: "#{id}:ABPerson", card: ADA)
 
-  def test_status_counts_each_plans_contacts_and_names_what_comes_next
+  def test_status_counts_each_plans_contacts_and_files_finished_plans_away
     Dir.mktmpdir do |root|
       root = Pathname.new(root)
-      imports = root / "data" / "imports"
-      # A plan carried all the way off the Mac, one mid-flight — one
-      # contact through to the Mac-cleanup step, one past it — and one
-      # freshly built with everything still to do.
-      through = Plan.write(imports / "macos-20260831T000000Z", source: "macos", created_at: Time.utc(2026, 8, 31), entries: [entry("qxqmqmqmqmqm"), entry("zzzzzzzzzzzz")])
-      through.contacts.each { it.status or through.record(it.id, "removed") }
-      underway = Plan.write(imports / "macos-20260901T000000Z", source: "macos", created_at: Time.utc(2026, 9, 1), entries: [entry("kmnuqmzxylru"), entry("vmnlryyvktux")])
+      plans = root / "data" / "import" / "plans"
+      # A plan carried all the way off the Mac — done, so swept into
+      # done/ rather than listed — one mid-flight with a contact through
+      # to the Mac-clearing step and one past it, and one freshly built
+      # with everything to do.
+      through = Plan.write(plans / "macos-20260831T000000Z", source: "macos", created_at: Time.utc(2026, 8, 31), entries: [entry("qxqmqmqmqmqm"), entry("zzzzzzzzzzzz")])
+      through.contacts.each { it.status or through.record(it.id, Plan::CLEARED) }
+      underway = Plan.write(plans / "macos-20260901T000000Z", source: "macos", created_at: Time.utc(2026, 9, 1), entries: [entry("kmnuqmzxylru"), entry("vmnlryyvktux")])
       underway.record("kmnuqmzxylru", "imported")
-      underway.record("vmnlryyvktux", "removed")
-      Plan.write(imports / "macos-20260916T180412Z", source: "macos", created_at: Time.utc(2026, 9, 16, 18, 4, 12), entries: [entry("aaaaaaaaaaaa")])
+      underway.record("vmnlryyvktux", Plan::CLEARED)
+      Plan.write(plans / "macos-20260916T180412Z", source: "macos", created_at: Time.utc(2026, 9, 16, 18, 4, 12), entries: [entry("aaaaaaaaaaaa")])
 
       out, = run_task(root)
 
-      assert_includes out, "✅ macos-20260831T000000Z  2/2 contacts\n"
-      assert_includes out, "⏳ macos-20260901T000000Z  2/2 contacts\n"
-      assert_includes out, "⏳ macos-20260916T180412Z  0/1 contacts\n"
+      assert_includes out, "macos-20260901T000000Z  2/2 contacts\n"
+      assert_includes out, "macos-20260916T180412Z  0/1 contacts\n"
       assert_includes out, "next: import:execute would land macos-20260916T180412Z\n"
-      assert_includes out, "next: import:macos:remove would clear macos-20260901T000000Z\n"
+      assert_includes out, "next: import:macos:clear would finish macos-20260901T000000Z\n"
+      assert (root / "data/import/done/macos-20260831T000000Z/plan.yml").file?
+      refute (plans / "macos-20260831T000000Z").directory?
+      refute_includes out, "macos-20260831T000000Z  "
     end
   end
 
@@ -42,14 +45,14 @@ class ImportStatusTaskTest < Minitest::Test
     Dir.mktmpdir do |root|
       out, = run_task(Pathname.new(root))
 
-      assert_includes out, "no plans in data/imports yet"
+      assert_includes out, "no plans in data/import/plans; run rake import:macos:plan"
     end
   end
 
   private
 
   # Runs import:status in a Rake application of its own, from root as
-  # the working directory: the task reads data/imports relative to
+  # the working directory: the task reads data/import relative to
   # where rake runs, not through ProTacts.config.
   def run_task(root)
     application = Rake.application