tcw-cli 1.2.2__tar.gz → 1.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 (112) hide show
  1. {tcw_cli-1.2.2/tcw_cli.egg-info → tcw_cli-1.2.3}/PKG-INFO +36 -1
  2. {tcw_cli-1.2.2 → tcw_cli-1.2.3}/README.md +35 -0
  3. {tcw_cli-1.2.2 → tcw_cli-1.2.3}/pyproject.toml +1 -1
  4. tcw_cli-1.2.3/tcw/__init__.py +1 -0
  5. {tcw_cli-1.2.2 → tcw_cli-1.2.3}/tcw/capabilities/cli.py +35 -8
  6. {tcw_cli-1.2.2 → tcw_cli-1.2.3}/tcw/refs.py +29 -4
  7. {tcw_cli-1.2.2 → tcw_cli-1.2.3}/tcw/serve/__init__.py +27 -3
  8. {tcw_cli-1.2.2 → tcw_cli-1.2.3}/tcw/store/base.py +52 -0
  9. {tcw_cli-1.2.2 → tcw_cli-1.2.3}/tcw/store/fs.py +417 -8
  10. {tcw_cli-1.2.2 → tcw_cli-1.2.3}/tcw/work/cli.py +29 -0
  11. {tcw_cli-1.2.2 → tcw_cli-1.2.3/tcw_cli.egg-info}/PKG-INFO +36 -1
  12. {tcw_cli-1.2.2 → tcw_cli-1.2.3}/tcw_cli.egg-info/SOURCES.txt +1 -0
  13. {tcw_cli-1.2.2 → tcw_cli-1.2.3}/tests/test_capabilities.py +135 -0
  14. {tcw_cli-1.2.2 → tcw_cli-1.2.3}/tests/test_non_git_writes.py +1 -0
  15. {tcw_cli-1.2.2 → tcw_cli-1.2.3}/tests/test_refs.py +62 -0
  16. {tcw_cli-1.2.2 → tcw_cli-1.2.3}/tests/test_serve_resolve.py +45 -0
  17. {tcw_cli-1.2.2 → tcw_cli-1.2.3}/tests/test_stage_verb.py +2 -1
  18. {tcw_cli-1.2.2 → tcw_cli-1.2.3}/tests/test_store_publication.py +17 -3
  19. tcw_cli-1.2.3/tests/test_tombstone.py +528 -0
  20. {tcw_cli-1.2.2 → tcw_cli-1.2.3}/tests/test_validate.py +62 -0
  21. tcw_cli-1.2.2/tcw/__init__.py +0 -1
  22. {tcw_cli-1.2.2 → tcw_cli-1.2.3}/LICENSE +0 -0
  23. {tcw_cli-1.2.2 → tcw_cli-1.2.3}/setup.cfg +0 -0
  24. {tcw_cli-1.2.2 → tcw_cli-1.2.3}/tcw/capabilities/__init__.py +0 -0
  25. {tcw_cli-1.2.2 → tcw_cli-1.2.3}/tcw/cli.py +0 -0
  26. {tcw_cli-1.2.2 → tcw_cli-1.2.3}/tcw/serve/dist/client/assets/index-CXNiOIeg.css +0 -0
  27. {tcw_cli-1.2.2 → tcw_cli-1.2.3}/tcw/serve/dist/client/assets/index-Dz5B7M8G.js +0 -0
  28. {tcw_cli-1.2.2 → tcw_cli-1.2.3}/tcw/serve/dist/client/index.html +0 -0
  29. {tcw_cli-1.2.2 → tcw_cli-1.2.3}/tcw/serve/dist/client/theme-init.js +0 -0
  30. {tcw_cli-1.2.2 → tcw_cli-1.2.3}/tcw/serve/dist/server.cjs +0 -0
  31. {tcw_cli-1.2.2 → tcw_cli-1.2.3}/tcw/serve/runtime.py +0 -0
  32. {tcw_cli-1.2.2 → tcw_cli-1.2.3}/tcw/stdin.py +0 -0
  33. {tcw_cli-1.2.2 → tcw_cli-1.2.3}/tcw/store/__init__.py +0 -0
  34. {tcw_cli-1.2.2 → tcw_cli-1.2.3}/tcw/store/project.py +0 -0
  35. {tcw_cli-1.2.2 → tcw_cli-1.2.3}/tcw/taxonomy/__init__.py +0 -0
  36. {tcw_cli-1.2.2 → tcw_cli-1.2.3}/tcw/taxonomy/cli.py +0 -0
  37. {tcw_cli-1.2.2 → tcw_cli-1.2.3}/tcw/validate.py +0 -0
  38. {tcw_cli-1.2.2 → tcw_cli-1.2.3}/tcw/work/__init__.py +0 -0
  39. {tcw_cli-1.2.2 → tcw_cli-1.2.3}/tcw/work/generate.py +0 -0
  40. {tcw_cli-1.2.2 → tcw_cli-1.2.3}/tcw/work/hooks.py +0 -0
  41. {tcw_cli-1.2.2 → tcw_cli-1.2.3}/tcw/work/projection.py +0 -0
  42. {tcw_cli-1.2.2 → tcw_cli-1.2.3}/tcw/work/prompts/implement.md +0 -0
  43. {tcw_cli-1.2.2 → tcw_cli-1.2.3}/tcw/work/prompts/plan.md +0 -0
  44. {tcw_cli-1.2.2 → tcw_cli-1.2.3}/tcw/work/prompts/postmortem.md +0 -0
  45. {tcw_cli-1.2.2 → tcw_cli-1.2.3}/tcw/work/prompts/request.md +0 -0
  46. {tcw_cli-1.2.2 → tcw_cli-1.2.3}/tcw/work/prompts/spec.md +0 -0
  47. {tcw_cli-1.2.2 → tcw_cli-1.2.3}/tcw/work/prompts/verify.md +0 -0
  48. {tcw_cli-1.2.2 → tcw_cli-1.2.3}/tcw/work/recursion.py +0 -0
  49. {tcw_cli-1.2.2 → tcw_cli-1.2.3}/tcw/work/resolve.py +0 -0
  50. {tcw_cli-1.2.2 → tcw_cli-1.2.3}/tcw/work/templates.py +0 -0
  51. {tcw_cli-1.2.2 → tcw_cli-1.2.3}/tcw_cli.egg-info/dependency_links.txt +0 -0
  52. {tcw_cli-1.2.2 → tcw_cli-1.2.3}/tcw_cli.egg-info/entry_points.txt +0 -0
  53. {tcw_cli-1.2.2 → tcw_cli-1.2.3}/tcw_cli.egg-info/requires.txt +0 -0
  54. {tcw_cli-1.2.2 → tcw_cli-1.2.3}/tcw_cli.egg-info/top_level.txt +0 -0
  55. {tcw_cli-1.2.2 → tcw_cli-1.2.3}/tests/test_body_prompt.py +0 -0
  56. {tcw_cli-1.2.2 → tcw_cli-1.2.3}/tests/test_capabilities_federation.py +0 -0
  57. {tcw_cli-1.2.2 → tcw_cli-1.2.3}/tests/test_capabilities_reset.py +0 -0
  58. {tcw_cli-1.2.2 → tcw_cli-1.2.3}/tests/test_capabilities_sidecar.py +0 -0
  59. {tcw_cli-1.2.2 → tcw_cli-1.2.3}/tests/test_capability_ref_wording.py +0 -0
  60. {tcw_cli-1.2.2 → tcw_cli-1.2.3}/tests/test_cut_version.py +0 -0
  61. {tcw_cli-1.2.2 → tcw_cli-1.2.3}/tests/test_documentation_config.py +0 -0
  62. {tcw_cli-1.2.2 → tcw_cli-1.2.3}/tests/test_documentation_prompt.py +0 -0
  63. {tcw_cli-1.2.2 → tcw_cli-1.2.3}/tests/test_documentation_sync_wiring.py +0 -0
  64. {tcw_cli-1.2.2 → tcw_cli-1.2.3}/tests/test_documented_cli_surface.py +0 -0
  65. {tcw_cli-1.2.2 → tcw_cli-1.2.3}/tests/test_environment_hardness.py +0 -0
  66. {tcw_cli-1.2.2 → tcw_cli-1.2.3}/tests/test_epic_completable.py +0 -0
  67. {tcw_cli-1.2.2 → tcw_cli-1.2.3}/tests/test_external_work_store.py +0 -0
  68. {tcw_cli-1.2.2 → tcw_cli-1.2.3}/tests/test_falsification_rule.py +0 -0
  69. {tcw_cli-1.2.2 → tcw_cli-1.2.3}/tests/test_generate_hook.py +0 -0
  70. {tcw_cli-1.2.2 → tcw_cli-1.2.3}/tests/test_inbox_title.py +0 -0
  71. {tcw_cli-1.2.2 → tcw_cli-1.2.3}/tests/test_lifecycle_baseline.py +0 -0
  72. {tcw_cli-1.2.2 → tcw_cli-1.2.3}/tests/test_lifecycle_hooks.py +0 -0
  73. {tcw_cli-1.2.2 → tcw_cli-1.2.3}/tests/test_lifecycle_inert.py +0 -0
  74. {tcw_cli-1.2.2 → tcw_cli-1.2.3}/tests/test_lifecycle_policy.py +0 -0
  75. {tcw_cli-1.2.2 → tcw_cli-1.2.3}/tests/test_lifecycle_validation.py +0 -0
  76. {tcw_cli-1.2.2 → tcw_cli-1.2.3}/tests/test_multiproject.py +0 -0
  77. {tcw_cli-1.2.2 → tcw_cli-1.2.3}/tests/test_plugin_manifests.py +0 -0
  78. {tcw_cli-1.2.2 → tcw_cli-1.2.3}/tests/test_project_registry.py +0 -0
  79. {tcw_cli-1.2.2 → tcw_cli-1.2.3}/tests/test_projection.py +0 -0
  80. {tcw_cli-1.2.2 → tcw_cli-1.2.3}/tests/test_prompt_fallback.py +0 -0
  81. {tcw_cli-1.2.2 → tcw_cli-1.2.3}/tests/test_qualified_ref.py +0 -0
  82. {tcw_cli-1.2.2 → tcw_cli-1.2.3}/tests/test_recursion.py +0 -0
  83. {tcw_cli-1.2.2 → tcw_cli-1.2.3}/tests/test_remote_session_setup.py +0 -0
  84. {tcw_cli-1.2.2 → tcw_cli-1.2.3}/tests/test_repo_lifecycle.py +0 -0
  85. {tcw_cli-1.2.2 → tcw_cli-1.2.3}/tests/test_resolve.py +0 -0
  86. {tcw_cli-1.2.2 → tcw_cli-1.2.3}/tests/test_scaffold.py +0 -0
  87. {tcw_cli-1.2.2 → tcw_cli-1.2.3}/tests/test_serve.py +0 -0
  88. {tcw_cli-1.2.2 → tcw_cli-1.2.3}/tests/test_serve_descendants.py +0 -0
  89. {tcw_cli-1.2.2 → tcw_cli-1.2.3}/tests/test_serve_projection.py +0 -0
  90. {tcw_cli-1.2.2 → tcw_cli-1.2.3}/tests/test_serve_runtime.py +0 -0
  91. {tcw_cli-1.2.2 → tcw_cli-1.2.3}/tests/test_serve_write.py +0 -0
  92. {tcw_cli-1.2.2 → tcw_cli-1.2.3}/tests/test_session_bootstrap.py +0 -0
  93. {tcw_cli-1.2.2 → tcw_cli-1.2.3}/tests/test_shipped_prompts.py +0 -0
  94. {tcw_cli-1.2.2 → tcw_cli-1.2.3}/tests/test_show_json.py +0 -0
  95. {tcw_cli-1.2.2 → tcw_cli-1.2.3}/tests/test_skill_flow.py +0 -0
  96. {tcw_cli-1.2.2 → tcw_cli-1.2.3}/tests/test_skill_lifecycle_parity.py +0 -0
  97. {tcw_cli-1.2.2 → tcw_cli-1.2.3}/tests/test_smoke.py +0 -0
  98. {tcw_cli-1.2.2 → tcw_cli-1.2.3}/tests/test_status_parity.py +0 -0
  99. {tcw_cli-1.2.2 → tcw_cli-1.2.3}/tests/test_stdin.py +0 -0
  100. {tcw_cli-1.2.2 → tcw_cli-1.2.3}/tests/test_stdin_cli.py +0 -0
  101. {tcw_cli-1.2.2 → tcw_cli-1.2.3}/tests/test_store_bounds.py +0 -0
  102. {tcw_cli-1.2.2 → tcw_cli-1.2.3}/tests/test_store_editor.py +0 -0
  103. {tcw_cli-1.2.2 → tcw_cli-1.2.3}/tests/test_store_nodes.py +0 -0
  104. {tcw_cli-1.2.2 → tcw_cli-1.2.3}/tests/test_store_provisioning.py +0 -0
  105. {tcw_cli-1.2.2 → tcw_cli-1.2.3}/tests/test_subprocess_stdin.py +0 -0
  106. {tcw_cli-1.2.2 → tcw_cli-1.2.3}/tests/test_taxonomy.py +0 -0
  107. {tcw_cli-1.2.2 → tcw_cli-1.2.3}/tests/test_unpushed_version_script.py +0 -0
  108. {tcw_cli-1.2.2 → tcw_cli-1.2.3}/tests/test_validate_target.py +0 -0
  109. {tcw_cli-1.2.2 → tcw_cli-1.2.3}/tests/test_work.py +0 -0
  110. {tcw_cli-1.2.2 → tcw_cli-1.2.3}/tests/test_work_autocommit.py +0 -0
  111. {tcw_cli-1.2.2 → tcw_cli-1.2.3}/tests/test_work_review.py +0 -0
  112. {tcw_cli-1.2.2 → tcw_cli-1.2.3}/tests/test_work_tags.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: tcw-cli
3
- Version: 1.2.2
3
+ Version: 1.2.3
4
4
  Summary: Taxonomy · Capabilities · Work — a storage-abstracted framework for describing and evolving a software project.
5
5
  License: Apache License
6
6
  Version 2.0, January 2004
@@ -947,6 +947,41 @@ Blocked-ness is a **derived overlay**: an item is blocked when it has at least
947
947
  one unresolved blocker recorded in its data — there is no separate "blocked"
948
948
  folder or status.
949
949
 
950
+ ### References to work that is finished
951
+
952
+ Resolving an item takes its documents out of the tracked tree, so a
953
+ `tcw://W/<slug>` link to it would have nothing to resolve against. Completing
954
+ and discarding therefore **record the slug**, in `graveyard.yaml` beside the
955
+ status folders, and the record rides the same commit as the status change. A
956
+ reference to finished work then keeps resolving: `tcw validate` says nothing
957
+ about it, and `tcw serve` shows it inert rather than broken. A reference to a
958
+ slug the project never held is still an error, in the same words as before —
959
+ that distinction is the whole point of the record.
960
+
961
+ The record says the slug existed and how it was resolved. It deliberately does
962
+ **not** say where the documents went: any such pointer stops working the moment
963
+ history is squashed, rebased, or shallowly cloned, and a pointer that quietly
964
+ breaks is worse than none. How long resolved documents are kept stays your
965
+ call — `completed/` and `discarded/` are gitignored by default, and a project
966
+ that wants them in the tracked tree simply does not ignore them.
967
+
968
+ For work resolved **before** your project kept these records — including
969
+ everything resolved before this feature existed — record a slug by hand:
970
+
971
+ ```bash
972
+ tcw work tombstone add <slug> --resolution done --resolved 2026-09-01
973
+ ```
974
+
975
+ Both flags are optional; omit them when nobody kept the detail, since the
976
+ record's job is to say the slug existed.
977
+
978
+ Run it wherever you are — including on the machine that resolved the work, where
979
+ the item's folder is usually still sitting on disk. It refuses only a slug that
980
+ is *live*, and a slug already in the graveyard, so re-running it over a list is
981
+ safe and will not quietly replace a good record with a blank one. It commits
982
+ what it writes, and on a store with a configured remote it publishes too, since
983
+ a record nobody else can see does not do its job.
984
+
950
985
  ### Binding your own skills and commands to the lifecycle
951
986
 
952
987
  The lifecycle has named **stages** (each producing one document) and named
@@ -731,6 +731,41 @@ Blocked-ness is a **derived overlay**: an item is blocked when it has at least
731
731
  one unresolved blocker recorded in its data — there is no separate "blocked"
732
732
  folder or status.
733
733
 
734
+ ### References to work that is finished
735
+
736
+ Resolving an item takes its documents out of the tracked tree, so a
737
+ `tcw://W/<slug>` link to it would have nothing to resolve against. Completing
738
+ and discarding therefore **record the slug**, in `graveyard.yaml` beside the
739
+ status folders, and the record rides the same commit as the status change. A
740
+ reference to finished work then keeps resolving: `tcw validate` says nothing
741
+ about it, and `tcw serve` shows it inert rather than broken. A reference to a
742
+ slug the project never held is still an error, in the same words as before —
743
+ that distinction is the whole point of the record.
744
+
745
+ The record says the slug existed and how it was resolved. It deliberately does
746
+ **not** say where the documents went: any such pointer stops working the moment
747
+ history is squashed, rebased, or shallowly cloned, and a pointer that quietly
748
+ breaks is worse than none. How long resolved documents are kept stays your
749
+ call — `completed/` and `discarded/` are gitignored by default, and a project
750
+ that wants them in the tracked tree simply does not ignore them.
751
+
752
+ For work resolved **before** your project kept these records — including
753
+ everything resolved before this feature existed — record a slug by hand:
754
+
755
+ ```bash
756
+ tcw work tombstone add <slug> --resolution done --resolved 2026-09-01
757
+ ```
758
+
759
+ Both flags are optional; omit them when nobody kept the detail, since the
760
+ record's job is to say the slug existed.
761
+
762
+ Run it wherever you are — including on the machine that resolved the work, where
763
+ the item's folder is usually still sitting on disk. It refuses only a slug that
764
+ is *live*, and a slug already in the graveyard, so re-running it over a list is
765
+ safe and will not quietly replace a good record with a blank one. It commits
766
+ what it writes, and on a store with a configured remote it publishes too, since
767
+ a record nobody else can see does not do its job.
768
+
734
769
  ### Binding your own skills and commands to the lifecycle
735
770
 
736
771
  The lifecycle has named **stages** (each producing one document) and named
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "tcw-cli"
7
- version = "1.2.2"
7
+ version = "1.2.3"
8
8
  description = "Taxonomy · Capabilities · Work — a storage-abstracted framework for describing and evolving a software project."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -0,0 +1 @@
1
+ __version__ = "1.2.3"
@@ -4,7 +4,7 @@ import argparse
4
4
  import sys
5
5
 
6
6
  from tcw.stdin import read_piped_stdin
7
- from tcw.store.base import Capability, RefError
7
+ from tcw.store.base import Capability, RefError, resolution_status
8
8
  from tcw.store.base import AmbiguousRef
9
9
  from tcw.store.fs import FsCapabilitiesStore, find_node, git_root
10
10
 
@@ -184,9 +184,15 @@ def _drift(args: argparse.Namespace) -> int:
184
184
 
185
185
 
186
186
  def _shipped_but_missing(node, st) -> list[tuple[str, str]]:
187
- """Local Missing capabilities whose `Planning doc` names a completed work item.
187
+ """Local Missing capabilities whose `Planning doc` names work that shipped.
188
+
188
189
  Read-only follow of an existing capability→work forward pointer; degrades to
189
- empty when no work node is present (no hard cross-axis dependency)."""
190
+ empty when no work node is present (no hard cross-axis dependency).
191
+
192
+ "Shipped" is answered from the live item where there is one and from its
193
+ tombstone where there is not, so the verdict is a property of the project
194
+ rather than of whose working directory it runs in.
195
+ """
190
196
  from tcw.store.fs import FsWorkStore
191
197
  try:
192
198
  work = FsWorkStore.open(node) # the *configured* store, wherever it is
@@ -199,16 +205,37 @@ def _shipped_but_missing(node, st) -> list[tuple[str, str]]:
199
205
  slug = c.fields.get("Planning doc")
200
206
  if not slug:
201
207
  continue
202
- try:
203
- item = work.get(str(slug))
204
- except Exception:
205
- item = None
206
208
  # `completed` alone, deliberately NOT `RESOLVED_STATUSES`: this asks
207
209
  # "did it ship?", not "is it closed?". A discarded item's capability is
208
210
  # *supposed* to stay Missing (or be marked Omitted) — reporting it as
209
211
  # shipped-but-unreconciled would be a false positive, which is exactly
210
212
  # what happened before `discarded` existed.
211
- if item is not None and item.status == "completed":
213
+ #
214
+ # Asked of the tombstone as well as the item, because `get()` alone made
215
+ # the answer depend on who was asking: a resolved item's folder is
216
+ # gitignored, so it is present for whoever ran the transition and reaches
217
+ # no other clone. `get()` answered there and returned None everywhere
218
+ # else, so this reported drift on one machine and "no capability drift"
219
+ # in CI — failing in the quiet direction, which is the one nobody
220
+ # notices. `resolution_status` maps the record onto the same status
221
+ # `complete()` derives, so the ship/abandon distinction above is drawn
222
+ # once rather than twice.
223
+ try:
224
+ item = work.get(str(slug))
225
+ if item is not None:
226
+ shipped = item.status == "completed"
227
+ else:
228
+ grave = work.tombstone(str(slug))
229
+ # No record: the slug names nothing this store ever held, which
230
+ # is a typo, not finished work. An unrecorded resolution: the
231
+ # store held it but nobody kept how it ended — report nothing
232
+ # rather than guess, because guessing wrong here means calling
233
+ # abandoned work shipped.
234
+ shipped = bool(grave and grave.resolution
235
+ and resolution_status(grave.resolution) == "completed")
236
+ except Exception:
237
+ shipped = False # a store that cannot answer is silent
238
+ if shipped:
212
239
  out.append((c.path, str(slug)))
213
240
  return out
214
241
 
@@ -8,9 +8,16 @@ Grammar: ``tcw://[<namespace>/]<axis>/<ref>``
8
8
 
9
9
  `parse_tcw_uri` is a pure, total function (never raises) — the abstract grammar.
10
10
  `resolve_tcw_ref` is thin CLI/serve adapter glue: it imports the FS stores and
11
- dispatches through their existing ``get()`` / ``resolve_qualified_work_ref`` (no
12
- new store-interface method — litmus-clean), and never propagates a store
13
- exception to a caller scanning many links.
11
+ dispatches through ``get()`` / ``tombstone()`` / ``resolve_qualified_work_ref``,
12
+ and never propagates a store exception to a caller scanning many links.
13
+
14
+ This module once recorded that it added *no* new store-interface method. That
15
+ stopped being true when work references learned to tell a resolved item from a
16
+ slug nobody created: `get()` alone cannot draw that distinction, because a store
17
+ whose resolved items leave it answers None to both. `tombstone()` is the second
18
+ abstract read, and it is abstract rather than a filesystem check on purpose —
19
+ any store can answer "did I ever hold this id", and one that keeps its resolved
20
+ items answers it without storing anything extra.
14
21
  """
15
22
 
16
23
  from __future__ import annotations
@@ -44,6 +51,12 @@ class ResolveResult:
44
51
  key: str | None
45
52
  reason: str
46
53
  project: str = "" # owning project id for a foreign work ref; "" = local
54
+ # A work ref that resolved to a *record* of a resolved item rather than to a
55
+ # live one. Still `ok` — the reference is sound and names real, finished work
56
+ # — but there is nothing to navigate to, so a viewer shows it inert rather
57
+ # than as a link, and `tcw validate` says nothing at all.
58
+ archived: bool = False
59
+ resolution: str = "" # the archived item's resolution; "" when unrecorded
47
60
 
48
61
 
49
62
  def _segment_ok(seg: str) -> bool:
@@ -130,8 +143,20 @@ def resolve_tcw_ref(node_root: Path | None, uri: str) -> ResolveResult:
130
143
  # `tcw://W/<slug>` reporting ok for a slug nobody ever created — passing
131
144
  # `tcw validate` and rendering as a link that 404s.
132
145
  if store.get(bare) is None:
146
+ # Not live — but "not live" and "never existed" are different
147
+ # answers, and reporting them identically is what made this check
148
+ # machine-dependent: a resolved item's folder is ignored, so it is
149
+ # present for whoever ran the transition and absent for everyone
150
+ # else, and the same reference passed there and failed here.
151
+ grave = store.tombstone(bare)
152
+ if grave is None:
153
+ return ResolveResult(
154
+ False, "W", None, qualified_work_ref_problem(node_root, ns_ref))
155
+ local = store.node_root == node_root.resolve()
133
156
  return ResolveResult(
134
- False, "W", None, qualified_work_ref_problem(node_root, ns_ref))
157
+ True, "W", bare if local else f"{ns_ref.partition('/')[0]}/{bare}",
158
+ "", "" if local else ns_ref.partition("/")[0],
159
+ archived=True, resolution=grave.resolution)
135
160
  if store.node_root == node_root.resolve(): # landed locally
136
161
  return ResolveResult(True, "W", bare, "")
137
162
  # Foreign: the qualifier is a project id (a status-path locator is always
@@ -989,13 +989,37 @@ class TcwHandler(BaseHTTPRequestHandler):
989
989
  r = resolve_tcw_ref(self.server.node_root, uri)
990
990
  # Failure objects carry why, because the viewer renders "valid but
991
991
  # not on this board" differently from "broken". The shape the SPA
992
- # branches on: `reason` is exactly one of two values, `project` is
993
- # non-empty iff "unhosted-project", `detail` present iff
994
- # "unresolved". A bare {"ok": false} never occurs.
992
+ # branches on: `reason` is exactly one of three values, `project`
993
+ # is non-empty iff "unhosted-project", `detail` present iff
994
+ # "unresolved" or "archived", `resolution` present iff
995
+ # "archived". A bare {"ok": false} never occurs.
995
996
  if not r.ok:
996
997
  result[uri] = {"ok": False, "reason": "unresolved",
997
998
  "detail": r.reason}
998
999
  continue
1000
+ if r.archived:
1001
+ # Sound reference, real finished work, nothing to open: the
1002
+ # item's documents left the tracked tree when it resolved.
1003
+ # `ok: false` for the same reason an off-board target gets it
1004
+ # — it is what stops the SPA writing a link that 404s — and
1005
+ # not the same thing as "broken", which is what this looked
1006
+ # like before resolved slugs were recorded at all.
1007
+ #
1008
+ # A client that knows nothing of `archived` falls through to
1009
+ # its unrecognized-reason branch, which neutralizes the anchor
1010
+ # and shows `detail`. That is already the right rendering, so
1011
+ # no client change is required for this to read correctly.
1012
+ # "resolved", not "completed": with no resolution recorded
1013
+ # this covers a discarded item too, and calling abandoned
1014
+ # work completed would be worse than saying less.
1015
+ detail = f"{r.key} is resolved work; its documents are no " \
1016
+ f"longer in the tree"
1017
+ if r.resolution:
1018
+ detail = f"{r.key} was resolved as {r.resolution}; its " \
1019
+ f"documents are no longer in the tree"
1020
+ result[uri] = {"ok": False, "reason": "archived",
1021
+ "detail": detail, "resolution": r.resolution}
1022
+ continue
999
1023
  if r.project:
1000
1024
  if hosted is None:
1001
1025
  hosted = self._hosted_projects()
@@ -1554,6 +1554,26 @@ class WorkItem:
1554
1554
  started: str = "" # UTC claim timestamp
1555
1555
 
1556
1556
 
1557
+ @dataclass(frozen=True)
1558
+ class Tombstone:
1559
+ """The record that `slug` named an item this store once held and has since
1560
+ resolved.
1561
+
1562
+ Answers exactly one question — *did this slug ever exist here?* — so that a
1563
+ reference to finished work is distinguishable from a reference to a slug
1564
+ nobody created. `resolution` and `resolved` are context for the reader; both
1565
+ may be empty on a degraded record, and neither changes the answer.
1566
+
1567
+ **There is deliberately no locator.** Recording where the item's documents
1568
+ went would promise they are retrievable there, and that promise does not
1569
+ survive a squash-merge, a rebase, or a shallow clone. A pointer that
1570
+ silently stops working is worse than no pointer.
1571
+ """
1572
+ slug: str
1573
+ resolution: str = ""
1574
+ resolved: str = ""
1575
+
1576
+
1557
1577
  @dataclass
1558
1578
  class Artifact:
1559
1579
  """A named lifecycle artifact associated with a work item."""
@@ -1697,6 +1717,38 @@ class WorkStore(ABC):
1697
1717
  def get(self, slug: str) -> WorkItem | None:
1698
1718
  """Resolve a stable id (slug) to its item, or None. Raises `MultipleMatch`."""
1699
1719
 
1720
+ @abstractmethod
1721
+ def tombstone(self, slug: str) -> Tombstone | None:
1722
+ """The record of an item this store once held and has since resolved, or
1723
+ None if it never held one by that id.
1724
+
1725
+ Distinct from `get`, which answers about *live* items only. An adapter
1726
+ whose resolved items remain retrievable by `get` — a tracker where a
1727
+ closed issue never stops existing — may answer from those directly and
1728
+ keep no separate record at all; one whose resolved items leave the store
1729
+ has to record something at resolution time in order to answer later.
1730
+
1731
+ A `None` here means the store has genuinely never held this id, which is
1732
+ what lets a caller report a typo as a typo.
1733
+ """
1734
+
1735
+ @abstractmethod
1736
+ def record_tombstone(self, slug: str, resolution: str = "",
1737
+ resolved: str = "") -> Tombstone:
1738
+ """Record that `slug` named an item this store held and has resolved,
1739
+ for one it resolved before it kept any record.
1740
+
1741
+ The backfill half of `tombstone`, and the only way an existing project
1742
+ gets the benefit: every reference written before tombstones existed names
1743
+ an item resolved before any record was kept. An adapter that already
1744
+ answers `tombstone` from its own resolved items has nothing to store and
1745
+ may treat this as a no-op.
1746
+
1747
+ Refuses a `slug` that is currently live — a store cannot both hold an
1748
+ item and have finished with it, and claiming otherwise would let slug
1749
+ assignment refuse an id that is legitimately in use.
1750
+ """
1751
+
1700
1752
  @abstractmethod
1701
1753
  def query(self, status: str | None = None) -> list[WorkItem]: ...
1702
1754