dctracker 1.2.1__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 (162) hide show
  1. {dctracker-1.2.1/dctracker.egg-info → dctracker-1.3.0}/PKG-INFO +14 -9
  2. {dctracker-1.2.1 → dctracker-1.3.0}/README.md +11 -6
  3. dctracker-1.3.0/dct/__init__.py +6 -0
  4. {dctracker-1.2.1 → dctracker-1.3.0}/dct/cli.py +321 -21
  5. {dctracker-1.2.1 → dctracker-1.3.0}/dct/core/analytics.py +13 -8
  6. {dctracker-1.2.1 → dctracker-1.3.0}/dct/core/changelog.py +4 -4
  7. {dctracker-1.2.1 → dctracker-1.3.0}/dct/core/checkpoints.py +101 -7
  8. {dctracker-1.2.1 → dctracker-1.3.0}/dct/core/decisions.py +41 -4
  9. {dctracker-1.2.1 → dctracker-1.3.0}/dct/core/engine.py +1 -1
  10. {dctracker-1.2.1 → dctracker-1.3.0}/dct/core/handoff.py +89 -33
  11. {dctracker-1.2.1 → dctracker-1.3.0}/dct/core/items.py +92 -44
  12. {dctracker-1.2.1 → dctracker-1.3.0}/dct/core/plans/crud.py +11 -11
  13. {dctracker-1.2.1 → dctracker-1.3.0}/dct/core/plans/parser.py +0 -1
  14. {dctracker-1.2.1 → dctracker-1.3.0}/dct/core/projects.py +7 -7
  15. {dctracker-1.2.1 → dctracker-1.3.0}/dct/core/provenance.py +4 -1
  16. dctracker-1.3.0/dct/core/sprint.py +508 -0
  17. {dctracker-1.2.1 → dctracker-1.3.0}/dct/core/sprints.py +371 -154
  18. {dctracker-1.2.1 → dctracker-1.3.0}/dct/db.py +51 -7
  19. {dctracker-1.2.1 → dctracker-1.3.0}/dct/export.py +2 -2
  20. {dctracker-1.2.1 → dctracker-1.3.0}/dct/integrations/claude.py +7 -5
  21. {dctracker-1.2.1 → dctracker-1.3.0}/dct/mcp/server.py +332 -49
  22. {dctracker-1.2.1 → dctracker-1.3.0}/dct/migrate.py +9 -7
  23. {dctracker-1.2.1 → dctracker-1.3.0}/dct/server.py +1 -1
  24. dctracker-1.3.0/dct/services/__init__.py +7 -0
  25. {dctracker-1.2.1 → dctracker-1.3.0}/dct/setup.py +1 -1
  26. dctracker-1.3.0/dct/skills/handoff/SKILL.md +188 -0
  27. dctracker-1.3.0/dct/skills/pickup/SKILL.md +147 -0
  28. {dctracker-1.2.1 → dctracker-1.3.0}/dct/skills/track-update/SKILL.md +6 -3
  29. {dctracker-1.2.1 → dctracker-1.3.0}/dct/update.py +1 -1
  30. {dctracker-1.2.1 → dctracker-1.3.0}/dct/web/app.py +1 -1
  31. {dctracker-1.2.1 → dctracker-1.3.0}/dct/web/blueprints/changelog.py +2 -2
  32. {dctracker-1.2.1 → dctracker-1.3.0}/dct/web/blueprints/decisions.py +2 -2
  33. {dctracker-1.2.1 → dctracker-1.3.0}/dct/web/blueprints/events.py +1 -1
  34. {dctracker-1.2.1 → dctracker-1.3.0}/dct/web/blueprints/handoffs.py +2 -2
  35. {dctracker-1.2.1 → dctracker-1.3.0}/dct/web/blueprints/items.py +5 -6
  36. {dctracker-1.2.1 → dctracker-1.3.0}/dct/web/blueprints/plans.py +3 -3
  37. {dctracker-1.2.1 → dctracker-1.3.0}/dct/web/blueprints/sprints.py +16 -6
  38. dctracker-1.3.0/dct/workflows/__init__.py +7 -0
  39. dctracker-1.3.0/dct/workflows/session.py +596 -0
  40. {dctracker-1.2.1 → dctracker-1.3.0/dctracker.egg-info}/PKG-INFO +14 -9
  41. {dctracker-1.2.1 → dctracker-1.3.0}/dctracker.egg-info/SOURCES.txt +6 -1
  42. {dctracker-1.2.1 → dctracker-1.3.0}/dctracker.egg-info/requires.txt +2 -2
  43. {dctracker-1.2.1 → dctracker-1.3.0}/pyproject.toml +14 -3
  44. {dctracker-1.2.1 → dctracker-1.3.0}/tests/test_adversarial.py +16 -5
  45. {dctracker-1.2.1 → dctracker-1.3.0}/tests/test_analytics.py +41 -34
  46. {dctracker-1.2.1 → dctracker-1.3.0}/tests/test_changelog.py +5 -1
  47. dctracker-1.3.0/tests/test_check_numbers.py +219 -0
  48. {dctracker-1.2.1 → dctracker-1.3.0}/tests/test_checkpoints.py +9 -4
  49. {dctracker-1.2.1 → dctracker-1.3.0}/tests/test_cli_sprints.py +1 -16
  50. {dctracker-1.2.1 → dctracker-1.3.0}/tests/test_cli_web.py +0 -1
  51. dctracker-1.3.0/tests/test_cli_workflows.py +616 -0
  52. {dctracker-1.2.1 → dctracker-1.3.0}/tests/test_config.py +1 -1
  53. {dctracker-1.2.1 → dctracker-1.3.0}/tests/test_handoff.py +7 -3
  54. {dctracker-1.2.1 → dctracker-1.3.0}/tests/test_ids.py +1 -1
  55. {dctracker-1.2.1 → dctracker-1.3.0}/tests/test_items.py +8 -2
  56. {dctracker-1.2.1 → dctracker-1.3.0}/tests/test_migrate.py +1 -1
  57. {dctracker-1.2.1 → dctracker-1.3.0}/tests/test_migration.py +165 -13
  58. {dctracker-1.2.1 → dctracker-1.3.0}/tests/test_plan_ingest.py +5 -2
  59. {dctracker-1.2.1 → dctracker-1.3.0}/tests/test_plan_parser.py +0 -1
  60. {dctracker-1.2.1 → dctracker-1.3.0}/tests/test_projects.py +9 -3
  61. {dctracker-1.2.1 → dctracker-1.3.0}/tests/test_server_acks.py +4 -14
  62. {dctracker-1.2.1 → dctracker-1.3.0}/tests/test_server_alwaysload.py +9 -6
  63. {dctracker-1.2.1 → dctracker-1.3.0}/tests/test_server_handoff_view.py +29 -18
  64. dctracker-1.3.0/tests/test_server_workflow_ops.py +527 -0
  65. dctracker-1.3.0/tests/test_sprint.py +310 -0
  66. {dctracker-1.2.1 → dctracker-1.3.0}/tests/test_sprints_crud.py +139 -35
  67. {dctracker-1.2.1 → dctracker-1.3.0}/tests/test_update.py +2 -4
  68. {dctracker-1.2.1 → dctracker-1.3.0}/tests/test_version.py +2 -3
  69. {dctracker-1.2.1 → dctracker-1.3.0}/tests/test_web_analytics.py +2 -2
  70. {dctracker-1.2.1 → dctracker-1.3.0}/tests/test_web_app.py +3 -1
  71. {dctracker-1.2.1 → dctracker-1.3.0}/tests/test_web_items.py +1 -1
  72. {dctracker-1.2.1 → dctracker-1.3.0}/tests/test_web_structural.py +5 -5
  73. {dctracker-1.2.1 → dctracker-1.3.0}/tests/test_windows_support.py +1 -3
  74. dctracker-1.3.0/tests/test_workflows.py +1287 -0
  75. dctracker-1.2.1/dct/__init__.py +0 -6
  76. dctracker-1.2.1/dct/core/sprint.py +0 -315
  77. dctracker-1.2.1/dct/services/__init__.py +0 -5
  78. dctracker-1.2.1/dct/skills/handoff/SKILL.md +0 -129
  79. dctracker-1.2.1/dct/skills/pickup/SKILL.md +0 -110
  80. dctracker-1.2.1/dct/workflows/__init__.py +0 -3
  81. dctracker-1.2.1/tests/test_sprint.py +0 -154
  82. {dctracker-1.2.1 → dctracker-1.3.0}/LICENSE +0 -0
  83. {dctracker-1.2.1 → dctracker-1.3.0}/MANIFEST.in +0 -0
  84. {dctracker-1.2.1 → dctracker-1.3.0}/dct/assets/__init__.py +0 -0
  85. {dctracker-1.2.1 → dctracker-1.3.0}/dct/config.py +0 -0
  86. {dctracker-1.2.1 → dctracker-1.3.0}/dct/core/__init__.py +0 -0
  87. {dctracker-1.2.1 → dctracker-1.3.0}/dct/core/ids.py +0 -0
  88. {dctracker-1.2.1 → dctracker-1.3.0}/dct/core/plans/__init__.py +0 -0
  89. {dctracker-1.2.1 → dctracker-1.3.0}/dct/core/plans/classify.py +0 -0
  90. {dctracker-1.2.1 → dctracker-1.3.0}/dct/core/plans/ingest.py +0 -0
  91. {dctracker-1.2.1 → dctracker-1.3.0}/dct/core/version.py +0 -0
  92. {dctracker-1.2.1 → dctracker-1.3.0}/dct/hooks/check-changelog-on-stop.sh +0 -0
  93. {dctracker-1.2.1 → dctracker-1.3.0}/dct/hooks/check-changelog.sh +0 -0
  94. {dctracker-1.2.1 → dctracker-1.3.0}/dct/hooks/dct-plan-autoscan.sh +0 -0
  95. {dctracker-1.2.1 → dctracker-1.3.0}/dct/hooks/dct-task-mirror.sh +0 -0
  96. {dctracker-1.2.1 → dctracker-1.3.0}/dct/integrations/__init__.py +0 -0
  97. {dctracker-1.2.1 → dctracker-1.3.0}/dct/mcp/__init__.py +0 -0
  98. {dctracker-1.2.1 → dctracker-1.3.0}/dct/paths.py +0 -0
  99. {dctracker-1.2.1 → dctracker-1.3.0}/dct/skills/clog/SKILL.md +0 -0
  100. {dctracker-1.2.1 → dctracker-1.3.0}/dct/skills/commit/SKILL.md +0 -0
  101. {dctracker-1.2.1 → dctracker-1.3.0}/dct/skills/dct-import/SKILL.md +0 -0
  102. {dctracker-1.2.1 → dctracker-1.3.0}/dct/skills/dct-init/SKILL.md +0 -0
  103. {dctracker-1.2.1 → dctracker-1.3.0}/dct/skills/plan-resolve-uncertain/SKILL.md +0 -0
  104. {dctracker-1.2.1 → dctracker-1.3.0}/dct/skills/plan-status/SKILL.md +0 -0
  105. {dctracker-1.2.1 → dctracker-1.3.0}/dct/skills/release/SKILL.md +0 -0
  106. {dctracker-1.2.1 → dctracker-1.3.0}/dct/skills/track/SKILL.md +0 -0
  107. {dctracker-1.2.1 → dctracker-1.3.0}/dct/skills/track-list/SKILL.md +0 -0
  108. {dctracker-1.2.1 → dctracker-1.3.0}/dct/skills/track-resolve/SKILL.md +0 -0
  109. {dctracker-1.2.1 → dctracker-1.3.0}/dct/web/__init__.py +0 -0
  110. {dctracker-1.2.1 → dctracker-1.3.0}/dct/web/blueprints/__init__.py +0 -0
  111. {dctracker-1.2.1 → dctracker-1.3.0}/dct/web/blueprints/analytics.py +0 -0
  112. {dctracker-1.2.1 → dctracker-1.3.0}/dct/web/blueprints/home.py +0 -0
  113. {dctracker-1.2.1 → dctracker-1.3.0}/dct/web/blueprints/projects.py +0 -0
  114. {dctracker-1.2.1 → dctracker-1.3.0}/dct/web/changes.py +9 -9
  115. {dctracker-1.2.1 → dctracker-1.3.0}/dct/web/serializers.py +0 -0
  116. {dctracker-1.2.1 → dctracker-1.3.0}/dct/web/static/app.css +0 -0
  117. {dctracker-1.2.1 → dctracker-1.3.0}/dct/web/static/app.js +0 -0
  118. {dctracker-1.2.1 → dctracker-1.3.0}/dct/web/static/vendor/SOURCES.txt +0 -0
  119. {dctracker-1.2.1 → dctracker-1.3.0}/dct/web/static/vendor/htmx.min.js +0 -0
  120. {dctracker-1.2.1 → dctracker-1.3.0}/dct/web/static/vendor/uPlot.iife.min.js +0 -0
  121. {dctracker-1.2.1 → dctracker-1.3.0}/dct/web/static/vendor/uPlot.min.css +0 -0
  122. {dctracker-1.2.1 → dctracker-1.3.0}/dct/web/templates/analytics.html +0 -0
  123. {dctracker-1.2.1 → dctracker-1.3.0}/dct/web/templates/base.html +0 -0
  124. {dctracker-1.2.1 → dctracker-1.3.0}/dct/web/templates/changelog/list.html +0 -0
  125. {dctracker-1.2.1 → dctracker-1.3.0}/dct/web/templates/decisions/list.html +0 -0
  126. {dctracker-1.2.1 → dctracker-1.3.0}/dct/web/templates/handoffs/list.html +0 -0
  127. {dctracker-1.2.1 → dctracker-1.3.0}/dct/web/templates/home.html +0 -0
  128. {dctracker-1.2.1 → dctracker-1.3.0}/dct/web/templates/item_detail.html +0 -0
  129. {dctracker-1.2.1 → dctracker-1.3.0}/dct/web/templates/items_list.html +0 -0
  130. {dctracker-1.2.1 → dctracker-1.3.0}/dct/web/templates/partials/_bars.html +0 -0
  131. {dctracker-1.2.1 → dctracker-1.3.0}/dct/web/templates/partials/_checkpoint_steps.html +0 -0
  132. {dctracker-1.2.1 → dctracker-1.3.0}/dct/web/templates/partials/_items_table.html +0 -0
  133. {dctracker-1.2.1 → dctracker-1.3.0}/dct/web/templates/partials/_plan_section.html +0 -0
  134. {dctracker-1.2.1 → dctracker-1.3.0}/dct/web/templates/partials/_project_card.html +0 -0
  135. {dctracker-1.2.1 → dctracker-1.3.0}/dct/web/templates/plans/detail.html +0 -0
  136. {dctracker-1.2.1 → dctracker-1.3.0}/dct/web/templates/plans/list.html +0 -0
  137. {dctracker-1.2.1 → dctracker-1.3.0}/dct/web/templates/project_home.html +0 -0
  138. {dctracker-1.2.1 → dctracker-1.3.0}/dct/web/templates/sprints/detail.html +0 -0
  139. {dctracker-1.2.1 → dctracker-1.3.0}/dct/web/templates/sprints/list.html +0 -0
  140. {dctracker-1.2.1 → dctracker-1.3.0}/dctracker.egg-info/dependency_links.txt +0 -0
  141. {dctracker-1.2.1 → dctracker-1.3.0}/dctracker.egg-info/entry_points.txt +0 -0
  142. {dctracker-1.2.1 → dctracker-1.3.0}/dctracker.egg-info/top_level.txt +0 -0
  143. {dctracker-1.2.1 → dctracker-1.3.0}/setup.cfg +0 -0
  144. {dctracker-1.2.1 → dctracker-1.3.0}/tests/test_changes.py +1 -1
  145. {dctracker-1.2.1 → dctracker-1.3.0}/tests/test_cli_prompts.py +0 -0
  146. {dctracker-1.2.1 → dctracker-1.3.0}/tests/test_decisions_crud.py +0 -0
  147. {dctracker-1.2.1 → dctracker-1.3.0}/tests/test_engine.py +1 -1
  148. {dctracker-1.2.1 → dctracker-1.3.0}/tests/test_export.py +1 -1
  149. {dctracker-1.2.1 → dctracker-1.3.0}/tests/test_hook_bypass.py +0 -0
  150. {dctracker-1.2.1 → dctracker-1.3.0}/tests/test_hook_changelog_stop.py +0 -0
  151. {dctracker-1.2.1 → dctracker-1.3.0}/tests/test_hook_plan_autoscan.py +2 -2
  152. {dctracker-1.2.1 → dctracker-1.3.0}/tests/test_hook_task_mirror.py +0 -0
  153. {dctracker-1.2.1 → dctracker-1.3.0}/tests/test_ingest_dogfood.py +0 -0
  154. {dctracker-1.2.1 → dctracker-1.3.0}/tests/test_packaging.py +0 -0
  155. {dctracker-1.2.1 → dctracker-1.3.0}/tests/test_paths.py +0 -0
  156. {dctracker-1.2.1 → dctracker-1.3.0}/tests/test_plan_classify.py +0 -0
  157. {dctracker-1.2.1 → dctracker-1.3.0}/tests/test_plans_crud.py +0 -0
  158. {dctracker-1.2.1 → dctracker-1.3.0}/tests/test_provenance.py +1 -1
  159. {dctracker-1.2.1 → dctracker-1.3.0}/tests/test_restructure.py +0 -0
  160. {dctracker-1.2.1 → dctracker-1.3.0}/tests/test_setup.py +0 -0
  161. {dctracker-1.2.1 → dctracker-1.3.0}/tests/test_setup_wiring.py +0 -0
  162. {dctracker-1.2.1 → dctracker-1.3.0}/tests/test_web_views.py +1 -1
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dctracker
3
- Version: 1.2.1
3
+ Version: 1.3.0
4
4
  Summary: Deterministic Context Tracker (dct) — cross-project tracking, changelog, and release tooling for Claude Code.
5
5
  Author-email: fotodeveloper <dev@fotodeveloper.com>
6
6
  License-Expression: MIT
@@ -25,7 +25,7 @@ Requires-Dist: platformdirs>=4.9.6; sys_platform == "win32"
25
25
  Provides-Extra: pg
26
26
  Requires-Dist: psycopg[binary]; extra == "pg"
27
27
  Provides-Extra: mcp
28
- Requires-Dist: mcp[cli]; extra == "mcp"
28
+ Requires-Dist: mcp[cli]<2,>=1.2; extra == "mcp"
29
29
  Provides-Extra: web
30
30
  Requires-Dist: flask>=3.0; extra == "web"
31
31
  Requires-Dist: markdown>=3.5; extra == "web"
@@ -34,7 +34,7 @@ Requires-Dist: pytest; extra == "dev"
34
34
  Requires-Dist: pytest-cov; extra == "dev"
35
35
  Provides-Extra: all
36
36
  Requires-Dist: psycopg[binary]; extra == "all"
37
- Requires-Dist: mcp[cli]; extra == "all"
37
+ Requires-Dist: mcp[cli]<2,>=1.2; extra == "all"
38
38
  Requires-Dist: flask>=3.0; extra == "all"
39
39
  Requires-Dist: markdown>=3.5; extra == "all"
40
40
  Requires-Dist: pytest; extra == "all"
@@ -48,7 +48,7 @@ Dynamic: license-file
48
48
  [![CI](https://github.com/fotodeveloper/dct/actions/workflows/ci.yml/badge.svg)](https://github.com/fotodeveloper/dct/actions/workflows/ci.yml)
49
49
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
50
50
 
51
- Persistent, queryable memory for AI coding assistants — your assistant reads facts from a database instead of guessing. One user-scoped database tracks issues, todos, changelog entries, plans, sprints, and decisions across all your projects. Built for [Claude Code](https://claude.com/claude-code) (skills, hooks, 49 MCP tools); the MCP server and CLI work with any MCP-capable assistant.
51
+ Persistent, queryable memory for AI coding assistants — your assistant reads facts from a database instead of guessing. One user-scoped database tracks issues, todos, changelog entries, plans, sprints, and decisions across all your projects. Built for [Claude Code](https://claude.com/claude-code) (skills, hooks, 52 MCP tools); the MCP server and CLI work with any MCP-capable assistant.
52
52
 
53
53
  Part of the **dc tools** family ([dctools.dev](https://dctools.dev)) — *deterministic context* for AI-assisted development.
54
54
 
@@ -122,7 +122,7 @@ markdown, and makes the release commit.
122
122
  - **Decision records** — ADRs, sprint amendments, inline plan decisions with multi-anchor linkage
123
123
  - **LLM-assisted classification** — uncertain plan sections auto-resolved via `claude --print` (haiku by default)
124
124
  - **Passive autoscan hook** — PostToolUse hook re-scans plan-like files in background on every edit
125
- - **MCP server** — native Claude Code integration via FastMCP, 49 tools
125
+ - **MCP server** — native Claude Code integration via FastMCP
126
126
  - **CLI** — fast `dct` command for hooks and scripts
127
127
  - **Skills** — Claude Code slash commands (`/track`, `/clog`, `/dct-import`, `/plan-status`, …)
128
128
  - **Soft deletes** — ledger pattern, nothing is ever lost
@@ -257,7 +257,12 @@ dct sprint create "Payments hardening" --code pay-1 --goal "Close the P1 backlog
257
257
  dct sprint show 3 # Details + linked items
258
258
  dct sprint activate 3 # Shortcuts: activate / complete / defer
259
259
 
260
- # Handoffs (amend an unconsumed handoff in place; creation is /handoff or MCP)
260
+ # Session edges (same composed ops the /pickup and /handoff skills use)
261
+ dct pickup # Snapshot: sprint, handoffs, gates, decisions
262
+ # (read-only — --consume defaults to never)
263
+ dct handoff create --prompt-file - --add-items 22,6 # Sprint change + handoff, one transaction
264
+ # --remove-items cuts scope, --items replaces;
265
+ # omit all three to leave the sprint untouched
261
266
  dct handoff update 12 --prompt "Wire the toggle into settings next"
262
267
 
263
268
  # Config
@@ -267,7 +272,7 @@ dct config path # Show resolved config/data locations
267
272
 
268
273
  ## MCP Tools
269
274
 
270
- dct ships **49 MCP tools**, registered at user scope so they're callable from any Claude Code session as `mcp__dct__<name>`. Zero-arg calls auto-detect the project from the session's working directory; pass `project=<slug>` to override. Write ops return a minimal ack (id + a few post-state scalars); fetch full context with `get_item(id)` / `get_handoff(id)`. Ten mid-session daily drivers are always resident (`alwaysLoad`); the rest of the catalogue loads on demand via ToolSearch:
275
+ dct ships **52 MCP tools**, registered at user scope so they're callable from any Claude Code session as `mcp__dct__<name>`. Zero-arg calls auto-detect the project from the session's working directory; pass `project=<slug>` to override. Write ops return a minimal ack (id + a few post-state scalars); fetch full context with `get_item(id)` / `get_handoff(id)`. Thirteen tools are always resident (`alwaysLoad`) — ten mid-session daily drivers plus the three composed workflow ops (`pickup_work`, `handoff_work`, `get_context_snapshot`); the rest of the catalogue loads on demand via ToolSearch:
271
276
 
272
277
  | Tool | Purpose |
273
278
  |---|---|
@@ -282,7 +287,7 @@ dct ships **49 MCP tools**, registered at user scope so they're callable from an
282
287
  | `reject_checkpoint` | Mark a checkpoint rejected (scope change) — ledger-preserving |
283
288
  | `add_changelog` | Add an unreleased changelog entry |
284
289
 
285
- Session-edge tools stay deferred: `get_handoff` (full handoff dict — untruncated prompt + context_summary), `create_handoff`, `set_sprint`, `release_changelog`, and more. Full categorized catalogue of all 49 tools: **[docs/mcp-tools.md](docs/mcp-tools.md)**.
290
+ Session-edge tools stay deferred: `get_handoff` (full handoff dict — untruncated prompt + context_summary), `create_handoff`, `set_sprint`, `release_changelog`, and more. Full categorized catalogue: **[docs/mcp-tools.md](docs/mcp-tools.md)**.
286
291
 
287
292
  ## Plans, Roadmaps & Decisions
288
293
 
@@ -360,7 +365,7 @@ The `DCT_DATABASE_URL` environment variable overrides `[database].url`. For the
360
365
  ## Testing
361
366
 
362
367
  ```bash
363
- .venv/bin/pytest -q # All tests (938)
368
+ .venv/bin/pytest -q # All tests
364
369
  .venv/bin/pytest tests/test_plan_parser.py -v # Parser only
365
370
  .venv/bin/pytest tests/test_plan_ingest.py -v # Ingest + sticky-resolved semantics
366
371
 
@@ -5,7 +5,7 @@
5
5
  [![CI](https://github.com/fotodeveloper/dct/actions/workflows/ci.yml/badge.svg)](https://github.com/fotodeveloper/dct/actions/workflows/ci.yml)
6
6
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
7
7
 
8
- Persistent, queryable memory for AI coding assistants — your assistant reads facts from a database instead of guessing. One user-scoped database tracks issues, todos, changelog entries, plans, sprints, and decisions across all your projects. Built for [Claude Code](https://claude.com/claude-code) (skills, hooks, 49 MCP tools); the MCP server and CLI work with any MCP-capable assistant.
8
+ Persistent, queryable memory for AI coding assistants — your assistant reads facts from a database instead of guessing. One user-scoped database tracks issues, todos, changelog entries, plans, sprints, and decisions across all your projects. Built for [Claude Code](https://claude.com/claude-code) (skills, hooks, 52 MCP tools); the MCP server and CLI work with any MCP-capable assistant.
9
9
 
10
10
  Part of the **dc tools** family ([dctools.dev](https://dctools.dev)) — *deterministic context* for AI-assisted development.
11
11
 
@@ -79,7 +79,7 @@ markdown, and makes the release commit.
79
79
  - **Decision records** — ADRs, sprint amendments, inline plan decisions with multi-anchor linkage
80
80
  - **LLM-assisted classification** — uncertain plan sections auto-resolved via `claude --print` (haiku by default)
81
81
  - **Passive autoscan hook** — PostToolUse hook re-scans plan-like files in background on every edit
82
- - **MCP server** — native Claude Code integration via FastMCP, 49 tools
82
+ - **MCP server** — native Claude Code integration via FastMCP
83
83
  - **CLI** — fast `dct` command for hooks and scripts
84
84
  - **Skills** — Claude Code slash commands (`/track`, `/clog`, `/dct-import`, `/plan-status`, …)
85
85
  - **Soft deletes** — ledger pattern, nothing is ever lost
@@ -214,7 +214,12 @@ dct sprint create "Payments hardening" --code pay-1 --goal "Close the P1 backlog
214
214
  dct sprint show 3 # Details + linked items
215
215
  dct sprint activate 3 # Shortcuts: activate / complete / defer
216
216
 
217
- # Handoffs (amend an unconsumed handoff in place; creation is /handoff or MCP)
217
+ # Session edges (same composed ops the /pickup and /handoff skills use)
218
+ dct pickup # Snapshot: sprint, handoffs, gates, decisions
219
+ # (read-only — --consume defaults to never)
220
+ dct handoff create --prompt-file - --add-items 22,6 # Sprint change + handoff, one transaction
221
+ # --remove-items cuts scope, --items replaces;
222
+ # omit all three to leave the sprint untouched
218
223
  dct handoff update 12 --prompt "Wire the toggle into settings next"
219
224
 
220
225
  # Config
@@ -224,7 +229,7 @@ dct config path # Show resolved config/data locations
224
229
 
225
230
  ## MCP Tools
226
231
 
227
- dct ships **49 MCP tools**, registered at user scope so they're callable from any Claude Code session as `mcp__dct__<name>`. Zero-arg calls auto-detect the project from the session's working directory; pass `project=<slug>` to override. Write ops return a minimal ack (id + a few post-state scalars); fetch full context with `get_item(id)` / `get_handoff(id)`. Ten mid-session daily drivers are always resident (`alwaysLoad`); the rest of the catalogue loads on demand via ToolSearch:
232
+ dct ships **52 MCP tools**, registered at user scope so they're callable from any Claude Code session as `mcp__dct__<name>`. Zero-arg calls auto-detect the project from the session's working directory; pass `project=<slug>` to override. Write ops return a minimal ack (id + a few post-state scalars); fetch full context with `get_item(id)` / `get_handoff(id)`. Thirteen tools are always resident (`alwaysLoad`) — ten mid-session daily drivers plus the three composed workflow ops (`pickup_work`, `handoff_work`, `get_context_snapshot`); the rest of the catalogue loads on demand via ToolSearch:
228
233
 
229
234
  | Tool | Purpose |
230
235
  |---|---|
@@ -239,7 +244,7 @@ dct ships **49 MCP tools**, registered at user scope so they're callable from an
239
244
  | `reject_checkpoint` | Mark a checkpoint rejected (scope change) — ledger-preserving |
240
245
  | `add_changelog` | Add an unreleased changelog entry |
241
246
 
242
- Session-edge tools stay deferred: `get_handoff` (full handoff dict — untruncated prompt + context_summary), `create_handoff`, `set_sprint`, `release_changelog`, and more. Full categorized catalogue of all 49 tools: **[docs/mcp-tools.md](docs/mcp-tools.md)**.
247
+ Session-edge tools stay deferred: `get_handoff` (full handoff dict — untruncated prompt + context_summary), `create_handoff`, `set_sprint`, `release_changelog`, and more. Full categorized catalogue: **[docs/mcp-tools.md](docs/mcp-tools.md)**.
243
248
 
244
249
  ## Plans, Roadmaps & Decisions
245
250
 
@@ -317,7 +322,7 @@ The `DCT_DATABASE_URL` environment variable overrides `[database].url`. For the
317
322
  ## Testing
318
323
 
319
324
  ```bash
320
- .venv/bin/pytest -q # All tests (938)
325
+ .venv/bin/pytest -q # All tests
321
326
  .venv/bin/pytest tests/test_plan_parser.py -v # Parser only
322
327
  .venv/bin/pytest tests/test_plan_ingest.py -v # Ingest + sticky-resolved semantics
323
328
 
@@ -0,0 +1,6 @@
1
+ from importlib.metadata import PackageNotFoundError, version
2
+
3
+ try:
4
+ __version__ = version("dctracker")
5
+ except PackageNotFoundError:
6
+ __version__ = "1.3.0"
@@ -2,17 +2,20 @@
2
2
 
3
3
  import argparse
4
4
  import atexit
5
+ import contextlib
5
6
  import os
6
7
  import signal
7
8
  import socket
8
9
  import subprocess
9
10
  import sys
11
+ from datetime import UTC
10
12
  from pathlib import Path
11
13
 
12
14
  from dct import __version__, migrate, paths
13
- from dct.config import load_config, save_config, Config
14
- from dct.db import get_engine, create_tables
15
+ from dct.config import Config, load_config, save_config
15
16
  from dct.core.engine import bootstrap_engine
17
+ from dct.core.items import TERMINAL_STATUSES
18
+ from dct.db import create_tables, get_engine
16
19
 
17
20
  # Windows-policy seam (tests flip it): dispatches the daemonization,
18
21
  # liveness-probe and signal paths that differ on win32 (W8-W10, ADR #52).
@@ -35,6 +38,7 @@ def _get_engine():
35
38
  def _detect_project(engine) -> str:
36
39
  """Auto-detect project slug from CWD. Exits if not found."""
37
40
  import os
41
+
38
42
  from dct.core.projects import get_project_by_path
39
43
 
40
44
  cwd = os.getcwd()
@@ -72,6 +76,59 @@ def _prompt_version(default: str = "0.1.0") -> str | None:
72
76
  return shortcut.get(choice, choice)
73
77
 
74
78
 
79
+ def _read_text_arg(text: str | None, path: str | None) -> str | None:
80
+ """Resolve a `--foo` / `--foo-file` flag pair to text ('-' = stdin).
81
+
82
+ The file wins when both are given. Returns None when neither is supplied,
83
+ so callers can tell "not passed" from an empty string. Raises (OSError) on
84
+ an unreadable path — call it INSIDE the command's try/except so the user
85
+ gets `✗ Error: ...` instead of a traceback.
86
+
87
+ Encoding is pinned to UTF-8 on BOTH paths: handoff prompts routinely carry
88
+ '✓', '—' and non-ASCII prose, and the platform default (cp1252 on Windows,
89
+ or a C-locale ASCII stdin) would mangle or reject them.
90
+ """
91
+ if path:
92
+ if path != "-":
93
+ return Path(path).read_text(encoding="utf-8")
94
+ # Decode the raw byte stream ourselves so a C-locale stdin still takes
95
+ # UTF-8. `buffer` is absent when stdin is a StringIO (tests, embedders)
96
+ # — then the text stream is already decoded.
97
+ buffer = getattr(sys.stdin, "buffer", None)
98
+ if buffer is None:
99
+ return sys.stdin.read()
100
+ return buffer.read().decode("utf-8")
101
+ return text
102
+
103
+
104
+ def _json_default(obj):
105
+ """json.dumps fallback for core payloads — datetime → ISO 8601, rest → str.
106
+
107
+ Core dicts carry real datetime objects (created_at, consumed_at, ...);
108
+ without this every `--json` surface would raise TypeError.
109
+ """
110
+ from datetime import date, datetime
111
+
112
+ if isinstance(obj, (datetime, date)):
113
+ return obj.isoformat()
114
+ return str(obj)
115
+
116
+
117
+ def _default_identity() -> str:
118
+ """Actor identity used when pickup/handoff callers omit theirs.
119
+
120
+ Shape: `<client> @ <UTC minute>` — the provenance client when the host
121
+ self-identifies (Claude Code, a G2 adapter), else "cli". Commands ECHO the
122
+ resolved value so attribution is never silent.
123
+ """
124
+ from datetime import datetime
125
+
126
+ from dct.core.provenance import get_provenance
127
+
128
+ client = get_provenance().actor_client or "cli"
129
+ return f"{client} @ {datetime.now(UTC).strftime('%Y-%m-%dT%H:%MZ')}"
130
+
131
+
75
132
  def _print_table(headers: list[str], rows: list[list[str]], max_widths: dict[int, int] | None = None) -> None:
76
133
  """Print a simple aligned table."""
77
134
  if not rows:
@@ -239,6 +296,7 @@ def cmd_hooks_wire(args):
239
296
 
240
297
  def cmd_project_add(args):
241
298
  import os
299
+
242
300
  from dct.core.projects import create_project, set_version
243
301
 
244
302
  # #227: realpath (not abspath) so a symlinked path is stored canonically and
@@ -360,7 +418,11 @@ def cmd_project_changelog_path(args):
360
418
  With a relpath → set it (validated to stay within the project root). Without
361
419
  → print the current relpath and its resolved absolute path.
362
420
  """
363
- from dct.core.projects import get_project, resolve_changelog_path, set_changelog_path
421
+ from dct.core.projects import (
422
+ get_project,
423
+ resolve_changelog_path,
424
+ set_changelog_path,
425
+ )
364
426
 
365
427
  engine = _get_engine()
366
428
 
@@ -400,6 +462,7 @@ def cmd_plan_rescan(args):
400
462
  injection + [x] state reading (§9.2).
401
463
  """
402
464
  import os
465
+
403
466
  from dct.core.plans.ingest import ingest_file
404
467
  from dct.core.projects import get_project_by_path
405
468
 
@@ -614,19 +677,69 @@ def cmd_delete(args):
614
677
 
615
678
  # ── Handoff commands ──────────────────────────────────────────────────────────
616
679
 
617
- def cmd_handoff_update(args):
618
- from dct.core.handoff import update_handoff
680
+ def cmd_handoff_create(args):
681
+ """`dct handoff create` — session-end op: set sprint membership + leave the
682
+ handoff prompt in ONE transaction (workflows.session.handoff_work)."""
683
+ import json
684
+
685
+ from dct.workflows.session import handoff_work
619
686
 
620
687
  engine = _get_engine()
688
+ try:
689
+ project = args.project or _detect_project(engine)
690
+ prompt = _read_text_arg(args.prompt, args.prompt_file)
691
+ identity = args.created_by or _default_identity()
692
+
693
+ def _ids(raw: str | None, flag: str) -> list[int] | None:
694
+ # None → flag unused; empty/garbage → error (a blank shell var
695
+ # must never read as "no items" or wipe anything — K-27).
696
+ if raw is None:
697
+ return None
698
+ try:
699
+ ids = [int(x.strip()) for x in raw.split(",") if x.strip()]
700
+ except ValueError:
701
+ raise ValueError(
702
+ f"{flag} expects comma-separated item ids, got: {raw!r}"
703
+ ) from None
704
+ if not ids:
705
+ raise ValueError(f"{flag} got no ids ({raw!r}) — omit the flag instead")
706
+ return ids
707
+
708
+ result = handoff_work(
709
+ engine, project,
710
+ prompt=prompt or "",
711
+ created_by=identity,
712
+ item_ids=_ids(args.items, "--items"),
713
+ add_item_ids=_ids(args.add_items, "--add-items"),
714
+ remove_item_ids=_ids(args.remove_items, "--remove-items"),
715
+ sprint=args.sprint,
716
+ scope=args.scope,
717
+ context_summary=args.context_summary,
718
+ )
719
+ except Exception as e:
720
+ print(f"✗ Error: {e}", file=sys.stderr)
721
+ sys.exit(1)
621
722
 
622
- def _read(text, path):
623
- if path:
624
- return sys.stdin.read() if path == "-" else Path(path).read_text()
625
- return text
723
+ if args.json:
724
+ print(json.dumps(result, indent=2, default=_json_default))
725
+ return
726
+
727
+ h = result["handoff"]
728
+ print(f"✓ Handoff #{h['id']} created (scope: {h.get('scope') or 'none'}, by {identity})")
729
+ sprint = result.get("sprint")
730
+ if sprint:
731
+ print(f" sprint #{sprint['sprint_id']}: {sprint['open_size']} open items")
732
+ else:
733
+ print(" sprint untouched")
626
734
 
627
- prompt = _read(args.prompt, args.prompt_file)
735
+
736
+ def cmd_handoff_update(args):
737
+ from dct.core.handoff import update_handoff
738
+
739
+ engine = _get_engine()
628
740
 
629
741
  try:
742
+ prompt = _read_text_arg(args.prompt, args.prompt_file)
630
743
  result = update_handoff(
631
744
  engine, args.id,
632
745
  prompt=prompt or None,
@@ -642,6 +755,129 @@ def cmd_handoff_update(args):
642
755
  sys.exit(1)
643
756
 
644
757
 
758
+ # ── Session pickup (#413) ─────────────────────────────────────────────────────
759
+
760
+ def _cols_rows_dicts(table: dict | None) -> list[dict]:
761
+ """{cols, rows} → list of dicts — the compact wire shape read by name."""
762
+ if not table:
763
+ return []
764
+ return [dict(zip(table["cols"], row)) for row in table["rows"]]
765
+
766
+
767
+ _CONTROL_CHARS = {c: None for c in range(0x20) if c not in (0x09, 0x0A)} | {0x7F: None}
768
+
769
+
770
+ def _strip_controls(text: str | None) -> str:
771
+ """Drop C0 control characters from DB text before printing it.
772
+
773
+ Handoff prompts and item titles are free text written by another session;
774
+ an ESC sequence in there would repaint or clear the reader's terminal. Tabs
775
+ and newlines are legitimate formatting and survive — everything else below
776
+ 0x20, plus DEL, is removed. None → "".
777
+ """
778
+ if not text:
779
+ return ""
780
+ return text.translate(_CONTROL_CHARS)
781
+
782
+
783
+ def _render_pickup(snap: dict, identity: str | None) -> None:
784
+ """Human view of `dct pickup`: what was consumed, then the state that
785
+ matters at session start. Sections dropped via --include are simply
786
+ absent from the snapshot, so every lookup is guarded. ALL free text coming
787
+ from the DB is passed through _strip_controls — a stored escape sequence
788
+ must not drive the reader's terminal."""
789
+ picked = snap.get("picked_up")
790
+ summary = snap.get("summary", {})
791
+
792
+ if picked:
793
+ print(f"✓ Picked up handoff #{picked['id']} (consumed as {identity})")
794
+ print()
795
+ for line in _strip_controls(picked.get("prompt")).splitlines():
796
+ print(f" {line}")
797
+ else:
798
+ print(f"— no handoff consumed ({summary.get('handoffs_pending', 0)} pending)")
799
+
800
+ sprint = snap.get("sprint")
801
+ if sprint:
802
+ items = _cols_rows_dicts(sprint.get("items"))
803
+ pending_cp = sum(
804
+ 1 for c in _cols_rows_dicts(sprint.get("checkpoints")) if c.get("status") == "pending"
805
+ )
806
+ print(f"\nSprint items ({pending_cp} pending checkpoints):")
807
+ _print_table(
808
+ ["#", "sprint", "prio", "status", "cp", "title"],
809
+ [
810
+ [
811
+ str(i["id"]), str(i.get("sprint_id") or ""), i.get("priority") or "",
812
+ i.get("status") or "", "{}/{}".format(*(i.get("cp") or [0, 0])),
813
+ _strip_controls(i.get("title")),
814
+ ]
815
+ for i in items
816
+ ],
817
+ max_widths={5: 60},
818
+ )
819
+
820
+ gates = _cols_rows_dicts(snap.get("gates"))
821
+ if gates:
822
+ print("\nPending gates:")
823
+ _print_table(
824
+ ["#", "cp", "kind", "prio", "title", "text"],
825
+ [
826
+ [
827
+ str(g["item_id"]), str(g["checkpoint_id"]), g.get("kind") or "",
828
+ g.get("priority") or "", _strip_controls(g.get("item_title")),
829
+ _strip_controls(g.get("text")),
830
+ ]
831
+ for g in gates
832
+ ],
833
+ max_widths={4: 30, 5: 50},
834
+ )
835
+
836
+ if summary:
837
+ print(
838
+ f"\nSprint: size {summary.get('sprint_size', 0)}, "
839
+ f"done {summary.get('sprint_completion', '0/0')} · "
840
+ f"open in project: {summary.get('total_open_in_project', 0)} · "
841
+ f"handoffs pending: {summary.get('handoffs_pending', 0)} · "
842
+ f"gates: {summary.get('gates_pending', 0)}"
843
+ )
844
+
845
+
846
+ def cmd_pickup(args):
847
+ """`dct pickup` — session-start op: one snapshot read + (possibly) consume
848
+ ONE handoff (workflows.session.pickup_work)."""
849
+ import json
850
+
851
+ from dct.workflows.session import parse_sections, pickup_work
852
+
853
+ engine = _get_engine()
854
+ consume = {"auto": None, "always": True, "never": False}[args.consume]
855
+ # consumed_by is REQUIRED by pickup_work whenever consumption is possible;
856
+ # default it (and echo it) rather than let the op fail on a missing identity.
857
+ identity = args.consumed_by or (None if consume is False else _default_identity())
858
+
859
+ try:
860
+ project = args.project or _detect_project(engine)
861
+ include = parse_sections(args.include)
862
+ snap = pickup_work(
863
+ engine, project,
864
+ consume=consume,
865
+ handoff_id=args.handoff_id,
866
+ scope=args.scope,
867
+ consumed_by=identity,
868
+ since=args.since,
869
+ include=include,
870
+ )
871
+ except Exception as e:
872
+ print(f"✗ Error: {e}", file=sys.stderr)
873
+ sys.exit(1)
874
+
875
+ if args.json:
876
+ print(json.dumps(snap, indent=2, default=_json_default))
877
+ return
878
+ _render_pickup(snap, identity)
879
+
880
+
645
881
  # ── Changelog commands ────────────────────────────────────────────────────────
646
882
 
647
883
  def cmd_clog_add(args):
@@ -695,7 +931,8 @@ def cmd_clog_count(args):
695
931
 
696
932
  def cmd_clog_release(args):
697
933
  import subprocess
698
- from dct.core.changelog import release_entries, count_unreleased
934
+
935
+ from dct.core.changelog import count_unreleased, release_entries
699
936
  from dct.core.projects import get_project
700
937
  from dct.core.version import bump
701
938
 
@@ -777,6 +1014,7 @@ def cmd_internal_task_mirror(args):
777
1014
  project: dct project slug. Empty → autodetect from CWD.
778
1015
  """
779
1016
  import json
1017
+
780
1018
  from dct.config import load_config
781
1019
 
782
1020
  try:
@@ -812,8 +1050,8 @@ def cmd_internal_task_mirror(args):
812
1050
  if not todos:
813
1051
  return
814
1052
 
815
- from dct.core.items import list_items, create_item
816
1053
  from dct.core.checkpoints import add_checkpoint
1054
+ from dct.core.items import create_item, list_items
817
1055
  from dct.core.projects import get_project_by_path
818
1056
 
819
1057
  engine = _get_engine()
@@ -941,7 +1179,7 @@ def cmd_checkpoint_add(args):
941
1179
 
942
1180
 
943
1181
  def cmd_checkpoint_list(args):
944
- from dct.core.checkpoints import list_checkpoints, get_progress
1182
+ from dct.core.checkpoints import get_progress, list_checkpoints
945
1183
 
946
1184
  engine = _get_engine()
947
1185
  cps = list_checkpoints(engine, args.item_id)
@@ -1125,7 +1363,8 @@ def cmd_upgrade(args):
1125
1363
  matrix so the user knows what takes effect when.
1126
1364
  """
1127
1365
  import subprocess
1128
- from dct.update import detect_install_method, upgrade_command, dist_pkg
1366
+
1367
+ from dct.update import detect_install_method, dist_pkg, upgrade_command
1129
1368
 
1130
1369
  method = detect_install_method()
1131
1370
 
@@ -1138,14 +1377,18 @@ def cmd_upgrade(args):
1138
1377
  cmd = upgrade_command(method)
1139
1378
  print(f"Detected install method: {method}")
1140
1379
  print(f"Upgrading {dist_pkg()} … ({' '.join(cmd)})")
1141
- result = subprocess.run(cmd)
1380
+ result = subprocess.run(cmd, check=False)
1142
1381
  if result.returncode != 0:
1143
1382
  print(f"✗ Upgrade command failed (exit {result.returncode})", file=sys.stderr)
1144
1383
  sys.exit(result.returncode)
1145
1384
 
1146
1385
  # Refresh Claude Code integration: re-link current skills/hooks (idempotent),
1147
1386
  # then remove links to skills/hooks that the new package no longer ships.
1148
- from dct.integrations.claude import install_hook, install_skills, prune_dangling_symlinks
1387
+ from dct.integrations.claude import (
1388
+ install_hook,
1389
+ install_skills,
1390
+ prune_dangling_symlinks,
1391
+ )
1149
1392
 
1150
1393
  linked_ok = True
1151
1394
  removed: list[str] = []
@@ -1190,6 +1433,7 @@ def _maybe_print_legacy_nudge() -> None:
1190
1433
 
1191
1434
  def cmd_project_detect(args):
1192
1435
  import os
1436
+
1193
1437
  from dct.core.projects import get_project_by_path
1194
1438
 
1195
1439
  engine = _get_engine()
@@ -1608,7 +1852,9 @@ def build_parser() -> argparse.ArgumentParser:
1608
1852
  # resolve
1609
1853
  p_resolve = sub.add_parser("resolve", help="Resolve/close an item")
1610
1854
  p_resolve.add_argument("id", type=int)
1611
- p_resolve.add_argument("--status", "-s", default="resolved", choices=["resolved", "wont_fix"])
1855
+ p_resolve.add_argument(
1856
+ "--status", "-s", default="resolved", choices=list(TERMINAL_STATUSES)
1857
+ )
1612
1858
  p_resolve.add_argument("--resolution", "-r")
1613
1859
 
1614
1860
  # note
@@ -1741,10 +1987,62 @@ def build_parser() -> argparse.ArgumentParser:
1741
1987
  p_verb.add_argument("id", type=int)
1742
1988
  p_verb.set_defaults(_forced_status=_st)
1743
1989
 
1744
- # web (read-only browser dashboard daemon — v0.4.1)
1990
+ # pickup (session-start op — snapshot + handoff consume, #413)
1991
+ p_pickup = sub.add_parser(
1992
+ "pickup",
1993
+ help="Session-start op: project snapshot + consume the pending handoff",
1994
+ )
1995
+ p_pickup.add_argument("--project", help="Project slug (default: auto-detect from CWD)")
1996
+ p_pickup.add_argument("--since", default="last_release",
1997
+ help="Activity window anchor for recent_changes: "
1998
+ "last_release (default) | all | <N>h (e.g. 24h) "
1999
+ "| ISO date")
2000
+ p_pickup.add_argument("--include", default=None,
2001
+ help="Comma-separated snapshot sections (sprint,handoff,gates,"
2002
+ "decisions,recent_changes). 'none' = summary only.")
2003
+ p_pickup.add_argument("--consume", choices=["auto", "always", "never"], default="never",
2004
+ help="never (default): pure read — pickup does not write; "
2005
+ "auto: consume an unambiguous single candidate; "
2006
+ "always: consume or fail (autonomous flows)")
2007
+ p_pickup.add_argument("--handoff-id", type=int, default=None,
2008
+ help="Consume this specific handoff (wins over --scope)")
2009
+ p_pickup.add_argument("--scope", default=None,
2010
+ help="Only consider handoffs carrying this scope tag")
2011
+ p_pickup.add_argument("--consumed-by", default=None,
2012
+ help="Session identity stamped on consume "
2013
+ "(default: '<client> @ <UTC minute>', echoed)")
2014
+ p_pickup.add_argument("--json", action="store_true", help="Print the raw snapshot as JSON")
2015
+
2016
+ # handoff (session-end ledger)
1745
2017
  p_handoff = sub.add_parser("handoff", help="Manage session handoffs")
1746
2018
  p_ho_sub = p_handoff.add_subparsers(dest="handoff_command")
1747
2019
 
2020
+ p_ho_create = p_ho_sub.add_parser(
2021
+ "create",
2022
+ help="Session-end op: set sprint membership + leave a handoff prompt (one transaction)",
2023
+ )
2024
+ p_ho_create.add_argument("--prompt", default=None, help="Handoff prompt text")
2025
+ p_ho_create.add_argument("--prompt-file", default=None,
2026
+ help="Read the prompt from a file ('-' = stdin)")
2027
+ p_ho_create.add_argument("--created-by", default=None,
2028
+ help="Session identity (default: '<client> @ <UTC minute>', echoed)")
2029
+ p_ho_create.add_argument("--items", default=None,
2030
+ help="REPLACE the sprint's OPEN membership with these ids "
2031
+ "(e.g. 22,21,6) — a conscious replan; resolved members "
2032
+ "stay as history. Mutually exclusive with --add-items/"
2033
+ "--remove-items. Omit all item flags to keep the sprint.")
2034
+ p_ho_create.add_argument("--add-items", default=None,
2035
+ help="ADD these item ids to the target sprint (additive edge)")
2036
+ p_ho_create.add_argument("--remove-items", default=None,
2037
+ help="CUT these OPEN items from the sprint's scope "
2038
+ "(terminal members refuse — they are history)")
2039
+ p_ho_create.add_argument("--sprint", default=None,
2040
+ help="Target sprint id or code (required when several are active)")
2041
+ p_ho_create.add_argument("--scope", default=None, help="Scope tag")
2042
+ p_ho_create.add_argument("--context-summary", default=None, help="Context summary snapshot")
2043
+ p_ho_create.add_argument("--project", help="Project slug (default: auto-detect from CWD)")
2044
+ p_ho_create.add_argument("--json", action="store_true", help="Print the raw result as JSON")
2045
+
1748
2046
  p_ho_update = p_ho_sub.add_parser("update", help="Amend an unconsumed handoff in place")
1749
2047
  p_ho_update.add_argument("id", type=int, help="Handoff id")
1750
2048
  p_ho_update.add_argument("--prompt", default=None, help="New prompt text")
@@ -1752,6 +2050,7 @@ def build_parser() -> argparse.ArgumentParser:
1752
2050
  p_ho_update.add_argument("--scope", default=None, help="New scope tag")
1753
2051
  p_ho_update.add_argument("--context-summary", default=None, help="New context summary snapshot")
1754
2052
 
2053
+ # web (read-only browser dashboard daemon — v0.4.1)
1755
2054
  p_web = sub.add_parser("web", help="Read-only browser dashboard daemon")
1756
2055
  p_web_sub = p_web.add_subparsers(dest="web_command")
1757
2056
 
@@ -1799,6 +2098,7 @@ COMMAND_MAP = {
1799
2098
  "resolve": cmd_resolve,
1800
2099
  "note": cmd_note,
1801
2100
  "delete": cmd_delete,
2101
+ "pickup": cmd_pickup,
1802
2102
  "_task-mirror": cmd_internal_task_mirror,
1803
2103
  "_web-serve": cmd_internal_web_serve,
1804
2104
  }
@@ -1852,6 +2152,7 @@ CHECKPOINT_COMMAND_MAP = {
1852
2152
  }
1853
2153
 
1854
2154
  HANDOFF_COMMAND_MAP = {
2155
+ "create": cmd_handoff_create,
1855
2156
  "update": cmd_handoff_update,
1856
2157
  }
1857
2158
 
@@ -1928,7 +2229,8 @@ def _maybe_nudge(args) -> None:
1928
2229
  suppressed, or opted-out command) is silent and never affects the command's
1929
2230
  own output or exit code.
1930
2231
  """
1931
- try:
2232
+ # A passive nicety must never break a command.
2233
+ with contextlib.suppress(Exception):
1932
2234
  if not sys.stderr.isatty() or _nudge_suppressed(args):
1933
2235
  return
1934
2236
  from dct.config import load_config
@@ -1940,8 +2242,6 @@ def _maybe_nudge(args) -> None:
1940
2242
  notice = get_cached_notice(config=cfg)
1941
2243
  if notice:
1942
2244
  print(notice, file=sys.stderr)
1943
- except Exception:
1944
- pass # a passive nicety must never break a command
1945
2245
 
1946
2246
 
1947
2247
  def main():