tcw-cli 1.2.2__tar.gz → 1.3.0__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 (116) hide show
  1. {tcw_cli-1.2.2/tcw_cli.egg-info → tcw_cli-1.3.0}/PKG-INFO +199 -10
  2. {tcw_cli-1.2.2 → tcw_cli-1.3.0}/README.md +198 -9
  3. {tcw_cli-1.2.2 → tcw_cli-1.3.0}/pyproject.toml +1 -1
  4. tcw_cli-1.3.0/tcw/__init__.py +1 -0
  5. {tcw_cli-1.2.2 → tcw_cli-1.3.0}/tcw/capabilities/cli.py +35 -8
  6. {tcw_cli-1.2.2 → tcw_cli-1.3.0}/tcw/cli.py +216 -3
  7. {tcw_cli-1.2.2 → tcw_cli-1.3.0}/tcw/refs.py +29 -4
  8. {tcw_cli-1.2.2 → tcw_cli-1.3.0}/tcw/serve/__init__.py +34 -3
  9. {tcw_cli-1.2.2 → tcw_cli-1.3.0}/tcw/store/base.py +431 -5
  10. tcw_cli-1.3.0/tcw/store/checkouts.py +88 -0
  11. {tcw_cli-1.2.2 → tcw_cli-1.3.0}/tcw/store/fs.py +1328 -162
  12. {tcw_cli-1.2.2 → tcw_cli-1.3.0}/tcw/store/project.py +234 -44
  13. {tcw_cli-1.2.2 → tcw_cli-1.3.0}/tcw/validate.py +43 -1
  14. {tcw_cli-1.2.2 → tcw_cli-1.3.0}/tcw/work/cli.py +270 -17
  15. {tcw_cli-1.2.2 → tcw_cli-1.3.0}/tcw/work/hooks.py +42 -8
  16. {tcw_cli-1.2.2 → tcw_cli-1.3.0}/tcw/work/recursion.py +30 -3
  17. {tcw_cli-1.2.2 → tcw_cli-1.3.0/tcw_cli.egg-info}/PKG-INFO +199 -10
  18. {tcw_cli-1.2.2 → tcw_cli-1.3.0}/tcw_cli.egg-info/SOURCES.txt +3 -0
  19. {tcw_cli-1.2.2 → tcw_cli-1.3.0}/tests/test_capabilities.py +135 -0
  20. {tcw_cli-1.2.2 → tcw_cli-1.3.0}/tests/test_capabilities_federation.py +254 -0
  21. {tcw_cli-1.2.2 → tcw_cli-1.3.0}/tests/test_epic_completable.py +156 -0
  22. {tcw_cli-1.2.2 → tcw_cli-1.3.0}/tests/test_lifecycle_policy.py +17 -5
  23. tcw_cli-1.3.0/tests/test_multiproject.py +352 -0
  24. {tcw_cli-1.2.2 → tcw_cli-1.3.0}/tests/test_non_git_writes.py +1 -0
  25. tcw_cli-1.3.0/tests/test_project_registry.py +579 -0
  26. {tcw_cli-1.2.2 → tcw_cli-1.3.0}/tests/test_qualified_ref.py +18 -0
  27. {tcw_cli-1.2.2 → tcw_cli-1.3.0}/tests/test_recursion.py +27 -0
  28. {tcw_cli-1.2.2 → tcw_cli-1.3.0}/tests/test_refs.py +62 -0
  29. tcw_cli-1.3.0/tests/test_retention.py +978 -0
  30. {tcw_cli-1.2.2 → tcw_cli-1.3.0}/tests/test_serve.py +36 -0
  31. {tcw_cli-1.2.2 → tcw_cli-1.3.0}/tests/test_serve_resolve.py +45 -0
  32. {tcw_cli-1.2.2 → tcw_cli-1.3.0}/tests/test_stage_verb.py +2 -1
  33. {tcw_cli-1.2.2 → tcw_cli-1.3.0}/tests/test_store_provisioning.py +544 -7
  34. {tcw_cli-1.2.2 → tcw_cli-1.3.0}/tests/test_store_publication.py +52 -3
  35. {tcw_cli-1.2.2 → tcw_cli-1.3.0}/tests/test_taxonomy.py +68 -0
  36. tcw_cli-1.3.0/tests/test_tombstone.py +528 -0
  37. {tcw_cli-1.2.2 → tcw_cli-1.3.0}/tests/test_validate.py +145 -0
  38. {tcw_cli-1.2.2 → tcw_cli-1.3.0}/tests/test_validate_target.py +20 -1
  39. tcw_cli-1.2.2/tcw/__init__.py +0 -1
  40. tcw_cli-1.2.2/tests/test_multiproject.py +0 -54
  41. tcw_cli-1.2.2/tests/test_project_registry.py +0 -233
  42. {tcw_cli-1.2.2 → tcw_cli-1.3.0}/LICENSE +0 -0
  43. {tcw_cli-1.2.2 → tcw_cli-1.3.0}/setup.cfg +0 -0
  44. {tcw_cli-1.2.2 → tcw_cli-1.3.0}/tcw/capabilities/__init__.py +0 -0
  45. {tcw_cli-1.2.2 → tcw_cli-1.3.0}/tcw/serve/dist/client/assets/index-CXNiOIeg.css +0 -0
  46. {tcw_cli-1.2.2 → tcw_cli-1.3.0}/tcw/serve/dist/client/assets/index-Dz5B7M8G.js +0 -0
  47. {tcw_cli-1.2.2 → tcw_cli-1.3.0}/tcw/serve/dist/client/index.html +0 -0
  48. {tcw_cli-1.2.2 → tcw_cli-1.3.0}/tcw/serve/dist/client/theme-init.js +0 -0
  49. {tcw_cli-1.2.2 → tcw_cli-1.3.0}/tcw/serve/dist/server.cjs +0 -0
  50. {tcw_cli-1.2.2 → tcw_cli-1.3.0}/tcw/serve/runtime.py +0 -0
  51. {tcw_cli-1.2.2 → tcw_cli-1.3.0}/tcw/stdin.py +0 -0
  52. {tcw_cli-1.2.2 → tcw_cli-1.3.0}/tcw/store/__init__.py +0 -0
  53. {tcw_cli-1.2.2 → tcw_cli-1.3.0}/tcw/taxonomy/__init__.py +0 -0
  54. {tcw_cli-1.2.2 → tcw_cli-1.3.0}/tcw/taxonomy/cli.py +0 -0
  55. {tcw_cli-1.2.2 → tcw_cli-1.3.0}/tcw/work/__init__.py +0 -0
  56. {tcw_cli-1.2.2 → tcw_cli-1.3.0}/tcw/work/generate.py +0 -0
  57. {tcw_cli-1.2.2 → tcw_cli-1.3.0}/tcw/work/projection.py +0 -0
  58. {tcw_cli-1.2.2 → tcw_cli-1.3.0}/tcw/work/prompts/implement.md +0 -0
  59. {tcw_cli-1.2.2 → tcw_cli-1.3.0}/tcw/work/prompts/plan.md +0 -0
  60. {tcw_cli-1.2.2 → tcw_cli-1.3.0}/tcw/work/prompts/postmortem.md +0 -0
  61. {tcw_cli-1.2.2 → tcw_cli-1.3.0}/tcw/work/prompts/request.md +0 -0
  62. {tcw_cli-1.2.2 → tcw_cli-1.3.0}/tcw/work/prompts/spec.md +0 -0
  63. {tcw_cli-1.2.2 → tcw_cli-1.3.0}/tcw/work/prompts/verify.md +0 -0
  64. {tcw_cli-1.2.2 → tcw_cli-1.3.0}/tcw/work/resolve.py +0 -0
  65. {tcw_cli-1.2.2 → tcw_cli-1.3.0}/tcw/work/templates.py +0 -0
  66. {tcw_cli-1.2.2 → tcw_cli-1.3.0}/tcw_cli.egg-info/dependency_links.txt +0 -0
  67. {tcw_cli-1.2.2 → tcw_cli-1.3.0}/tcw_cli.egg-info/entry_points.txt +0 -0
  68. {tcw_cli-1.2.2 → tcw_cli-1.3.0}/tcw_cli.egg-info/requires.txt +0 -0
  69. {tcw_cli-1.2.2 → tcw_cli-1.3.0}/tcw_cli.egg-info/top_level.txt +0 -0
  70. {tcw_cli-1.2.2 → tcw_cli-1.3.0}/tests/test_body_prompt.py +0 -0
  71. {tcw_cli-1.2.2 → tcw_cli-1.3.0}/tests/test_capabilities_reset.py +0 -0
  72. {tcw_cli-1.2.2 → tcw_cli-1.3.0}/tests/test_capabilities_sidecar.py +0 -0
  73. {tcw_cli-1.2.2 → tcw_cli-1.3.0}/tests/test_capability_ref_wording.py +0 -0
  74. {tcw_cli-1.2.2 → tcw_cli-1.3.0}/tests/test_cut_version.py +0 -0
  75. {tcw_cli-1.2.2 → tcw_cli-1.3.0}/tests/test_documentation_config.py +0 -0
  76. {tcw_cli-1.2.2 → tcw_cli-1.3.0}/tests/test_documentation_prompt.py +0 -0
  77. {tcw_cli-1.2.2 → tcw_cli-1.3.0}/tests/test_documentation_sync_wiring.py +0 -0
  78. {tcw_cli-1.2.2 → tcw_cli-1.3.0}/tests/test_documented_cli_surface.py +0 -0
  79. {tcw_cli-1.2.2 → tcw_cli-1.3.0}/tests/test_environment_hardness.py +0 -0
  80. {tcw_cli-1.2.2 → tcw_cli-1.3.0}/tests/test_external_work_store.py +0 -0
  81. {tcw_cli-1.2.2 → tcw_cli-1.3.0}/tests/test_falsification_rule.py +0 -0
  82. {tcw_cli-1.2.2 → tcw_cli-1.3.0}/tests/test_generate_hook.py +0 -0
  83. {tcw_cli-1.2.2 → tcw_cli-1.3.0}/tests/test_inbox_title.py +0 -0
  84. {tcw_cli-1.2.2 → tcw_cli-1.3.0}/tests/test_lifecycle_baseline.py +0 -0
  85. {tcw_cli-1.2.2 → tcw_cli-1.3.0}/tests/test_lifecycle_hooks.py +0 -0
  86. {tcw_cli-1.2.2 → tcw_cli-1.3.0}/tests/test_lifecycle_inert.py +0 -0
  87. {tcw_cli-1.2.2 → tcw_cli-1.3.0}/tests/test_lifecycle_validation.py +0 -0
  88. {tcw_cli-1.2.2 → tcw_cli-1.3.0}/tests/test_plugin_manifests.py +0 -0
  89. {tcw_cli-1.2.2 → tcw_cli-1.3.0}/tests/test_projection.py +0 -0
  90. {tcw_cli-1.2.2 → tcw_cli-1.3.0}/tests/test_prompt_fallback.py +0 -0
  91. {tcw_cli-1.2.2 → tcw_cli-1.3.0}/tests/test_remote_session_setup.py +0 -0
  92. {tcw_cli-1.2.2 → tcw_cli-1.3.0}/tests/test_repo_lifecycle.py +0 -0
  93. {tcw_cli-1.2.2 → tcw_cli-1.3.0}/tests/test_resolve.py +0 -0
  94. {tcw_cli-1.2.2 → tcw_cli-1.3.0}/tests/test_scaffold.py +0 -0
  95. {tcw_cli-1.2.2 → tcw_cli-1.3.0}/tests/test_serve_descendants.py +0 -0
  96. {tcw_cli-1.2.2 → tcw_cli-1.3.0}/tests/test_serve_projection.py +0 -0
  97. {tcw_cli-1.2.2 → tcw_cli-1.3.0}/tests/test_serve_runtime.py +0 -0
  98. {tcw_cli-1.2.2 → tcw_cli-1.3.0}/tests/test_serve_write.py +0 -0
  99. {tcw_cli-1.2.2 → tcw_cli-1.3.0}/tests/test_session_bootstrap.py +0 -0
  100. {tcw_cli-1.2.2 → tcw_cli-1.3.0}/tests/test_shipped_prompts.py +0 -0
  101. {tcw_cli-1.2.2 → tcw_cli-1.3.0}/tests/test_show_json.py +0 -0
  102. {tcw_cli-1.2.2 → tcw_cli-1.3.0}/tests/test_skill_flow.py +0 -0
  103. {tcw_cli-1.2.2 → tcw_cli-1.3.0}/tests/test_skill_lifecycle_parity.py +0 -0
  104. {tcw_cli-1.2.2 → tcw_cli-1.3.0}/tests/test_smoke.py +0 -0
  105. {tcw_cli-1.2.2 → tcw_cli-1.3.0}/tests/test_status_parity.py +0 -0
  106. {tcw_cli-1.2.2 → tcw_cli-1.3.0}/tests/test_stdin.py +0 -0
  107. {tcw_cli-1.2.2 → tcw_cli-1.3.0}/tests/test_stdin_cli.py +0 -0
  108. {tcw_cli-1.2.2 → tcw_cli-1.3.0}/tests/test_store_bounds.py +0 -0
  109. {tcw_cli-1.2.2 → tcw_cli-1.3.0}/tests/test_store_editor.py +0 -0
  110. {tcw_cli-1.2.2 → tcw_cli-1.3.0}/tests/test_store_nodes.py +0 -0
  111. {tcw_cli-1.2.2 → tcw_cli-1.3.0}/tests/test_subprocess_stdin.py +0 -0
  112. {tcw_cli-1.2.2 → tcw_cli-1.3.0}/tests/test_unpushed_version_script.py +0 -0
  113. {tcw_cli-1.2.2 → tcw_cli-1.3.0}/tests/test_work.py +0 -0
  114. {tcw_cli-1.2.2 → tcw_cli-1.3.0}/tests/test_work_autocommit.py +0 -0
  115. {tcw_cli-1.2.2 → tcw_cli-1.3.0}/tests/test_work_review.py +0 -0
  116. {tcw_cli-1.2.2 → tcw_cli-1.3.0}/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.3.0
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
@@ -406,13 +406,86 @@ mirror — `tcw taxonomy init`, `tcw capabilities init`, `tcw work init` —
406
406
  identical to `tcw init --id <project-id> <component>`. Existing configured nodes
407
407
  may omit `--id`; legacy ID-less markers use it once to backfill their identity.
408
408
 
409
- Scaffolding `work` also adds `.gitignore` rules for `docs/work/completed/` and
410
- `docs/work/discarded/`, keeping each folder's `.gitkeep` tracked. Resolved items
411
- therefore stay on your disk and in the history that tracked them while they were
412
- live, without piling up in the tree forever. Delete the rules to track resolved
413
- work instead; on a node that predates them, re-run `tcw work init` to add them
414
- and `git rm -r --cached docs/work/completed docs/work/discarded` to drop what git
415
- already tracks.
409
+ ### What happens to resolved work
410
+
411
+ Three arrangements, and a project picks one per resolved status.
412
+
413
+ **Gitignored** is what scaffolding gives you and what every existing project
414
+ has: `tcw work init` writes `.gitignore` rules for `docs/work/completed/` and
415
+ `docs/work/discarded/`, keeping each folder's `.gitkeep` tracked. A resolved item
416
+ is untracked and left on disk, so it stays with the person who resolved it —
417
+ and, worth knowing, reaches nobody else. A fresh clone has no resolved items at
418
+ all.
419
+
420
+ **Retained** tracks them: delete the rules, and `git rm -r --cached
421
+ docs/work/completed docs/work/discarded` to drop what git already has.
422
+
423
+ **Auto-deleted** removes the folder and keeps the content in history:
424
+
425
+ ```yaml
426
+ work:
427
+ retain:
428
+ completed: false # default: true, for both resolved statuses
429
+ discarded: false
430
+ ```
431
+
432
+ The resolving transition then writes **two commits** — the item lands in its
433
+ resolved folder and is committed, then the folder is removed — and the
434
+ graveyard entry records the first commit, so `tcw work show <slug>` on a
435
+ resolved item reports where its documents can still be fetched from. Nothing is
436
+ deleted unless you ask: the default retains everything, and a malformed
437
+ `retain` reads as the default and is reported by `tcw validate` rather than
438
+ quietly becoming a deletion.
439
+
440
+ **Auto-delete and the ignore rules cannot coexist**, and TCW refuses the
441
+ combination before anything moves. Git untracks rather than moves a path into an
442
+ ignored folder, so the first commit would record a removal and hold no item —
443
+ leaving the record pointing at a commit that never contained anything, and no
444
+ copy anywhere. Removing the rules is a precondition, not a companion change. Once
445
+ a status is named in `retain`, `tcw work init` stops writing rules for it.
446
+
447
+ **Hand the item to your own archive before it goes.** The removal is a bindable
448
+ lifecycle step, `auto-delete`, with `pre` and `post`:
449
+
450
+ ```yaml
451
+ work:
452
+ retain:
453
+ completed: false
454
+ lifecycle:
455
+ transitions:
456
+ auto-delete:
457
+ pre:
458
+ - command: tar -czf - -C "$TCW_ITEM_PATH" . |
459
+ aws s3 cp - "s3://my-bucket/$TCW_RESOLUTION/$TCW_SLUG.tgz"
460
+ ```
461
+
462
+ `pre` runs after the item is committed where it landed and before it is removed,
463
+ so your command sees a complete artifact that is already recorded. Two variables
464
+ join the usual four: `TCW_ITEM_PATH`, the store's own answer for where the item
465
+ is at the moment the hook runs, and `TCW_RESOLUTION`. Both are set on any
466
+ transition that has them — `TCW_ITEM_PATH` on all of them — and omitted rather
467
+ than blank when they do not, so a script can test for presence. **If your command fails, the item is not deleted** — it
468
+ stays resolved, recorded and committed, and `tcw work delete <slug>` finishes the
469
+ removal once you have fixed things. A command that moves the item away itself is
470
+ fine; an already-absent folder counts as removed.
471
+
472
+ Two things this does not promise. TCW cannot tell whether your command really
473
+ archived anything, and a `skill:` binding is reported for your agent to invoke
474
+ rather than run — so anything you need guaranteed belongs in a `command:`.
475
+ `tcw serve` runs no hooks, so an item resolved through the web UI waits for a CLI
476
+ `tcw work delete` rather than being removed without your archive.
477
+
478
+ Adopting auto-delete on an older board wants one more step first: the graveyard
479
+ is what keeps a deleted slug from being reissued, and a board that predates it
480
+ has none. `tcw work tombstone add <slug>` backfills the ones already resolved.
481
+
482
+ A history that gets rewritten takes the content with it. A squash-merge or a
483
+ shallow clone can leave a record whose commit no longer resolves — `tcw work
484
+ show` says so rather than printing a dead pointer, but it cannot get the content
485
+ back. That is the trade auto-delete makes, and it is why the default does not
486
+ make it for you.
487
+
488
+ ### Where a component store lives
416
489
 
417
490
  To keep a project's work in another Git repository while preserving its own ID
418
491
  and lifecycle configuration, set `work.path` in its `tcw-config.yaml` or pass
@@ -447,6 +520,9 @@ taxonomy:
447
520
  path: docs/taxonomy
448
521
  ```
449
522
 
523
+ A connected project takes the same block, in the same place its locator goes —
524
+ see [Connected projects](#connected-projects).
525
+
450
526
  **A store that is already here always wins.** The declaration is consulted only
451
527
  when the local store is absent, so the same config keeps working untouched on a
452
528
  machine that has the folder, and answers for one that doesn't. Where the store is
@@ -455,17 +531,32 @@ instead of reporting that the project has no such component. That last part
455
531
  matters most for the trees: a checkout that cloned only the code has no
456
532
  `docs/taxonomy/` folder, which used to read as "this project has no taxonomy".
457
533
 
534
+ ### Obtaining a declared store or project
535
+
458
536
  `tcw provision` is what obtains it:
459
537
 
460
538
  ```sh
461
- tcw provision # every declared store, when it is not here yet
539
+ tcw provision # every declared store and connected project
462
540
  tcw provision --dry-run # print the plan; contact nothing
463
541
  tcw provision --refresh # bring an existing copy to the declared version
464
542
  tcw provision --component taxonomy
465
543
  ```
466
544
 
467
545
  Each declared component is obtained on its own, so one bad declaration does not
468
- suppress another's result.
546
+ suppress another's result. Connected projects are obtained after the components,
547
+ and **transitively**: a project obtained because it was declared may declare
548
+ others, and those are obtained in the same run. That is the one place `tcw`
549
+ contacts a URL you did not write yourself, so every remote is printed before it
550
+ is contacted — the transitive ones included — and `--dry-run` walks the whole
551
+ queue without touching the network, saying plainly that a project it has not
552
+ fetched may declare more. `--component` scopes the component pass only —
553
+ connected projects are still obtained, and every remote is still printed first. A
554
+ project this checkout can already reach is never fetched, however it is declared
555
+ — the same "already here wins" rule the stores follow — so declaring an edge on
556
+ both sides costs nothing. `--refresh` does not override that: it brings a copy
557
+ `tcw` itself provisioned back to the declared version, and a project you resolve
558
+ somewhere else has no such copy to bring anywhere — obtaining one would put a
559
+ second node in the graph under a single ID.
469
560
 
470
561
  If the `repository` block itself is wrong — a missing `url`, a path that escapes
471
562
  the repository root, a key that is not one of the four — every command says which
@@ -568,6 +659,24 @@ connected-projects:
568
659
  orchestrator: ../orchestrator
569
660
  ```
570
661
 
662
+ An entry may also say where the project *comes from*, taking the same
663
+ `repository` block a component store takes (see [Where a component store
664
+ lives](#where-a-component-store-lives)) in the place a locator goes:
665
+
666
+ ```yaml
667
+ id: project-a
668
+ connected-projects:
669
+ parent:
670
+ orchestrator:
671
+ path: ../orchestrator # optional; where it is here
672
+ repository:
673
+ url: https://github.com/me/orchestrator.git
674
+ ref: main
675
+ ```
676
+
677
+ A bare locator string stays a locator, so nothing already written changes. The
678
+ ladder is the store's, and `tcw provision` is what walks it.
679
+
571
680
  Relative locators resolve from the declaring config; absolute locators are also
572
681
  allowed. `children` contains direct children only and `parent` has at most one
573
682
  entry. TCW derives deeper descendants and ancestors transitively, never by
@@ -575,6 +684,51 @@ scanning directories to discover a project. `tcw work list --include-descendants
575
684
  groups registered boards by project ID, and any work command accepts
576
685
  `<descendant-project-id>/<slug>`.
577
686
 
687
+ **A locator is a fact about one machine, and a project that is not on it drops
688
+ out of the graph rather than failing your commands.** A checkout holding only
689
+ some of a graph's repositories — a fresh clone, a cloud session that cloned one
690
+ repo — keeps working: the absent project is simply not in the graph, and
691
+ everything that does not need it behaves normally. `tcw validate` names each
692
+ project it could not reach, every run. A project some other declaration did
693
+ resolve is not listed — in a
694
+ reciprocal graph both sides name every connection, and on a machine holding only
695
+ some of the repositories one of those two is routinely a path that is not here:
696
+
697
+ ```
698
+ tcw-config.yaml: connected project 'orchestrator' is declared but not reachable
699
+ in this checkout (/home/you/orchestrator)
700
+ ```
701
+
702
+ A command that *does* need the absent project says which one and where it was
703
+ declared, rather than reporting that it was never registered. This is the same
704
+ courtesy a declared store already gets when it has not been provisioned here.
705
+ `tcw work nodes` lists it as a parent or a child that is not in this checkout,
706
+ and `tcw work escalate` and `tcw work delegate` name it instead of calling the
707
+ node a root or a leaf.
708
+
709
+ `tcw validate` also reports a declared locator that does not resolve here for a
710
+ project it *does* have — declared there, found here — without calling it a
711
+ problem. Nothing on disk separates a typo from a path that is simply right for
712
+ another machine, and in a workspace whose repositories sit differently on
713
+ different disks the second is routine, so it states both facts and draws no
714
+ conclusion.
715
+
716
+ **A node need not keep a work store.** A repository root that only groups the
717
+ packages owning the boards is a registered project like any other, and relations
718
+ pass straight through it: an epic two levels up resolves, its slices below one
719
+ are found, and `tcw work escalate` reaches the nearest ancestor that does keep a
720
+ board. `tcw work nodes` says `parent: <id> (no work store)` for such a parent
721
+ rather than calling this node the root, and `(work store not provisioned here)`
722
+ for one whose declared board this machine has not obtained — the same two markers
723
+ it puts on the children lines.
724
+
725
+ Configuration that is genuinely wrong still fails closed, unchanged: an invalid
726
+ or duplicated project ID, a cycle, unparseable YAML, a registered key that
727
+ disagrees with the target it names. The one thing that relaxes with it is
728
+ reciprocity — two nodes that name each other at paths belonging to different
729
+ machines are correctly configured, and only a counterpart that is *present* and
730
+ points somewhere else is a non-reciprocal declaration.
731
+
578
732
  Inside a **linked git worktree** a relative locator would otherwise be off by the
579
733
  worktree's nesting depth, because it was written against the project's position
580
734
  in its primary checkout. TCW re-anchors it against that project's own counterpart
@@ -947,6 +1101,41 @@ Blocked-ness is a **derived overlay**: an item is blocked when it has at least
947
1101
  one unresolved blocker recorded in its data — there is no separate "blocked"
948
1102
  folder or status.
949
1103
 
1104
+ ### References to work that is finished
1105
+
1106
+ Resolving an item takes its documents out of the tracked tree, so a
1107
+ `tcw://W/<slug>` link to it would have nothing to resolve against. Completing
1108
+ and discarding therefore **record the slug**, in `graveyard.yaml` beside the
1109
+ status folders, and the record rides the same commit as the status change. A
1110
+ reference to finished work then keeps resolving: `tcw validate` says nothing
1111
+ about it, and `tcw serve` shows it inert rather than broken. A reference to a
1112
+ slug the project never held is still an error, in the same words as before —
1113
+ that distinction is the whole point of the record.
1114
+
1115
+ The record says the slug existed and how it was resolved. It deliberately does
1116
+ **not** say where the documents went: any such pointer stops working the moment
1117
+ history is squashed, rebased, or shallowly cloned, and a pointer that quietly
1118
+ breaks is worse than none. How long resolved documents are kept stays your
1119
+ call — `completed/` and `discarded/` are gitignored by default, and a project
1120
+ that wants them in the tracked tree simply does not ignore them.
1121
+
1122
+ For work resolved **before** your project kept these records — including
1123
+ everything resolved before this feature existed — record a slug by hand:
1124
+
1125
+ ```bash
1126
+ tcw work tombstone add <slug> --resolution done --resolved 2026-09-01
1127
+ ```
1128
+
1129
+ Both flags are optional; omit them when nobody kept the detail, since the
1130
+ record's job is to say the slug existed.
1131
+
1132
+ Run it wherever you are — including on the machine that resolved the work, where
1133
+ the item's folder is usually still sitting on disk. It refuses only a slug that
1134
+ is *live*, and a slug already in the graveyard, so re-running it over a list is
1135
+ safe and will not quietly replace a good record with a blank one. It commits
1136
+ what it writes, and on a store with a configured remote it publishes too, since
1137
+ a record nobody else can see does not do its job.
1138
+
950
1139
  ### Binding your own skills and commands to the lifecycle
951
1140
 
952
1141
  The lifecycle has named **stages** (each producing one document) and named
@@ -190,13 +190,86 @@ mirror — `tcw taxonomy init`, `tcw capabilities init`, `tcw work init` —
190
190
  identical to `tcw init --id <project-id> <component>`. Existing configured nodes
191
191
  may omit `--id`; legacy ID-less markers use it once to backfill their identity.
192
192
 
193
- Scaffolding `work` also adds `.gitignore` rules for `docs/work/completed/` and
194
- `docs/work/discarded/`, keeping each folder's `.gitkeep` tracked. Resolved items
195
- therefore stay on your disk and in the history that tracked them while they were
196
- live, without piling up in the tree forever. Delete the rules to track resolved
197
- work instead; on a node that predates them, re-run `tcw work init` to add them
198
- and `git rm -r --cached docs/work/completed docs/work/discarded` to drop what git
199
- already tracks.
193
+ ### What happens to resolved work
194
+
195
+ Three arrangements, and a project picks one per resolved status.
196
+
197
+ **Gitignored** is what scaffolding gives you and what every existing project
198
+ has: `tcw work init` writes `.gitignore` rules for `docs/work/completed/` and
199
+ `docs/work/discarded/`, keeping each folder's `.gitkeep` tracked. A resolved item
200
+ is untracked and left on disk, so it stays with the person who resolved it —
201
+ and, worth knowing, reaches nobody else. A fresh clone has no resolved items at
202
+ all.
203
+
204
+ **Retained** tracks them: delete the rules, and `git rm -r --cached
205
+ docs/work/completed docs/work/discarded` to drop what git already has.
206
+
207
+ **Auto-deleted** removes the folder and keeps the content in history:
208
+
209
+ ```yaml
210
+ work:
211
+ retain:
212
+ completed: false # default: true, for both resolved statuses
213
+ discarded: false
214
+ ```
215
+
216
+ The resolving transition then writes **two commits** — the item lands in its
217
+ resolved folder and is committed, then the folder is removed — and the
218
+ graveyard entry records the first commit, so `tcw work show <slug>` on a
219
+ resolved item reports where its documents can still be fetched from. Nothing is
220
+ deleted unless you ask: the default retains everything, and a malformed
221
+ `retain` reads as the default and is reported by `tcw validate` rather than
222
+ quietly becoming a deletion.
223
+
224
+ **Auto-delete and the ignore rules cannot coexist**, and TCW refuses the
225
+ combination before anything moves. Git untracks rather than moves a path into an
226
+ ignored folder, so the first commit would record a removal and hold no item —
227
+ leaving the record pointing at a commit that never contained anything, and no
228
+ copy anywhere. Removing the rules is a precondition, not a companion change. Once
229
+ a status is named in `retain`, `tcw work init` stops writing rules for it.
230
+
231
+ **Hand the item to your own archive before it goes.** The removal is a bindable
232
+ lifecycle step, `auto-delete`, with `pre` and `post`:
233
+
234
+ ```yaml
235
+ work:
236
+ retain:
237
+ completed: false
238
+ lifecycle:
239
+ transitions:
240
+ auto-delete:
241
+ pre:
242
+ - command: tar -czf - -C "$TCW_ITEM_PATH" . |
243
+ aws s3 cp - "s3://my-bucket/$TCW_RESOLUTION/$TCW_SLUG.tgz"
244
+ ```
245
+
246
+ `pre` runs after the item is committed where it landed and before it is removed,
247
+ so your command sees a complete artifact that is already recorded. Two variables
248
+ join the usual four: `TCW_ITEM_PATH`, the store's own answer for where the item
249
+ is at the moment the hook runs, and `TCW_RESOLUTION`. Both are set on any
250
+ transition that has them — `TCW_ITEM_PATH` on all of them — and omitted rather
251
+ than blank when they do not, so a script can test for presence. **If your command fails, the item is not deleted** — it
252
+ stays resolved, recorded and committed, and `tcw work delete <slug>` finishes the
253
+ removal once you have fixed things. A command that moves the item away itself is
254
+ fine; an already-absent folder counts as removed.
255
+
256
+ Two things this does not promise. TCW cannot tell whether your command really
257
+ archived anything, and a `skill:` binding is reported for your agent to invoke
258
+ rather than run — so anything you need guaranteed belongs in a `command:`.
259
+ `tcw serve` runs no hooks, so an item resolved through the web UI waits for a CLI
260
+ `tcw work delete` rather than being removed without your archive.
261
+
262
+ Adopting auto-delete on an older board wants one more step first: the graveyard
263
+ is what keeps a deleted slug from being reissued, and a board that predates it
264
+ has none. `tcw work tombstone add <slug>` backfills the ones already resolved.
265
+
266
+ A history that gets rewritten takes the content with it. A squash-merge or a
267
+ shallow clone can leave a record whose commit no longer resolves — `tcw work
268
+ show` says so rather than printing a dead pointer, but it cannot get the content
269
+ back. That is the trade auto-delete makes, and it is why the default does not
270
+ make it for you.
271
+
272
+ ### Where a component store lives
200
273
 
201
274
  To keep a project's work in another Git repository while preserving its own ID
202
275
  and lifecycle configuration, set `work.path` in its `tcw-config.yaml` or pass
@@ -231,6 +304,9 @@ taxonomy:
231
304
  path: docs/taxonomy
232
305
  ```
233
306
 
307
+ A connected project takes the same block, in the same place its locator goes —
308
+ see [Connected projects](#connected-projects).
309
+
234
310
  **A store that is already here always wins.** The declaration is consulted only
235
311
  when the local store is absent, so the same config keeps working untouched on a
236
312
  machine that has the folder, and answers for one that doesn't. Where the store is
@@ -239,17 +315,32 @@ instead of reporting that the project has no such component. That last part
239
315
  matters most for the trees: a checkout that cloned only the code has no
240
316
  `docs/taxonomy/` folder, which used to read as "this project has no taxonomy".
241
317
 
318
+ ### Obtaining a declared store or project
319
+
242
320
  `tcw provision` is what obtains it:
243
321
 
244
322
  ```sh
245
- tcw provision # every declared store, when it is not here yet
323
+ tcw provision # every declared store and connected project
246
324
  tcw provision --dry-run # print the plan; contact nothing
247
325
  tcw provision --refresh # bring an existing copy to the declared version
248
326
  tcw provision --component taxonomy
249
327
  ```
250
328
 
251
329
  Each declared component is obtained on its own, so one bad declaration does not
252
- suppress another's result.
330
+ suppress another's result. Connected projects are obtained after the components,
331
+ and **transitively**: a project obtained because it was declared may declare
332
+ others, and those are obtained in the same run. That is the one place `tcw`
333
+ contacts a URL you did not write yourself, so every remote is printed before it
334
+ is contacted — the transitive ones included — and `--dry-run` walks the whole
335
+ queue without touching the network, saying plainly that a project it has not
336
+ fetched may declare more. `--component` scopes the component pass only —
337
+ connected projects are still obtained, and every remote is still printed first. A
338
+ project this checkout can already reach is never fetched, however it is declared
339
+ — the same "already here wins" rule the stores follow — so declaring an edge on
340
+ both sides costs nothing. `--refresh` does not override that: it brings a copy
341
+ `tcw` itself provisioned back to the declared version, and a project you resolve
342
+ somewhere else has no such copy to bring anywhere — obtaining one would put a
343
+ second node in the graph under a single ID.
253
344
 
254
345
  If the `repository` block itself is wrong — a missing `url`, a path that escapes
255
346
  the repository root, a key that is not one of the four — every command says which
@@ -352,6 +443,24 @@ connected-projects:
352
443
  orchestrator: ../orchestrator
353
444
  ```
354
445
 
446
+ An entry may also say where the project *comes from*, taking the same
447
+ `repository` block a component store takes (see [Where a component store
448
+ lives](#where-a-component-store-lives)) in the place a locator goes:
449
+
450
+ ```yaml
451
+ id: project-a
452
+ connected-projects:
453
+ parent:
454
+ orchestrator:
455
+ path: ../orchestrator # optional; where it is here
456
+ repository:
457
+ url: https://github.com/me/orchestrator.git
458
+ ref: main
459
+ ```
460
+
461
+ A bare locator string stays a locator, so nothing already written changes. The
462
+ ladder is the store's, and `tcw provision` is what walks it.
463
+
355
464
  Relative locators resolve from the declaring config; absolute locators are also
356
465
  allowed. `children` contains direct children only and `parent` has at most one
357
466
  entry. TCW derives deeper descendants and ancestors transitively, never by
@@ -359,6 +468,51 @@ scanning directories to discover a project. `tcw work list --include-descendants
359
468
  groups registered boards by project ID, and any work command accepts
360
469
  `<descendant-project-id>/<slug>`.
361
470
 
471
+ **A locator is a fact about one machine, and a project that is not on it drops
472
+ out of the graph rather than failing your commands.** A checkout holding only
473
+ some of a graph's repositories — a fresh clone, a cloud session that cloned one
474
+ repo — keeps working: the absent project is simply not in the graph, and
475
+ everything that does not need it behaves normally. `tcw validate` names each
476
+ project it could not reach, every run. A project some other declaration did
477
+ resolve is not listed — in a
478
+ reciprocal graph both sides name every connection, and on a machine holding only
479
+ some of the repositories one of those two is routinely a path that is not here:
480
+
481
+ ```
482
+ tcw-config.yaml: connected project 'orchestrator' is declared but not reachable
483
+ in this checkout (/home/you/orchestrator)
484
+ ```
485
+
486
+ A command that *does* need the absent project says which one and where it was
487
+ declared, rather than reporting that it was never registered. This is the same
488
+ courtesy a declared store already gets when it has not been provisioned here.
489
+ `tcw work nodes` lists it as a parent or a child that is not in this checkout,
490
+ and `tcw work escalate` and `tcw work delegate` name it instead of calling the
491
+ node a root or a leaf.
492
+
493
+ `tcw validate` also reports a declared locator that does not resolve here for a
494
+ project it *does* have — declared there, found here — without calling it a
495
+ problem. Nothing on disk separates a typo from a path that is simply right for
496
+ another machine, and in a workspace whose repositories sit differently on
497
+ different disks the second is routine, so it states both facts and draws no
498
+ conclusion.
499
+
500
+ **A node need not keep a work store.** A repository root that only groups the
501
+ packages owning the boards is a registered project like any other, and relations
502
+ pass straight through it: an epic two levels up resolves, its slices below one
503
+ are found, and `tcw work escalate` reaches the nearest ancestor that does keep a
504
+ board. `tcw work nodes` says `parent: <id> (no work store)` for such a parent
505
+ rather than calling this node the root, and `(work store not provisioned here)`
506
+ for one whose declared board this machine has not obtained — the same two markers
507
+ it puts on the children lines.
508
+
509
+ Configuration that is genuinely wrong still fails closed, unchanged: an invalid
510
+ or duplicated project ID, a cycle, unparseable YAML, a registered key that
511
+ disagrees with the target it names. The one thing that relaxes with it is
512
+ reciprocity — two nodes that name each other at paths belonging to different
513
+ machines are correctly configured, and only a counterpart that is *present* and
514
+ points somewhere else is a non-reciprocal declaration.
515
+
362
516
  Inside a **linked git worktree** a relative locator would otherwise be off by the
363
517
  worktree's nesting depth, because it was written against the project's position
364
518
  in its primary checkout. TCW re-anchors it against that project's own counterpart
@@ -731,6 +885,41 @@ Blocked-ness is a **derived overlay**: an item is blocked when it has at least
731
885
  one unresolved blocker recorded in its data — there is no separate "blocked"
732
886
  folder or status.
733
887
 
888
+ ### References to work that is finished
889
+
890
+ Resolving an item takes its documents out of the tracked tree, so a
891
+ `tcw://W/<slug>` link to it would have nothing to resolve against. Completing
892
+ and discarding therefore **record the slug**, in `graveyard.yaml` beside the
893
+ status folders, and the record rides the same commit as the status change. A
894
+ reference to finished work then keeps resolving: `tcw validate` says nothing
895
+ about it, and `tcw serve` shows it inert rather than broken. A reference to a
896
+ slug the project never held is still an error, in the same words as before —
897
+ that distinction is the whole point of the record.
898
+
899
+ The record says the slug existed and how it was resolved. It deliberately does
900
+ **not** say where the documents went: any such pointer stops working the moment
901
+ history is squashed, rebased, or shallowly cloned, and a pointer that quietly
902
+ breaks is worse than none. How long resolved documents are kept stays your
903
+ call — `completed/` and `discarded/` are gitignored by default, and a project
904
+ that wants them in the tracked tree simply does not ignore them.
905
+
906
+ For work resolved **before** your project kept these records — including
907
+ everything resolved before this feature existed — record a slug by hand:
908
+
909
+ ```bash
910
+ tcw work tombstone add <slug> --resolution done --resolved 2026-09-01
911
+ ```
912
+
913
+ Both flags are optional; omit them when nobody kept the detail, since the
914
+ record's job is to say the slug existed.
915
+
916
+ Run it wherever you are — including on the machine that resolved the work, where
917
+ the item's folder is usually still sitting on disk. It refuses only a slug that
918
+ is *live*, and a slug already in the graveyard, so re-running it over a list is
919
+ safe and will not quietly replace a good record with a blank one. It commits
920
+ what it writes, and on a store with a configured remote it publishes too, since
921
+ a record nobody else can see does not do its job.
922
+
734
923
  ### Binding your own skills and commands to the lifecycle
735
924
 
736
925
  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.3.0"
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.3.0"
@@ -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