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.
- {tcw_cli-1.2.2/tcw_cli.egg-info → tcw_cli-1.2.3}/PKG-INFO +36 -1
- {tcw_cli-1.2.2 → tcw_cli-1.2.3}/README.md +35 -0
- {tcw_cli-1.2.2 → tcw_cli-1.2.3}/pyproject.toml +1 -1
- tcw_cli-1.2.3/tcw/__init__.py +1 -0
- {tcw_cli-1.2.2 → tcw_cli-1.2.3}/tcw/capabilities/cli.py +35 -8
- {tcw_cli-1.2.2 → tcw_cli-1.2.3}/tcw/refs.py +29 -4
- {tcw_cli-1.2.2 → tcw_cli-1.2.3}/tcw/serve/__init__.py +27 -3
- {tcw_cli-1.2.2 → tcw_cli-1.2.3}/tcw/store/base.py +52 -0
- {tcw_cli-1.2.2 → tcw_cli-1.2.3}/tcw/store/fs.py +417 -8
- {tcw_cli-1.2.2 → tcw_cli-1.2.3}/tcw/work/cli.py +29 -0
- {tcw_cli-1.2.2 → tcw_cli-1.2.3/tcw_cli.egg-info}/PKG-INFO +36 -1
- {tcw_cli-1.2.2 → tcw_cli-1.2.3}/tcw_cli.egg-info/SOURCES.txt +1 -0
- {tcw_cli-1.2.2 → tcw_cli-1.2.3}/tests/test_capabilities.py +135 -0
- {tcw_cli-1.2.2 → tcw_cli-1.2.3}/tests/test_non_git_writes.py +1 -0
- {tcw_cli-1.2.2 → tcw_cli-1.2.3}/tests/test_refs.py +62 -0
- {tcw_cli-1.2.2 → tcw_cli-1.2.3}/tests/test_serve_resolve.py +45 -0
- {tcw_cli-1.2.2 → tcw_cli-1.2.3}/tests/test_stage_verb.py +2 -1
- {tcw_cli-1.2.2 → tcw_cli-1.2.3}/tests/test_store_publication.py +17 -3
- tcw_cli-1.2.3/tests/test_tombstone.py +528 -0
- {tcw_cli-1.2.2 → tcw_cli-1.2.3}/tests/test_validate.py +62 -0
- tcw_cli-1.2.2/tcw/__init__.py +0 -1
- {tcw_cli-1.2.2 → tcw_cli-1.2.3}/LICENSE +0 -0
- {tcw_cli-1.2.2 → tcw_cli-1.2.3}/setup.cfg +0 -0
- {tcw_cli-1.2.2 → tcw_cli-1.2.3}/tcw/capabilities/__init__.py +0 -0
- {tcw_cli-1.2.2 → tcw_cli-1.2.3}/tcw/cli.py +0 -0
- {tcw_cli-1.2.2 → tcw_cli-1.2.3}/tcw/serve/dist/client/assets/index-CXNiOIeg.css +0 -0
- {tcw_cli-1.2.2 → tcw_cli-1.2.3}/tcw/serve/dist/client/assets/index-Dz5B7M8G.js +0 -0
- {tcw_cli-1.2.2 → tcw_cli-1.2.3}/tcw/serve/dist/client/index.html +0 -0
- {tcw_cli-1.2.2 → tcw_cli-1.2.3}/tcw/serve/dist/client/theme-init.js +0 -0
- {tcw_cli-1.2.2 → tcw_cli-1.2.3}/tcw/serve/dist/server.cjs +0 -0
- {tcw_cli-1.2.2 → tcw_cli-1.2.3}/tcw/serve/runtime.py +0 -0
- {tcw_cli-1.2.2 → tcw_cli-1.2.3}/tcw/stdin.py +0 -0
- {tcw_cli-1.2.2 → tcw_cli-1.2.3}/tcw/store/__init__.py +0 -0
- {tcw_cli-1.2.2 → tcw_cli-1.2.3}/tcw/store/project.py +0 -0
- {tcw_cli-1.2.2 → tcw_cli-1.2.3}/tcw/taxonomy/__init__.py +0 -0
- {tcw_cli-1.2.2 → tcw_cli-1.2.3}/tcw/taxonomy/cli.py +0 -0
- {tcw_cli-1.2.2 → tcw_cli-1.2.3}/tcw/validate.py +0 -0
- {tcw_cli-1.2.2 → tcw_cli-1.2.3}/tcw/work/__init__.py +0 -0
- {tcw_cli-1.2.2 → tcw_cli-1.2.3}/tcw/work/generate.py +0 -0
- {tcw_cli-1.2.2 → tcw_cli-1.2.3}/tcw/work/hooks.py +0 -0
- {tcw_cli-1.2.2 → tcw_cli-1.2.3}/tcw/work/projection.py +0 -0
- {tcw_cli-1.2.2 → tcw_cli-1.2.3}/tcw/work/prompts/implement.md +0 -0
- {tcw_cli-1.2.2 → tcw_cli-1.2.3}/tcw/work/prompts/plan.md +0 -0
- {tcw_cli-1.2.2 → tcw_cli-1.2.3}/tcw/work/prompts/postmortem.md +0 -0
- {tcw_cli-1.2.2 → tcw_cli-1.2.3}/tcw/work/prompts/request.md +0 -0
- {tcw_cli-1.2.2 → tcw_cli-1.2.3}/tcw/work/prompts/spec.md +0 -0
- {tcw_cli-1.2.2 → tcw_cli-1.2.3}/tcw/work/prompts/verify.md +0 -0
- {tcw_cli-1.2.2 → tcw_cli-1.2.3}/tcw/work/recursion.py +0 -0
- {tcw_cli-1.2.2 → tcw_cli-1.2.3}/tcw/work/resolve.py +0 -0
- {tcw_cli-1.2.2 → tcw_cli-1.2.3}/tcw/work/templates.py +0 -0
- {tcw_cli-1.2.2 → tcw_cli-1.2.3}/tcw_cli.egg-info/dependency_links.txt +0 -0
- {tcw_cli-1.2.2 → tcw_cli-1.2.3}/tcw_cli.egg-info/entry_points.txt +0 -0
- {tcw_cli-1.2.2 → tcw_cli-1.2.3}/tcw_cli.egg-info/requires.txt +0 -0
- {tcw_cli-1.2.2 → tcw_cli-1.2.3}/tcw_cli.egg-info/top_level.txt +0 -0
- {tcw_cli-1.2.2 → tcw_cli-1.2.3}/tests/test_body_prompt.py +0 -0
- {tcw_cli-1.2.2 → tcw_cli-1.2.3}/tests/test_capabilities_federation.py +0 -0
- {tcw_cli-1.2.2 → tcw_cli-1.2.3}/tests/test_capabilities_reset.py +0 -0
- {tcw_cli-1.2.2 → tcw_cli-1.2.3}/tests/test_capabilities_sidecar.py +0 -0
- {tcw_cli-1.2.2 → tcw_cli-1.2.3}/tests/test_capability_ref_wording.py +0 -0
- {tcw_cli-1.2.2 → tcw_cli-1.2.3}/tests/test_cut_version.py +0 -0
- {tcw_cli-1.2.2 → tcw_cli-1.2.3}/tests/test_documentation_config.py +0 -0
- {tcw_cli-1.2.2 → tcw_cli-1.2.3}/tests/test_documentation_prompt.py +0 -0
- {tcw_cli-1.2.2 → tcw_cli-1.2.3}/tests/test_documentation_sync_wiring.py +0 -0
- {tcw_cli-1.2.2 → tcw_cli-1.2.3}/tests/test_documented_cli_surface.py +0 -0
- {tcw_cli-1.2.2 → tcw_cli-1.2.3}/tests/test_environment_hardness.py +0 -0
- {tcw_cli-1.2.2 → tcw_cli-1.2.3}/tests/test_epic_completable.py +0 -0
- {tcw_cli-1.2.2 → tcw_cli-1.2.3}/tests/test_external_work_store.py +0 -0
- {tcw_cli-1.2.2 → tcw_cli-1.2.3}/tests/test_falsification_rule.py +0 -0
- {tcw_cli-1.2.2 → tcw_cli-1.2.3}/tests/test_generate_hook.py +0 -0
- {tcw_cli-1.2.2 → tcw_cli-1.2.3}/tests/test_inbox_title.py +0 -0
- {tcw_cli-1.2.2 → tcw_cli-1.2.3}/tests/test_lifecycle_baseline.py +0 -0
- {tcw_cli-1.2.2 → tcw_cli-1.2.3}/tests/test_lifecycle_hooks.py +0 -0
- {tcw_cli-1.2.2 → tcw_cli-1.2.3}/tests/test_lifecycle_inert.py +0 -0
- {tcw_cli-1.2.2 → tcw_cli-1.2.3}/tests/test_lifecycle_policy.py +0 -0
- {tcw_cli-1.2.2 → tcw_cli-1.2.3}/tests/test_lifecycle_validation.py +0 -0
- {tcw_cli-1.2.2 → tcw_cli-1.2.3}/tests/test_multiproject.py +0 -0
- {tcw_cli-1.2.2 → tcw_cli-1.2.3}/tests/test_plugin_manifests.py +0 -0
- {tcw_cli-1.2.2 → tcw_cli-1.2.3}/tests/test_project_registry.py +0 -0
- {tcw_cli-1.2.2 → tcw_cli-1.2.3}/tests/test_projection.py +0 -0
- {tcw_cli-1.2.2 → tcw_cli-1.2.3}/tests/test_prompt_fallback.py +0 -0
- {tcw_cli-1.2.2 → tcw_cli-1.2.3}/tests/test_qualified_ref.py +0 -0
- {tcw_cli-1.2.2 → tcw_cli-1.2.3}/tests/test_recursion.py +0 -0
- {tcw_cli-1.2.2 → tcw_cli-1.2.3}/tests/test_remote_session_setup.py +0 -0
- {tcw_cli-1.2.2 → tcw_cli-1.2.3}/tests/test_repo_lifecycle.py +0 -0
- {tcw_cli-1.2.2 → tcw_cli-1.2.3}/tests/test_resolve.py +0 -0
- {tcw_cli-1.2.2 → tcw_cli-1.2.3}/tests/test_scaffold.py +0 -0
- {tcw_cli-1.2.2 → tcw_cli-1.2.3}/tests/test_serve.py +0 -0
- {tcw_cli-1.2.2 → tcw_cli-1.2.3}/tests/test_serve_descendants.py +0 -0
- {tcw_cli-1.2.2 → tcw_cli-1.2.3}/tests/test_serve_projection.py +0 -0
- {tcw_cli-1.2.2 → tcw_cli-1.2.3}/tests/test_serve_runtime.py +0 -0
- {tcw_cli-1.2.2 → tcw_cli-1.2.3}/tests/test_serve_write.py +0 -0
- {tcw_cli-1.2.2 → tcw_cli-1.2.3}/tests/test_session_bootstrap.py +0 -0
- {tcw_cli-1.2.2 → tcw_cli-1.2.3}/tests/test_shipped_prompts.py +0 -0
- {tcw_cli-1.2.2 → tcw_cli-1.2.3}/tests/test_show_json.py +0 -0
- {tcw_cli-1.2.2 → tcw_cli-1.2.3}/tests/test_skill_flow.py +0 -0
- {tcw_cli-1.2.2 → tcw_cli-1.2.3}/tests/test_skill_lifecycle_parity.py +0 -0
- {tcw_cli-1.2.2 → tcw_cli-1.2.3}/tests/test_smoke.py +0 -0
- {tcw_cli-1.2.2 → tcw_cli-1.2.3}/tests/test_status_parity.py +0 -0
- {tcw_cli-1.2.2 → tcw_cli-1.2.3}/tests/test_stdin.py +0 -0
- {tcw_cli-1.2.2 → tcw_cli-1.2.3}/tests/test_stdin_cli.py +0 -0
- {tcw_cli-1.2.2 → tcw_cli-1.2.3}/tests/test_store_bounds.py +0 -0
- {tcw_cli-1.2.2 → tcw_cli-1.2.3}/tests/test_store_editor.py +0 -0
- {tcw_cli-1.2.2 → tcw_cli-1.2.3}/tests/test_store_nodes.py +0 -0
- {tcw_cli-1.2.2 → tcw_cli-1.2.3}/tests/test_store_provisioning.py +0 -0
- {tcw_cli-1.2.2 → tcw_cli-1.2.3}/tests/test_subprocess_stdin.py +0 -0
- {tcw_cli-1.2.2 → tcw_cli-1.2.3}/tests/test_taxonomy.py +0 -0
- {tcw_cli-1.2.2 → tcw_cli-1.2.3}/tests/test_unpushed_version_script.py +0 -0
- {tcw_cli-1.2.2 → tcw_cli-1.2.3}/tests/test_validate_target.py +0 -0
- {tcw_cli-1.2.2 → tcw_cli-1.2.3}/tests/test_work.py +0 -0
- {tcw_cli-1.2.2 → tcw_cli-1.2.3}/tests/test_work_autocommit.py +0 -0
- {tcw_cli-1.2.2 → tcw_cli-1.2.3}/tests/test_work_review.py +0 -0
- {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.
|
|
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.
|
|
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
|
|
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
|
-
|
|
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
|
|
12
|
-
|
|
13
|
-
|
|
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
|
-
|
|
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
|
|
993
|
-
# non-empty iff "unhosted-project", `detail` present iff
|
|
994
|
-
# "unresolved"
|
|
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
|
|