roadmap-core 0.2.2__tar.gz → 0.2.3__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (24) hide show
  1. {roadmap_core-0.2.2/roadmap_core.egg-info → roadmap_core-0.2.3}/PKG-INFO +12 -1
  2. {roadmap_core-0.2.2 → roadmap_core-0.2.3}/README.md +11 -0
  3. {roadmap_core-0.2.2 → roadmap_core-0.2.3}/pyproject.toml +1 -1
  4. {roadmap_core-0.2.2 → roadmap_core-0.2.3}/roadmap_core/cli.py +53 -12
  5. {roadmap_core-0.2.2 → roadmap_core-0.2.3}/roadmap_core/stores.py +35 -5
  6. {roadmap_core-0.2.2 → roadmap_core-0.2.3/roadmap_core.egg-info}/PKG-INFO +12 -1
  7. {roadmap_core-0.2.2 → roadmap_core-0.2.3}/tests/test_adoption.py +121 -0
  8. {roadmap_core-0.2.2 → roadmap_core-0.2.3}/tests/test_stores.py +82 -0
  9. {roadmap_core-0.2.2 → roadmap_core-0.2.3}/LICENSE +0 -0
  10. {roadmap_core-0.2.2 → roadmap_core-0.2.3}/roadmap_core/__init__.py +0 -0
  11. {roadmap_core-0.2.2 → roadmap_core-0.2.3}/roadmap_core/graph.py +0 -0
  12. {roadmap_core-0.2.2 → roadmap_core-0.2.3}/roadmap_core/impact.py +0 -0
  13. {roadmap_core-0.2.2 → roadmap_core-0.2.3}/roadmap_core/store.py +0 -0
  14. {roadmap_core-0.2.2 → roadmap_core-0.2.3}/roadmap_core.egg-info/SOURCES.txt +0 -0
  15. {roadmap_core-0.2.2 → roadmap_core-0.2.3}/roadmap_core.egg-info/dependency_links.txt +0 -0
  16. {roadmap_core-0.2.2 → roadmap_core-0.2.3}/roadmap_core.egg-info/entry_points.txt +0 -0
  17. {roadmap_core-0.2.2 → roadmap_core-0.2.3}/roadmap_core.egg-info/requires.txt +0 -0
  18. {roadmap_core-0.2.2 → roadmap_core-0.2.3}/roadmap_core.egg-info/top_level.txt +0 -0
  19. {roadmap_core-0.2.2 → roadmap_core-0.2.3}/setup.cfg +0 -0
  20. {roadmap_core-0.2.2 → roadmap_core-0.2.3}/templates/roadmap.yml +0 -0
  21. {roadmap_core-0.2.2 → roadmap_core-0.2.3}/tests/test_arcs.py +0 -0
  22. {roadmap_core-0.2.2 → roadmap_core-0.2.3}/tests/test_graph.py +0 -0
  23. {roadmap_core-0.2.2 → roadmap_core-0.2.3}/tests/test_impact.py +0 -0
  24. {roadmap_core-0.2.2 → roadmap_core-0.2.3}/tests/test_store.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: roadmap-core
3
- Version: 0.2.2
3
+ Version: 0.2.3
4
4
  Summary: The roadmap work-item and arc graph: status derivation, validation, and markdown rendering. Stdlib-only, so any repo can adopt it without adopting a backend.
5
5
  License: MIT
6
6
  Project-URL: Source, https://github.com/gald33/roadmap-core
@@ -129,6 +129,17 @@ FAIL store roadmap/roadmap.db holds 0 items while roadmap/items/ holds 7.
129
129
  Seed it: `roadmap push`
130
130
  ```
131
131
 
132
+ **Who owns a claim depends on which store you have.** On the SQLite floor the
133
+ file is authoritative: CI rebuilds the store from `roadmap/items/*.yaml` every
134
+ run, so `push` takes a file's `claim` when it CREATES the item — otherwise a
135
+ held item renders as `ready` and `sync --check` fails for as long as anybody is
136
+ working. Against a served store the file is a *projection* of a store that
137
+ outlives the checkout, so a claim in a file is never pushed: a stale clone would
138
+ recreate one the store had already released. Either way `push` ignores it on
139
+ UPDATE, because the store is the live record of who holds what.
140
+
141
+ Not guessable from the field's name, which is why it is written down here.
142
+
132
143
  **Two things that are conventions rather than choices**, both found by doing
133
144
  this rather than by reading the code:
134
145
 
@@ -108,6 +108,17 @@ FAIL store roadmap/roadmap.db holds 0 items while roadmap/items/ holds 7.
108
108
  Seed it: `roadmap push`
109
109
  ```
110
110
 
111
+ **Who owns a claim depends on which store you have.** On the SQLite floor the
112
+ file is authoritative: CI rebuilds the store from `roadmap/items/*.yaml` every
113
+ run, so `push` takes a file's `claim` when it CREATES the item — otherwise a
114
+ held item renders as `ready` and `sync --check` fails for as long as anybody is
115
+ working. Against a served store the file is a *projection* of a store that
116
+ outlives the checkout, so a claim in a file is never pushed: a stale clone would
117
+ recreate one the store had already released. Either way `push` ignores it on
118
+ UPDATE, because the store is the live record of who holds what.
119
+
120
+ Not guessable from the field's name, which is why it is written down here.
121
+
111
122
  **Two things that are conventions rather than choices**, both found by doing
112
123
  this rather than by reading the code:
113
124
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "roadmap-core"
3
- version = "0.2.2"
3
+ version = "0.2.3"
4
4
  description = "The roadmap work-item and arc graph: status derivation, validation, and markdown rendering. Stdlib-only, so any repo can adopt it without adopting a backend."
5
5
  requires-python = ">=3.11"
6
6
  readme = "README.md"
@@ -899,7 +899,16 @@ def cmd_push(args: argparse.Namespace) -> int:
899
899
  }
900
900
  try:
901
901
  if local is not None:
902
- local.upsert_item(payload)
902
+ # The claim goes to the LOCAL store only. Its store is ephemeral
903
+ # — CI rebuilds it from these files every run — so the file is
904
+ # the only durable record a claim has. The served store outlives
905
+ # the checkout, where the same move would recreate a claim it had
906
+ # already released. See `LocalStore`'s "claim on the floor".
907
+ local.upsert_item(dict(
908
+ payload,
909
+ claimed_by=item.get("claimed_by"),
910
+ claimed_at=item.get("claimed_at"),
911
+ ))
903
912
  else:
904
913
  _api("PUT", f"/admin/roadmap/{key}", payload)
905
914
  except stores.Pruned:
@@ -1000,7 +1009,15 @@ def cmd_status(args: argparse.Namespace) -> int:
1000
1009
  file=sys.stderr,
1001
1010
  )
1002
1011
  if args.status == "done":
1003
- print("done also drops the claim — `roadmap.py prune` when you are ready to clear it")
1012
+ # The store drops the hold; the FILE has to lose it too, or the next
1013
+ # push puts it straight back. On the floor that is not a cosmetic lag —
1014
+ # the file is where a claim durably lives there, so a stale block in it
1015
+ # is the claim, and `sync --check` goes red on an item nobody holds.
1016
+ # Projected here rather than left to `pull`, which is a served-store
1017
+ # command a floor project never runs.
1018
+ with switchboard_write_lock(args.key):
1019
+ _claim_projected(args.key, None)
1020
+ print("done also drops the claim — `roadmap prune` when you are ready to clear it")
1004
1021
  # No lease to drop here any more. This used to release the long-lived
1005
1022
  # mirror, which the store's `done` side effect would otherwise leave
1006
1023
  # reading as "claimed and alive" for up to four hours after the roadmap
@@ -1915,22 +1932,46 @@ def cmd_validate(args: argparse.Namespace) -> int:
1915
1932
 
1916
1933
 
1917
1934
  def installed_version() -> str:
1918
- """The version of the distribution this module was imported from.
1919
-
1920
- A source checkout that was never installed has no distribution metadata,
1921
- which is a legitimate way to run the CLI and must not raise. It is still
1922
- worth distinguishing in the output: "which version is this" and "there is no
1923
- package here, you are running a directory" are different answers to the same
1924
- question, and only one of them can be compared against a pin.
1935
+ """The version of the code that is actually running.
1936
+
1937
+ Not simply `metadata.version("roadmap-core")`, which reports the version of
1938
+ the INSTALLED DISTRIBUTION whether or not that is what got imported. Those
1939
+ diverge whenever a source tree shadows the install — `PYTHONPATH=.` in a
1940
+ checkout, an editable install left pointing at a directory that moved, a
1941
+ `sys.path` entry a wrapper script prepended. In every one of those the
1942
+ distribution's metadata is still perfectly readable and describes a copy of
1943
+ the code nobody is executing.
1944
+
1945
+ That is a small inaccuracy in most commands and a disqualifying one here.
1946
+ This function exists because two installs at different versions could not be
1947
+ told apart; a version string that can name the wrong one reintroduces the
1948
+ problem it was added to solve. Measured 2026-08-22 in this repository:
1949
+ `doctor` reported `0.2.1` while running 0.2.2 from a checkout.
1950
+
1951
+ So the location is compared, and a mismatch is stated rather than resolved —
1952
+ which one is "right" depends on what the caller meant, and printing both is
1953
+ the only answer that cannot be wrong.
1925
1954
  """
1926
1955
  try:
1927
- from importlib.metadata import PackageNotFoundError, version
1956
+ from importlib.metadata import PackageNotFoundError, distribution
1928
1957
  except ImportError: # pragma: no cover - stdlib since 3.8
1929
1958
  return "unknown"
1959
+
1960
+ running_from = Path(__file__).resolve().parent
1930
1961
  try:
1931
- return version("roadmap-core")
1962
+ dist = distribution("roadmap-core")
1932
1963
  except PackageNotFoundError:
1933
- return "not installed (running from a source tree)"
1964
+ return f"not installed (running from {running_from})"
1965
+
1966
+ declared = dist.version
1967
+ try:
1968
+ installed_at = Path(str(dist.locate_file("roadmap_core"))).resolve()
1969
+ except Exception: # noqa: BLE001 - a path we cannot resolve is a mismatch we cannot rule out
1970
+ return declared
1971
+
1972
+ if installed_at == running_from:
1973
+ return declared
1974
+ return f"{declared} installed, but running from {running_from}"
1934
1975
 
1935
1976
 
1936
1977
  class _Check:
@@ -172,6 +172,20 @@ def _row_to_arc(row: sqlite3.Row) -> dict[str, Any]:
172
172
  class LocalStore:
173
173
  """The roadmap in one SQLite file, with no server anywhere.
174
174
 
175
+ **Claim on the floor.** This store takes a file's `claimed_by`/`claimed_at`
176
+ when it CREATES an item, and ignores them on update. `ApiStore` never does.
177
+ That asymmetry is deliberate and is the ruling recorded in
178
+ `a-claim-cannot-survive-the-floors-ci`: here the store is EPHEMERAL — CI
179
+ deletes it and rebuilds it from `roadmap/items/*.yaml` every run — so the
180
+ file is the only durable record a claim has, and dropping it made a held item
181
+ render as `ready` and `sync --check` fail for as long as anybody was working.
182
+ Against a SERVED store the file is a projection of a store that outlives it,
183
+ and honouring a claim from a stale checkout would recreate one the store had
184
+ already released — the resurrection class `roadmap_prunes` exists to prevent.
185
+
186
+ One field, two meanings, decided by which store you are holding. Stated here
187
+ because it is not guessable from the field's name.
188
+
175
189
  Owns its connection and closes it, so a caller can use it as a context
176
190
  manager and a test does not leak file handles into the next test.
177
191
  """
@@ -260,6 +274,10 @@ class LocalStore:
260
274
  payload["id"] = store.new_id()
261
275
  payload["status"] = item.get("status") or "ready"
262
276
  payload["created_at"] = now
277
+ # The claim, on CREATE only, for the same reason as `status` and with
278
+ # the same asymmetry — see the class docstring's "claim on the floor".
279
+ payload["claimed_by"] = item.get("claimed_by")
280
+ payload["claimed_at"] = item.get("claimed_at")
263
281
  names = ", ".join(payload)
264
282
  marks = ", ".join("?" for _ in payload)
265
283
  self._conn.execute(
@@ -326,11 +344,23 @@ class LocalStore:
326
344
  raise StoreError(f"no such item: {key}")
327
345
  now = _utcnow_iso()
328
346
  done_at = row["done_at"] or (now if status == "done" else None)
329
- self._conn.execute(
330
- "UPDATE roadmap_items SET status = ?, done_at = ?, updated_at = ?"
331
- " WHERE key = ?",
332
- (status, done_at, now, key),
333
- )
347
+ if status == "done":
348
+ # Finishing drops the hold, matching the served path — and matching
349
+ # what the CLI already prints ("done also drops the claim"), which it
350
+ # printed here while nothing happened. Finishing is the one moment a
351
+ # hold is definitely over, and releasing separately is the step that
352
+ # never happens because nothing prompts it.
353
+ self._conn.execute(
354
+ "UPDATE roadmap_items SET status = ?, done_at = ?, updated_at = ?,"
355
+ " claimed_by = NULL, claimed_at = NULL WHERE key = ?",
356
+ (status, done_at, now, key),
357
+ )
358
+ else:
359
+ self._conn.execute(
360
+ "UPDATE roadmap_items SET status = ?, done_at = ?, updated_at = ?"
361
+ " WHERE key = ?",
362
+ (status, done_at, now, key),
363
+ )
334
364
  return self.get_item(key) or {}
335
365
 
336
366
  def delete_item(self, key: str) -> bool:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: roadmap-core
3
- Version: 0.2.2
3
+ Version: 0.2.3
4
4
  Summary: The roadmap work-item and arc graph: status derivation, validation, and markdown rendering. Stdlib-only, so any repo can adopt it without adopting a backend.
5
5
  License: MIT
6
6
  Project-URL: Source, https://github.com/gald33/roadmap-core
@@ -129,6 +129,17 @@ FAIL store roadmap/roadmap.db holds 0 items while roadmap/items/ holds 7.
129
129
  Seed it: `roadmap push`
130
130
  ```
131
131
 
132
+ **Who owns a claim depends on which store you have.** On the SQLite floor the
133
+ file is authoritative: CI rebuilds the store from `roadmap/items/*.yaml` every
134
+ run, so `push` takes a file's `claim` when it CREATES the item — otherwise a
135
+ held item renders as `ready` and `sync --check` fails for as long as anybody is
136
+ working. Against a served store the file is a *projection* of a store that
137
+ outlives the checkout, so a claim in a file is never pushed: a stale clone would
138
+ recreate one the store had already released. Either way `push` ignores it on
139
+ UPDATE, because the store is the live record of who holds what.
140
+
141
+ Not guessable from the field's name, which is why it is written down here.
142
+
132
143
  **Two things that are conventions rather than choices**, both found by doing
133
144
  this rather than by reading the code:
134
145
 
@@ -29,7 +29,9 @@ empty backlog for an afternoon.
29
29
 
30
30
  from __future__ import annotations
31
31
 
32
+ import argparse
32
33
  import os
34
+ import shutil
33
35
  import subprocess
34
36
  import sys
35
37
  from pathlib import Path
@@ -373,3 +375,122 @@ def test_the_version_is_answerable_without_a_subcommand(project):
373
375
 
374
376
  assert result.returncode == 0
375
377
  assert "roadmap-core" in result.stdout
378
+
379
+
380
+ def test_the_version_names_the_code_that_is_running_not_the_one_installed(
381
+ project, tmp_path
382
+ ):
383
+ """A shadowed install must not be reported as the running one.
384
+
385
+ `metadata.version()` reads the INSTALLED distribution whether or not that is
386
+ what got imported, and the two diverge on `PYTHONPATH=.` in a checkout, an
387
+ editable install pointing at a moved directory, or any wrapper that prepends
388
+ to `sys.path`. Measured in this repository on 2026-08-22: `doctor` printed
389
+ `0.2.1` while running 0.2.2 from a checkout.
390
+
391
+ This is the one command where that is disqualifying rather than untidy — it
392
+ exists BECAUSE two installs at different versions could not be told apart, so
393
+ a version string able to name the wrong one reintroduces its own subject.
394
+
395
+ Done by really shadowing the package rather than by patching `metadata`: a
396
+ copy at a path nobody would mistake for the install, imported by putting it
397
+ first on `PYTHONPATH`.
398
+ """
399
+ shadow = tmp_path / "shadow"
400
+ shutil.copytree(PACKAGE_ROOT / "roadmap_core", shadow / "roadmap_core")
401
+
402
+ env = dict(os.environ)
403
+ env["PYTHONPATH"] = str(shadow)
404
+ env["ROADMAP_SOURCE"] = "local"
405
+ result = subprocess.run(
406
+ [sys.executable, "-m", "roadmap_core.cli", "--version"],
407
+ cwd=project, env=env, capture_output=True, text=True, timeout=60,
408
+ )
409
+
410
+ assert result.returncode == 0, result.stderr
411
+ assert str(shadow / "roadmap_core") in result.stdout, (
412
+ "the version must name where the running code came from when that is not "
413
+ f"the installed distribution — got: {result.stdout!r}"
414
+ )
415
+
416
+
417
+ # --- claiming, on the floor, end to end ---------------------------------------
418
+
419
+
420
+ def test_a_floor_project_can_claim_and_still_pass_its_own_ci(project):
421
+ """The failure `a-claim-cannot-survive-the-floors-ci` describes, run whole.
422
+
423
+ Claim an item, commit what the CLI tells you to commit, then let CI rebuild
424
+ the store from the files — which is what `.github/workflows/roadmap.yml`
425
+ does on every run, because on the floor the store is a SQLite file that does
426
+ not exist until `push` creates it.
427
+
428
+ Before the fix this ended in `ARCS.md is stale — run 'roadmap sync'`, and
429
+ `sync` did not fix it: the file said `claimed`, a fresh push rendered
430
+ `ready`, and the two could not agree while anybody held anything. The floor's
431
+ CI was red for exactly as long as its coordination feature was in use.
432
+
433
+ The store is deleted between the two halves ON PURPOSE. Checking against the
434
+ warm store the claim was made in is what hid this — it agrees with itself.
435
+ """
436
+ assert run(project, "push").returncode == 0
437
+ # `--by` explicitly: the scratch project is not a git repository, so
438
+ # `current_branch()` has nothing to read and the holder would be the literal
439
+ # string "unknown" — which would still pass a test that only checked the
440
+ # claim survived, while proving nothing about the name travelling with it.
441
+ assert run(project, "claim", "first-thing", "--by", "claude/holder").returncode == 0
442
+ assert run(project, "sync").returncode == 0
443
+
444
+ (project / "roadmap" / "roadmap.db").unlink() # as CI starts
445
+ assert run(project, "push").returncode == 0
446
+ assert run(project, "validate").returncode == 0
447
+
448
+ checked = run(project, "sync", "--check")
449
+ assert checked.returncode == 0, (
450
+ "a claimed item makes the committed markdown unreproducible:\n"
451
+ f"{checked.stdout}\n{checked.stderr}"
452
+ )
453
+ # And the holder survived, not merely the status: a rebuild that kept
454
+ # `claimed` while losing WHO holds it would still leave the next session
455
+ # unable to tell whether the item is theirs.
456
+ assert "CLAIMED" in run(project, "list").stdout
457
+ assert "claude/holder" in run(project, "show", "first-thing").stdout
458
+
459
+
460
+ def test_the_served_path_still_refuses_to_carry_a_claim(project, monkeypatch):
461
+ """Acceptance 2, asserted rather than assumed.
462
+
463
+ The same move against a SERVED store is the resurrection class: its store
464
+ outlives the checkout, so a push from a stale clone would recreate a claim
465
+ that store had already released. The fix must be visible on the floor and
466
+ absent over the API, and the only way to know is to look at what `push`
467
+ actually sends.
468
+ """
469
+ import importlib.util
470
+
471
+ spec = importlib.util.spec_from_file_location(
472
+ "roadmap_core.cli", PACKAGE_ROOT / "roadmap_core" / "cli.py"
473
+ )
474
+ cli = importlib.util.module_from_spec(spec)
475
+ spec.loader.exec_module(cli)
476
+
477
+ monkeypatch.setattr(cli, "REPO_ROOT", project)
478
+ monkeypatch.setattr(cli, "ITEMS_DIR", project / "roadmap" / "items")
479
+ monkeypatch.setattr(cli, "ARCS_DIR", project / "roadmap" / "arcs")
480
+ (project / "roadmap" / "items" / "first-thing.yaml").write_text(
481
+ ITEM + "claim:\n by: claude/stale-checkout\n at: '2026-01-01T00:00:00Z'\n"
482
+ )
483
+
484
+ sent: list[dict] = []
485
+ monkeypatch.setattr(cli, "_api", lambda m, p, payload=None: sent.append(payload or {}))
486
+ monkeypatch.setattr(cli, "load_from_db", dict)
487
+
488
+ cli.cmd_push(argparse.Namespace(source="db"))
489
+
490
+ item_payloads = [p for p in sent if "evidence" in p]
491
+ assert item_payloads, "push sent no item at all"
492
+ for payload in item_payloads:
493
+ assert "claimed_by" not in payload, (
494
+ "the served store must not be handed a claim from a file — a stale "
495
+ f"checkout would resurrect a released one: {payload}"
496
+ )
@@ -452,3 +452,85 @@ def test_json_columns_named_by_the_schema_are_the_ones_decoded(db):
452
452
  item = db.items()["alpha"]
453
453
  for column in store.JSON_COLUMNS["roadmap_items"]:
454
454
  assert not isinstance(item[column], str), f"{column} came back as raw JSON text"
455
+
456
+
457
+ # --- claim on the floor -------------------------------------------------------
458
+ #
459
+ # The store here is EPHEMERAL: CI deletes it and rebuilds it from the committed
460
+ # YAML every run. So a claim that a file records has to survive being pushed, or
461
+ # a held item renders as `ready` and the committed markdown can never match what
462
+ # CI generates. That is `a-claim-cannot-survive-the-floors-ci`, and these pin the
463
+ # ruling: authoritative on the floor, CREATE only, exactly like `status`.
464
+
465
+
466
+ def test_a_claim_in_the_file_survives_a_rebuild_of_the_store(db):
467
+ db.upsert_item({
468
+ "key": "alpha",
469
+ "title": "Alpha",
470
+ "status": "claimed",
471
+ "claimed_by": "claude/some-branch",
472
+ "claimed_at": "2026-08-22T10:00:00+00:00",
473
+ })
474
+ item = db.items()["alpha"]
475
+ assert item["claimed_by"] == "claude/some-branch"
476
+ assert item["claimed_at"] == "2026-08-22T10:00:00+00:00"
477
+ assert item["status"] == "claimed"
478
+
479
+
480
+ def test_an_update_cannot_move_a_claim(db):
481
+ """CREATE only, for the same reason `status` is.
482
+
483
+ On update the store is the live record of who holds what — a push from a
484
+ checkout that predates a release would otherwise hand the item back to a
485
+ session that has already let go.
486
+ """
487
+ db.upsert_item({"key": "alpha", "title": "Alpha"})
488
+ db.claim("alpha", by="claude/live")
489
+
490
+ db.upsert_item({
491
+ "key": "alpha",
492
+ "title": "Alpha",
493
+ "claimed_by": "claude/stale-checkout",
494
+ "claimed_at": "2026-01-01T00:00:00+00:00",
495
+ })
496
+
497
+ assert db.items()["alpha"]["claimed_by"] == "claude/live"
498
+
499
+
500
+ def test_an_unclaimed_file_creates_an_unclaimed_item(db):
501
+ """The ordinary case: no claim block, no claim. Asserted because the create
502
+ path now writes those columns explicitly rather than leaving them to the
503
+ schema default, and `None` is the value that has to arrive."""
504
+ db.upsert_item({"key": "alpha", "title": "Alpha"})
505
+ item = db.items()["alpha"]
506
+ assert item["claimed_by"] is None
507
+ assert item["claimed_at"] is None
508
+
509
+
510
+ def test_done_drops_the_claim(db):
511
+ """The CLI has always printed "done also drops the claim". On the floor it
512
+ did not — the served path clears the hold and this one did not, and nobody
513
+ could see the difference because a claim never survived a rebuild anyway.
514
+
515
+ Fixing claims-survive-a-rebuild is what made this visible: the notice about
516
+ an unreleased hold started firing on an item that had just been finished.
517
+ """
518
+ db.upsert_item({"key": "alpha", "title": "Alpha"})
519
+ db.claim("alpha", by="claude/holder")
520
+
521
+ row = db.set_status("alpha", "done")
522
+
523
+ assert row["status"] == "done"
524
+ assert row["claimed_by"] is None
525
+ assert row["claimed_at"] is None
526
+
527
+
528
+ def test_a_status_that_is_not_done_leaves_the_claim_alone(db):
529
+ """`verifying` explicitly does NOT drop it: the branch that shipped the work
530
+ still owns confirming it landed."""
531
+ db.upsert_item({"key": "alpha", "title": "Alpha"})
532
+ db.claim("alpha", by="claude/holder")
533
+
534
+ row = db.set_status("alpha", "verifying")
535
+
536
+ assert row["claimed_by"] == "claude/holder"
File without changes
File without changes