dctracker 1.0.0__tar.gz → 1.1.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 (141) hide show
  1. {dctracker-1.0.0/dctracker.egg-info → dctracker-1.1.0}/PKG-INFO +18 -8
  2. {dctracker-1.0.0 → dctracker-1.1.0}/README.md +17 -7
  3. {dctracker-1.0.0 → dctracker-1.1.0}/dct/__init__.py +1 -1
  4. {dctracker-1.0.0 → dctracker-1.1.0}/dct/cli.py +174 -3
  5. {dctracker-1.0.0 → dctracker-1.1.0}/dct/config.py +21 -1
  6. {dctracker-1.0.0 → dctracker-1.1.0}/dct/core/engine.py +8 -4
  7. {dctracker-1.0.0 → dctracker-1.1.0}/dct/db.py +18 -0
  8. dctracker-1.1.0/dct/hooks/check-changelog-on-stop.sh +112 -0
  9. {dctracker-1.0.0 → dctracker-1.1.0}/dct/hooks/dct-task-mirror.sh +15 -25
  10. {dctracker-1.0.0 → dctracker-1.1.0}/dct/server.py +372 -194
  11. {dctracker-1.0.0 → dctracker-1.1.0}/dct/setup.py +41 -2
  12. {dctracker-1.0.0 → dctracker-1.1.0}/dct/skills/clog/SKILL.md +4 -9
  13. dctracker-1.1.0/dct/skills/commit/SKILL.md +114 -0
  14. {dctracker-1.0.0 → dctracker-1.1.0}/dct/skills/dct-import/SKILL.md +2 -1
  15. {dctracker-1.0.0 → dctracker-1.1.0}/dct/skills/dct-init/SKILL.md +2 -1
  16. dctracker-1.1.0/dct/skills/handoff/SKILL.md +129 -0
  17. dctracker-1.1.0/dct/skills/pickup/SKILL.md +110 -0
  18. {dctracker-1.0.0 → dctracker-1.1.0}/dct/skills/plan-resolve-uncertain/SKILL.md +2 -1
  19. {dctracker-1.0.0 → dctracker-1.1.0}/dct/skills/plan-status/SKILL.md +5 -6
  20. {dctracker-1.0.0 → dctracker-1.1.0}/dct/skills/release/SKILL.md +73 -60
  21. {dctracker-1.0.0 → dctracker-1.1.0}/dct/skills/track/SKILL.md +4 -8
  22. {dctracker-1.0.0 → dctracker-1.1.0}/dct/skills/track-list/SKILL.md +4 -5
  23. {dctracker-1.0.0 → dctracker-1.1.0}/dct/skills/track-resolve/SKILL.md +3 -4
  24. {dctracker-1.0.0 → dctracker-1.1.0}/dct/skills/track-update/SKILL.md +4 -7
  25. dctracker-1.1.0/dct/update.py +318 -0
  26. {dctracker-1.0.0 → dctracker-1.1.0/dctracker.egg-info}/PKG-INFO +18 -8
  27. {dctracker-1.0.0 → dctracker-1.1.0}/dctracker.egg-info/SOURCES.txt +5 -0
  28. {dctracker-1.0.0 → dctracker-1.1.0}/pyproject.toml +1 -1
  29. {dctracker-1.0.0 → dctracker-1.1.0}/tests/test_engine.py +37 -0
  30. dctracker-1.1.0/tests/test_hook_changelog_stop.py +241 -0
  31. dctracker-1.1.0/tests/test_hook_task_mirror.py +211 -0
  32. dctracker-1.1.0/tests/test_server_acks.py +425 -0
  33. dctracker-1.1.0/tests/test_server_alwaysload.py +71 -0
  34. dctracker-1.1.0/tests/test_server_handoff_view.py +111 -0
  35. dctracker-1.1.0/tests/test_update.py +532 -0
  36. dctracker-1.0.0/dct/hooks/check-changelog-on-stop.sh +0 -49
  37. dctracker-1.0.0/dct/skills/commit/SKILL.md +0 -153
  38. dctracker-1.0.0/dct/skills/handoff/SKILL.md +0 -136
  39. dctracker-1.0.0/dct/skills/pickup/SKILL.md +0 -115
  40. dctracker-1.0.0/tests/test_hook_task_mirror.py +0 -101
  41. dctracker-1.0.0/tests/test_server_alwaysload.py +0 -47
  42. {dctracker-1.0.0 → dctracker-1.1.0}/LICENSE +0 -0
  43. {dctracker-1.0.0 → dctracker-1.1.0}/MANIFEST.in +0 -0
  44. {dctracker-1.0.0 → dctracker-1.1.0}/dct/core/__init__.py +0 -0
  45. {dctracker-1.0.0 → dctracker-1.1.0}/dct/core/analytics.py +0 -0
  46. {dctracker-1.0.0 → dctracker-1.1.0}/dct/core/changelog.py +0 -0
  47. {dctracker-1.0.0 → dctracker-1.1.0}/dct/core/checkpoints.py +0 -0
  48. {dctracker-1.0.0 → dctracker-1.1.0}/dct/core/decisions.py +0 -0
  49. {dctracker-1.0.0 → dctracker-1.1.0}/dct/core/handoff.py +0 -0
  50. {dctracker-1.0.0 → dctracker-1.1.0}/dct/core/ids.py +0 -0
  51. {dctracker-1.0.0 → dctracker-1.1.0}/dct/core/items.py +0 -0
  52. {dctracker-1.0.0 → dctracker-1.1.0}/dct/core/plans/__init__.py +0 -0
  53. {dctracker-1.0.0 → dctracker-1.1.0}/dct/core/plans/classify.py +0 -0
  54. {dctracker-1.0.0 → dctracker-1.1.0}/dct/core/plans/crud.py +0 -0
  55. {dctracker-1.0.0 → dctracker-1.1.0}/dct/core/plans/ingest.py +0 -0
  56. {dctracker-1.0.0 → dctracker-1.1.0}/dct/core/plans/parser.py +0 -0
  57. {dctracker-1.0.0 → dctracker-1.1.0}/dct/core/projects.py +0 -0
  58. {dctracker-1.0.0 → dctracker-1.1.0}/dct/core/sprint.py +0 -0
  59. {dctracker-1.0.0 → dctracker-1.1.0}/dct/core/sprints.py +0 -0
  60. {dctracker-1.0.0 → dctracker-1.1.0}/dct/core/version.py +0 -0
  61. {dctracker-1.0.0 → dctracker-1.1.0}/dct/export.py +0 -0
  62. {dctracker-1.0.0 → dctracker-1.1.0}/dct/hooks/check-changelog.sh +0 -0
  63. {dctracker-1.0.0 → dctracker-1.1.0}/dct/hooks/dct-plan-autoscan.sh +0 -0
  64. {dctracker-1.0.0 → dctracker-1.1.0}/dct/web/__init__.py +0 -0
  65. {dctracker-1.0.0 → dctracker-1.1.0}/dct/web/app.py +0 -0
  66. {dctracker-1.0.0 → dctracker-1.1.0}/dct/web/blueprints/__init__.py +0 -0
  67. {dctracker-1.0.0 → dctracker-1.1.0}/dct/web/blueprints/analytics.py +0 -0
  68. {dctracker-1.0.0 → dctracker-1.1.0}/dct/web/blueprints/changelog.py +0 -0
  69. {dctracker-1.0.0 → dctracker-1.1.0}/dct/web/blueprints/decisions.py +0 -0
  70. {dctracker-1.0.0 → dctracker-1.1.0}/dct/web/blueprints/events.py +0 -0
  71. {dctracker-1.0.0 → dctracker-1.1.0}/dct/web/blueprints/handoffs.py +0 -0
  72. {dctracker-1.0.0 → dctracker-1.1.0}/dct/web/blueprints/home.py +0 -0
  73. {dctracker-1.0.0 → dctracker-1.1.0}/dct/web/blueprints/items.py +0 -0
  74. {dctracker-1.0.0 → dctracker-1.1.0}/dct/web/blueprints/plans.py +0 -0
  75. {dctracker-1.0.0 → dctracker-1.1.0}/dct/web/blueprints/projects.py +0 -0
  76. {dctracker-1.0.0 → dctracker-1.1.0}/dct/web/blueprints/sprints.py +0 -0
  77. {dctracker-1.0.0 → dctracker-1.1.0}/dct/web/changes.py +0 -0
  78. {dctracker-1.0.0 → dctracker-1.1.0}/dct/web/serializers.py +0 -0
  79. {dctracker-1.0.0 → dctracker-1.1.0}/dct/web/static/app.css +0 -0
  80. {dctracker-1.0.0 → dctracker-1.1.0}/dct/web/static/app.js +0 -0
  81. {dctracker-1.0.0 → dctracker-1.1.0}/dct/web/static/vendor/SOURCES.txt +0 -0
  82. {dctracker-1.0.0 → dctracker-1.1.0}/dct/web/static/vendor/htmx.min.js +0 -0
  83. {dctracker-1.0.0 → dctracker-1.1.0}/dct/web/static/vendor/uPlot.iife.min.js +0 -0
  84. {dctracker-1.0.0 → dctracker-1.1.0}/dct/web/static/vendor/uPlot.min.css +0 -0
  85. {dctracker-1.0.0 → dctracker-1.1.0}/dct/web/templates/analytics.html +0 -0
  86. {dctracker-1.0.0 → dctracker-1.1.0}/dct/web/templates/base.html +0 -0
  87. {dctracker-1.0.0 → dctracker-1.1.0}/dct/web/templates/changelog/list.html +0 -0
  88. {dctracker-1.0.0 → dctracker-1.1.0}/dct/web/templates/decisions/list.html +0 -0
  89. {dctracker-1.0.0 → dctracker-1.1.0}/dct/web/templates/handoffs/list.html +0 -0
  90. {dctracker-1.0.0 → dctracker-1.1.0}/dct/web/templates/home.html +0 -0
  91. {dctracker-1.0.0 → dctracker-1.1.0}/dct/web/templates/item_detail.html +0 -0
  92. {dctracker-1.0.0 → dctracker-1.1.0}/dct/web/templates/items_list.html +0 -0
  93. {dctracker-1.0.0 → dctracker-1.1.0}/dct/web/templates/partials/_bars.html +0 -0
  94. {dctracker-1.0.0 → dctracker-1.1.0}/dct/web/templates/partials/_checkpoint_steps.html +0 -0
  95. {dctracker-1.0.0 → dctracker-1.1.0}/dct/web/templates/partials/_items_table.html +0 -0
  96. {dctracker-1.0.0 → dctracker-1.1.0}/dct/web/templates/partials/_plan_section.html +0 -0
  97. {dctracker-1.0.0 → dctracker-1.1.0}/dct/web/templates/partials/_project_card.html +0 -0
  98. {dctracker-1.0.0 → dctracker-1.1.0}/dct/web/templates/plans/detail.html +0 -0
  99. {dctracker-1.0.0 → dctracker-1.1.0}/dct/web/templates/plans/list.html +0 -0
  100. {dctracker-1.0.0 → dctracker-1.1.0}/dct/web/templates/project_home.html +0 -0
  101. {dctracker-1.0.0 → dctracker-1.1.0}/dct/web/templates/sprints/detail.html +0 -0
  102. {dctracker-1.0.0 → dctracker-1.1.0}/dct/web/templates/sprints/list.html +0 -0
  103. {dctracker-1.0.0 → dctracker-1.1.0}/dctracker.egg-info/dependency_links.txt +0 -0
  104. {dctracker-1.0.0 → dctracker-1.1.0}/dctracker.egg-info/entry_points.txt +0 -0
  105. {dctracker-1.0.0 → dctracker-1.1.0}/dctracker.egg-info/requires.txt +0 -0
  106. {dctracker-1.0.0 → dctracker-1.1.0}/dctracker.egg-info/top_level.txt +0 -0
  107. {dctracker-1.0.0 → dctracker-1.1.0}/setup.cfg +0 -0
  108. {dctracker-1.0.0 → dctracker-1.1.0}/tests/test_adversarial.py +0 -0
  109. {dctracker-1.0.0 → dctracker-1.1.0}/tests/test_analytics.py +0 -0
  110. {dctracker-1.0.0 → dctracker-1.1.0}/tests/test_changelog.py +0 -0
  111. {dctracker-1.0.0 → dctracker-1.1.0}/tests/test_changes.py +0 -0
  112. {dctracker-1.0.0 → dctracker-1.1.0}/tests/test_checkpoints.py +0 -0
  113. {dctracker-1.0.0 → dctracker-1.1.0}/tests/test_cli_prompts.py +0 -0
  114. {dctracker-1.0.0 → dctracker-1.1.0}/tests/test_cli_sprints.py +0 -0
  115. {dctracker-1.0.0 → dctracker-1.1.0}/tests/test_cli_web.py +0 -0
  116. {dctracker-1.0.0 → dctracker-1.1.0}/tests/test_config.py +0 -0
  117. {dctracker-1.0.0 → dctracker-1.1.0}/tests/test_decisions_crud.py +0 -0
  118. {dctracker-1.0.0 → dctracker-1.1.0}/tests/test_export.py +0 -0
  119. {dctracker-1.0.0 → dctracker-1.1.0}/tests/test_handoff.py +0 -0
  120. {dctracker-1.0.0 → dctracker-1.1.0}/tests/test_hook_bypass.py +0 -0
  121. {dctracker-1.0.0 → dctracker-1.1.0}/tests/test_hook_plan_autoscan.py +0 -0
  122. {dctracker-1.0.0 → dctracker-1.1.0}/tests/test_ids.py +0 -0
  123. {dctracker-1.0.0 → dctracker-1.1.0}/tests/test_ingest_dogfood.py +0 -0
  124. {dctracker-1.0.0 → dctracker-1.1.0}/tests/test_items.py +0 -0
  125. {dctracker-1.0.0 → dctracker-1.1.0}/tests/test_migration.py +0 -0
  126. {dctracker-1.0.0 → dctracker-1.1.0}/tests/test_packaging.py +0 -0
  127. {dctracker-1.0.0 → dctracker-1.1.0}/tests/test_plan_classify.py +0 -0
  128. {dctracker-1.0.0 → dctracker-1.1.0}/tests/test_plan_ingest.py +0 -0
  129. {dctracker-1.0.0 → dctracker-1.1.0}/tests/test_plan_parser.py +0 -0
  130. {dctracker-1.0.0 → dctracker-1.1.0}/tests/test_plans_crud.py +0 -0
  131. {dctracker-1.0.0 → dctracker-1.1.0}/tests/test_projects.py +0 -0
  132. {dctracker-1.0.0 → dctracker-1.1.0}/tests/test_setup.py +0 -0
  133. {dctracker-1.0.0 → dctracker-1.1.0}/tests/test_setup_wiring.py +0 -0
  134. {dctracker-1.0.0 → dctracker-1.1.0}/tests/test_sprint.py +0 -0
  135. {dctracker-1.0.0 → dctracker-1.1.0}/tests/test_sprints_crud.py +0 -0
  136. {dctracker-1.0.0 → dctracker-1.1.0}/tests/test_version.py +0 -0
  137. {dctracker-1.0.0 → dctracker-1.1.0}/tests/test_web_analytics.py +0 -0
  138. {dctracker-1.0.0 → dctracker-1.1.0}/tests/test_web_app.py +0 -0
  139. {dctracker-1.0.0 → dctracker-1.1.0}/tests/test_web_items.py +0 -0
  140. {dctracker-1.0.0 → dctracker-1.1.0}/tests/test_web_structural.py +0 -0
  141. {dctracker-1.0.0 → dctracker-1.1.0}/tests/test_web_views.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dctracker
3
- Version: 1.0.0
3
+ Version: 1.1.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
@@ -47,7 +47,7 @@ Dynamic: license-file
47
47
  [![CI](https://github.com/fotodeveloper/dct/actions/workflows/ci.yml/badge.svg)](https://github.com/fotodeveloper/dct/actions/workflows/ci.yml)
48
48
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
49
49
 
50
- 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, 48 MCP tools); the MCP server and CLI work with any MCP-capable assistant.
50
+ 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
51
 
52
52
  Part of the **dc tools** family ([dctools.dev](https://dctools.dev)) — *deterministic context* for AI-assisted development.
53
53
 
@@ -121,7 +121,7 @@ markdown, and makes the release commit.
121
121
  - **Decision records** — ADRs, sprint amendments, inline plan decisions with multi-anchor linkage
122
122
  - **LLM-assisted classification** — uncertain plan sections auto-resolved via `claude --print` (haiku by default)
123
123
  - **Passive autoscan hook** — PostToolUse hook re-scans plan-like files in background on every edit
124
- - **MCP server** — native Claude Code integration via FastMCP, 48 tools
124
+ - **MCP server** — native Claude Code integration via FastMCP, 49 tools
125
125
  - **CLI** — fast `dct` command for hooks and scripts
126
126
  - **Skills** — Claude Code slash commands (`/track`, `/clog`, `/dct-import`, `/plan-status`, …)
127
127
  - **Soft deletes** — ledger pattern, nothing is ever lost
@@ -200,6 +200,16 @@ happens in the conversation you're already having.
200
200
  - **MCP tools don't show up in Claude Code** — the MCP server is a per-session subprocess, so newly registered tools (or a reinstall) appear only after you **restart the Claude Code session**. Start a fresh session and they'll be there.
201
201
  - **Plans/decisions tools fail with `No module named 'ulid'`** — your installed tool environment predates the `python-ulid` dependency. Upgrade it: `uv tool upgrade dctracker` (or reinstall via your chosen option above).
202
202
 
203
+ ### Updating
204
+
205
+ dct runs a passive, offline-safe update check — a cached PyPI probe (refreshed at most once a day, never blocking) surfaces a one-line "newer release available" nudge on the CLI and in the MCP server instructions. Opt out with `[update] check = false` in `~/.claude/dct.toml`. When a newer version exists:
206
+
207
+ ```bash
208
+ dct upgrade # detects install method (uv tool / pipx / pip), upgrades, re-links skills + hooks
209
+ ```
210
+
211
+ `dct upgrade` refreshes the skill/hook symlinks and prunes any that the new package dropped. Editable/dev checkouts print `git pull` guidance instead (no package to upgrade). Run it at a session boundary — skill descriptions and the MCP server pick up the new code on the next session, migrations on the next engine bootstrap.
212
+
203
213
  ## Usage
204
214
 
205
215
  ### CLI
@@ -243,22 +253,22 @@ dct checkpoint reject 4 # Scope-change, keep in ledger
243
253
 
244
254
  ## MCP Tools
245
255
 
246
- dct ships **48 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. The daily drivers:
256
+ 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:
247
257
 
248
258
  | Tool | Purpose |
249
259
  |---|---|
250
260
  | `create_item` | Create a tracked item (issue / todo / feature / idea / improvement) |
261
+ | `get_item` | Full item dict — fields, notes, checkpoints, progress |
251
262
  | `list_items` | List / search items in compact `{cols, rows}` form |
252
263
  | `update_item` | Update fields on an existing item |
253
264
  | `resolve_item` | Close an item (terminal status), with gate-checkpoint guard |
265
+ | `add_note` | Append a note to an item (append-only ledger) |
254
266
  | `add_checkpoint` | Add an acceptance criterion to an item |
255
267
  | `complete_checkpoint` | Mark a checkpoint done (real-time, never batched) |
268
+ | `reject_checkpoint` | Mark a checkpoint rejected (scope change) — ledger-preserving |
256
269
  | `add_changelog` | Add an unreleased changelog entry |
257
- | `release_changelog` | Stamp unreleased entries with a version + bump `current_version` |
258
- | `set_sprint` | Atomically replace the current sprint's membership |
259
- | `create_handoff` | Leave a handoff draft for the next session |
260
270
 
261
- Full categorized catalogue of all 48 tools: **[docs/mcp-tools.md](docs/mcp-tools.md)**.
271
+ 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)**.
262
272
 
263
273
  ## Plans, Roadmaps & Decisions
264
274
 
@@ -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, 48 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, 49 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, 48 tools
82
+ - **MCP server** — native Claude Code integration via FastMCP, 49 tools
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
@@ -158,6 +158,16 @@ happens in the conversation you're already having.
158
158
  - **MCP tools don't show up in Claude Code** — the MCP server is a per-session subprocess, so newly registered tools (or a reinstall) appear only after you **restart the Claude Code session**. Start a fresh session and they'll be there.
159
159
  - **Plans/decisions tools fail with `No module named 'ulid'`** — your installed tool environment predates the `python-ulid` dependency. Upgrade it: `uv tool upgrade dctracker` (or reinstall via your chosen option above).
160
160
 
161
+ ### Updating
162
+
163
+ dct runs a passive, offline-safe update check — a cached PyPI probe (refreshed at most once a day, never blocking) surfaces a one-line "newer release available" nudge on the CLI and in the MCP server instructions. Opt out with `[update] check = false` in `~/.claude/dct.toml`. When a newer version exists:
164
+
165
+ ```bash
166
+ dct upgrade # detects install method (uv tool / pipx / pip), upgrades, re-links skills + hooks
167
+ ```
168
+
169
+ `dct upgrade` refreshes the skill/hook symlinks and prunes any that the new package dropped. Editable/dev checkouts print `git pull` guidance instead (no package to upgrade). Run it at a session boundary — skill descriptions and the MCP server pick up the new code on the next session, migrations on the next engine bootstrap.
170
+
161
171
  ## Usage
162
172
 
163
173
  ### CLI
@@ -201,22 +211,22 @@ dct checkpoint reject 4 # Scope-change, keep in ledger
201
211
 
202
212
  ## MCP Tools
203
213
 
204
- dct ships **48 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. The daily drivers:
214
+ 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:
205
215
 
206
216
  | Tool | Purpose |
207
217
  |---|---|
208
218
  | `create_item` | Create a tracked item (issue / todo / feature / idea / improvement) |
219
+ | `get_item` | Full item dict — fields, notes, checkpoints, progress |
209
220
  | `list_items` | List / search items in compact `{cols, rows}` form |
210
221
  | `update_item` | Update fields on an existing item |
211
222
  | `resolve_item` | Close an item (terminal status), with gate-checkpoint guard |
223
+ | `add_note` | Append a note to an item (append-only ledger) |
212
224
  | `add_checkpoint` | Add an acceptance criterion to an item |
213
225
  | `complete_checkpoint` | Mark a checkpoint done (real-time, never batched) |
226
+ | `reject_checkpoint` | Mark a checkpoint rejected (scope change) — ledger-preserving |
214
227
  | `add_changelog` | Add an unreleased changelog entry |
215
- | `release_changelog` | Stamp unreleased entries with a version + bump `current_version` |
216
- | `set_sprint` | Atomically replace the current sprint's membership |
217
- | `create_handoff` | Leave a handoff draft for the next session |
218
228
 
219
- Full categorized catalogue of all 48 tools: **[docs/mcp-tools.md](docs/mcp-tools.md)**.
229
+ 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)**.
220
230
 
221
231
  ## Plans, Roadmaps & Decisions
222
232
 
@@ -3,4 +3,4 @@ from importlib.metadata import version, PackageNotFoundError
3
3
  try:
4
4
  __version__ = version("dctracker")
5
5
  except PackageNotFoundError:
6
- __version__ = "1.0.0"
6
+ __version__ = "1.1.0"
@@ -1,6 +1,7 @@
1
1
  """CLI entry point for dct (Deterministic Context Tracker)."""
2
2
 
3
3
  import argparse
4
+ import atexit
4
5
  import os
5
6
  import signal
6
7
  import socket
@@ -749,21 +750,34 @@ def cmd_internal_task_mirror(args):
749
750
  appends each todo as a `kind='cc-task'` checkpoint. Non-blocking —
750
751
  safe to fail silently (hook is fire-and-forget).
751
752
 
753
+ Owns the config gate and project autodetect (the shell hook is a lean
754
+ dispatcher that only drains stdin and backgrounds this worker — #354):
755
+ - reads [mirror].task_to_dct; anything but 'auto' is a silent no-op;
756
+ - detects the project from CWD, silent no-op when the CWD is not a
757
+ registered dct project.
758
+
752
759
  Intentionally named with an underscore prefix — not user-facing, just
753
760
  the target of the task-mirror hook. Re-expose via the `dct` entry point
754
761
  so the venv-installed Python is used (hooks cannot reliably find it).
755
762
 
756
763
  Args:
757
- session-id: Claude Code session_id (from hook payload .session_id).
764
+ session-id: Claude Code session_id. Empty → read payload .session_id.
758
765
  project: dct project slug. Empty → autodetect from CWD.
759
766
  """
760
767
  import json
768
+ from dct.config import load_config
761
769
 
762
770
  try:
763
771
  payload = json.load(sys.stdin)
764
772
  except json.JSONDecodeError:
765
773
  return # fire-and-forget: swallow, exit clean
766
774
 
775
+ # Config gate (moved off the shell fast-path). auto → mirror; ask/off/
776
+ # anything else → silent no-op.
777
+ mode = getattr(load_config().mirror, "task_to_dct", "auto") or "auto"
778
+ if mode != "auto":
779
+ return
780
+
767
781
  tool_input = payload.get("tool_input") or {}
768
782
  todos: list[str] = []
769
783
  if isinstance(tool_input.get("todos"), list):
@@ -788,11 +802,22 @@ def cmd_internal_task_mirror(args):
788
802
 
789
803
  from dct.core.items import list_items, create_item
790
804
  from dct.core.checkpoints import add_checkpoint
805
+ from dct.core.projects import get_project_by_path
791
806
 
792
807
  engine = _get_engine()
793
808
 
794
- project_slug = args.project or _detect_project(engine)
795
- session_id = args.session_id or "unknown"
809
+ # Silent project autodetect — a fire-and-forget worker must never raise
810
+ # or exit non-zero when the CWD is not a registered project.
811
+ project_slug = args.project
812
+ if not project_slug:
813
+ import os
814
+
815
+ detected = get_project_by_path(engine, os.getcwd())
816
+ if not detected:
817
+ return
818
+ project_slug = detected["slug"]
819
+
820
+ session_id = args.session_id or payload.get("session_id") or "unknown"
796
821
  title = f"CC task list — session {session_id}"
797
822
 
798
823
  existing = [
@@ -1039,6 +1064,65 @@ def cmd_server(args):
1039
1064
  mcp.run()
1040
1065
 
1041
1066
 
1067
+ # ── Upgrade ──────────────────────────────────────────────────────────────────
1068
+
1069
+ def cmd_upgrade(args):
1070
+ """Upgrade the installed dct package and refresh its Claude Code symlinks.
1071
+
1072
+ Detects the install method (editable / uv tool / pipx / pip), runs the
1073
+ matching upgrade, re-links skills + hooks (idempotent) and prunes dangling
1074
+ links for skills/hooks dropped from the new package, then prints the effect
1075
+ matrix so the user knows what takes effect when.
1076
+ """
1077
+ import subprocess
1078
+ from dct.update import detect_install_method, upgrade_command, dist_pkg
1079
+
1080
+ method = detect_install_method()
1081
+
1082
+ if method == "editable":
1083
+ print("dct is installed as an editable / dev checkout — no package upgrade to run.")
1084
+ print("Pull the repo to update:")
1085
+ print(" git -C <dct-repo> pull")
1086
+ print(" uv pip install -e '<dct-repo>[mcp]' # only if dependencies changed")
1087
+ else:
1088
+ cmd = upgrade_command(method)
1089
+ print(f"Detected install method: {method}")
1090
+ print(f"Upgrading {dist_pkg()} … ({' '.join(cmd)})")
1091
+ result = subprocess.run(cmd)
1092
+ if result.returncode != 0:
1093
+ print(f"✗ Upgrade command failed (exit {result.returncode})", file=sys.stderr)
1094
+ sys.exit(result.returncode)
1095
+
1096
+ # Refresh Claude Code integration: re-link current skills/hooks (idempotent),
1097
+ # then remove links to skills/hooks that the new package no longer ships.
1098
+ from dct.setup import install_hook, install_skills, prune_dangling_symlinks
1099
+
1100
+ linked_ok = True
1101
+ removed: list[str] = []
1102
+ try:
1103
+ install_skills()
1104
+ install_hook()
1105
+ removed = prune_dangling_symlinks()
1106
+ except Exception as e: # never let link maintenance abort the command
1107
+ linked_ok = False
1108
+ print(f" ⚠ Skills/hooks re-link failed: {e}", file=sys.stderr)
1109
+ print(" Run `dct init` to repair the Claude Code integration.", file=sys.stderr)
1110
+
1111
+ if linked_ok:
1112
+ print(" ✓ Skills and hooks re-linked")
1113
+ if removed:
1114
+ print(f" ✓ Removed {len(removed)} dangling link(s):")
1115
+ for path in removed:
1116
+ print(f" - {path}")
1117
+
1118
+ print("\nWhat takes effect when:")
1119
+ print(" Now (this shell): dct CLI, hooks, skill bodies")
1120
+ print(" Next session / /mcp reconnect: skill descriptions, MCP server code")
1121
+ print(" Next engine bootstrap: database schema migrations")
1122
+ print("\nRecommend running `dct upgrade` at a session boundary — an old running")
1123
+ print("MCP server against a freshly migrated schema is an edge best avoided.")
1124
+
1125
+
1042
1126
  # ── Project detect (for hooks) ───────────────────────────────────────────────
1043
1127
 
1044
1128
  def cmd_project_detect(args):
@@ -1254,6 +1338,13 @@ def build_parser() -> argparse.ArgumentParser:
1254
1338
  # server (MCP stdio entry point)
1255
1339
  sub.add_parser("server", help="Start the MCP server (stdio transport)")
1256
1340
 
1341
+ # upgrade (self-update + symlink refresh — #356)
1342
+ sub.add_parser(
1343
+ "upgrade",
1344
+ help="Upgrade dct (auto-detects uv tool / pipx / pip; guides editable installs) "
1345
+ "and refresh skill/hook symlinks",
1346
+ )
1347
+
1257
1348
  # project
1258
1349
  p_project = sub.add_parser("project", help="Manage projects")
1259
1350
  p_proj_sub = p_project.add_subparsers(dest="project_command")
@@ -1512,6 +1603,7 @@ def build_parser() -> argparse.ArgumentParser:
1512
1603
  COMMAND_MAP = {
1513
1604
  "init": cmd_init,
1514
1605
  "server": cmd_server,
1606
+ "upgrade": cmd_upgrade,
1515
1607
  "add": cmd_add,
1516
1608
  "list": cmd_list,
1517
1609
  "show": cmd_show,
@@ -1585,6 +1677,83 @@ SPRINT_COMMAND_MAP = {
1585
1677
  }
1586
1678
 
1587
1679
 
1680
+ # ── Passive update nudge (#356) ──────────────────────────────────────────────
1681
+
1682
+ # (command, subcommand) pairs that hooks / tooling call non-interactively — a
1683
+ # nudge on stderr would corrupt their machine-read output, so never surface it.
1684
+ _NUDGE_HOOK_SUBCOMMANDS = frozenset({
1685
+ ("project", "detect"),
1686
+ ("project", "manages-changelog"),
1687
+ ("clog", "count"),
1688
+ ("config", "get"),
1689
+ })
1690
+
1691
+
1692
+ def _nudge_suppressed(args) -> bool:
1693
+ """True when the passive update nudge must NOT be printed for this command.
1694
+
1695
+ Suppressed for: any underscore-prefixed command (internal, e.g. _task-mirror),
1696
+ any underscore-prefixed subcommand, and the hook-driven machine-output pairs
1697
+ in ``_NUDGE_HOOK_SUBCOMMANDS`` (project detect / manages-changelog, clog
1698
+ count, config get). The caller additionally gates on ``stderr.isatty()``.
1699
+ """
1700
+ cmd = getattr(args, "command", None) or ""
1701
+ if not cmd or cmd.startswith("_"):
1702
+ return True
1703
+ sub = (
1704
+ getattr(args, "project_command", None)
1705
+ or getattr(args, "clog_command", None)
1706
+ or getattr(args, "config_command", None)
1707
+ )
1708
+ if isinstance(sub, str) and sub.startswith("_"):
1709
+ return True
1710
+ return (cmd, sub) in _NUDGE_HOOK_SUBCOMMANDS
1711
+
1712
+
1713
+ NUDGE_JOIN_TIMEOUT = 1.5 # bounded wait for the background PyPI probe at exit
1714
+
1715
+
1716
+ def _register_probe_join(thread) -> None:
1717
+ """Join the background update probe (bounded) at interpreter exit.
1718
+
1719
+ The probe runs in a daemon thread, so a short-lived CLI command would
1720
+ otherwise exit before it finishes and the cache would never populate. Joining
1721
+ at ``atexit`` — AFTER the command's own work is done — lets quick commands
1722
+ still warm the cache without the probe blocking the command. Offline worst
1723
+ case is bounded by the probe's ``NETWORK_TIMEOUT`` (2s) racing this 1.5s join;
1724
+ the daemon flag guarantees the process still exits either way. No-op when the
1725
+ probe was not started this invocation (``thread is None``).
1726
+ """
1727
+ if thread is None:
1728
+ return
1729
+ atexit.register(thread.join, NUDGE_JOIN_TIMEOUT)
1730
+
1731
+
1732
+ def _maybe_nudge(args) -> None:
1733
+ """Print the cached update notice to stderr for interactive human commands.
1734
+
1735
+ Also kicks off the non-blocking background cache refresh and registers a
1736
+ bounded ``atexit`` join so a short command still populates the cache before
1737
+ the interpreter exits. Entirely best-effort: any failure (or a non-tty,
1738
+ suppressed, or opted-out command) is silent and never affects the command's
1739
+ own output or exit code.
1740
+ """
1741
+ try:
1742
+ if not sys.stderr.isatty() or _nudge_suppressed(args):
1743
+ return
1744
+ from dct.config import load_config
1745
+ from dct.update import check_for_update, get_cached_notice
1746
+
1747
+ cfg = load_config() # single load; both halves honor [update] check
1748
+ thread = check_for_update(cfg) # background refresh; returns immediately
1749
+ _register_probe_join(thread) # bounded join at exit, after command work
1750
+ notice = get_cached_notice(config=cfg)
1751
+ if notice:
1752
+ print(notice, file=sys.stderr)
1753
+ except Exception:
1754
+ pass # a passive nicety must never break a command
1755
+
1756
+
1588
1757
  def main():
1589
1758
  parser = build_parser()
1590
1759
  args = parser.parse_args()
@@ -1593,6 +1762,8 @@ def main():
1593
1762
  parser.print_help()
1594
1763
  sys.exit(0)
1595
1764
 
1765
+ _maybe_nudge(args)
1766
+
1596
1767
  if args.command == "project":
1597
1768
  handler = PROJECT_COMMAND_MAP.get(args.project_command)
1598
1769
  if not handler:
@@ -55,6 +55,16 @@ class WebConfig:
55
55
  refresh_poll_ms: int = 2000
56
56
 
57
57
 
58
+ @dataclass
59
+ class UpdateConfig:
60
+ """[update] section — #356 passive PyPI update check.
61
+
62
+ check when False, disables BOTH the background version probe and the CLI
63
+ nudge. Offline-safe and non-blocking regardless; this is the opt-out.
64
+ """
65
+ check: bool = True
66
+
67
+
58
68
  @dataclass
59
69
  class Config:
60
70
  database_url: str = ""
@@ -62,6 +72,7 @@ class Config:
62
72
  plans: PlansConfig = field(default_factory=PlansConfig)
63
73
  mirror: MirrorConfig = field(default_factory=MirrorConfig)
64
74
  web: WebConfig = field(default_factory=WebConfig)
75
+ update: UpdateConfig = field(default_factory=UpdateConfig)
65
76
 
66
77
  def __post_init__(self):
67
78
  if not self.database_url:
@@ -107,12 +118,18 @@ def load_config(config_path: Path | None = None) -> Config:
107
118
  refresh_poll_ms=int(web_section.get("refresh_poll_ms", 2000)),
108
119
  )
109
120
 
121
+ update_section = data.get("update", {})
122
+ update = UpdateConfig(check=bool(update_section.get("check", True)))
123
+
110
124
  # Env var overrides config file
111
125
  env_url = os.environ.get("DCT_DATABASE_URL")
112
126
  if env_url:
113
127
  database_url = env_url
114
128
 
115
- return Config(database_url=database_url, dct_root=dct_root, plans=plans, mirror=mirror, web=web)
129
+ return Config(
130
+ database_url=database_url, dct_root=dct_root,
131
+ plans=plans, mirror=mirror, web=web, update=update,
132
+ )
116
133
 
117
134
 
118
135
  def save_config(config: Config, config_path: Path | None = None) -> Path:
@@ -146,5 +163,8 @@ def save_config(config: Config, config_path: Path | None = None) -> Path:
146
163
  lines.append(f'open_browser = {str(config.web.open_browser).lower()}\n')
147
164
  lines.append(f'refresh_poll_ms = {config.web.refresh_poll_ms}\n')
148
165
 
166
+ lines.append('\n[update]\n')
167
+ lines.append(f'check = {str(config.update.check).lower()}\n')
168
+
149
169
  path.write_text("".join(lines))
150
170
  return path
@@ -11,16 +11,19 @@ the schema (no create_tables, only SELECTs).
11
11
  import sqlalchemy as sa
12
12
 
13
13
  from dct.config import load_config
14
- from dct.db import get_engine, create_tables
14
+ from dct.db import ensure_sqlite_dir, get_engine, create_tables
15
15
 
16
16
 
17
17
  def bootstrap_engine(create: bool = True) -> sa.Engine:
18
18
  """Build a SQLAlchemy Engine from dct config.
19
19
 
20
20
  Args:
21
- create: when True (default) run idempotent DDL via create_tables so the
22
- schema exists. When False, skip ALL DDL — caller guarantees the DB
23
- already exists (read-only consumers such as the web viewer).
21
+ create: when True (default) provision the parent directory for
22
+ file-backed sqlite URLs (first run on a pristine machine, #360)
23
+ and run idempotent DDL via create_tables so the schema exists.
24
+ When False, skip ALL provisioning — no DDL, no directory creation;
25
+ caller guarantees the DB already exists (read-only consumers such
26
+ as the web viewer).
24
27
 
25
28
  Returns:
26
29
  A configured sqlalchemy.Engine.
@@ -28,5 +31,6 @@ def bootstrap_engine(create: bool = True) -> sa.Engine:
28
31
  cfg = load_config()
29
32
  engine = get_engine(cfg.database_url)
30
33
  if create:
34
+ ensure_sqlite_dir(cfg.database_url)
31
35
  create_tables(engine) # idempotent
32
36
  return engine
@@ -4,6 +4,8 @@ Defines all tables, engine creation, and connection helpers.
4
4
  Uses Core (not ORM) for portability between PostgreSQL and SQLite.
5
5
  """
6
6
 
7
+ from pathlib import Path
8
+
7
9
  import sqlalchemy as sa
8
10
  from sqlalchemy import event
9
11
 
@@ -323,6 +325,22 @@ def _set_sqlite_wal(dbapi_connection, _connection_record):
323
325
  cursor.close()
324
326
 
325
327
 
328
+ def ensure_sqlite_dir(database_url: str) -> None:
329
+ """Create the parent directory of a file-backed sqlite URL if missing.
330
+
331
+ First run on a pristine machine: the default sqlite path lives under a
332
+ directory that may not exist yet, and sqlite fails to open the database
333
+ file with a raw OperationalError instead of creating it (#360). No-op for
334
+ non-sqlite dialects and for memory URLs ('sqlite://', 'sqlite:///:memory:').
335
+ """
336
+ url = sa.engine.make_url(database_url)
337
+ if url.get_backend_name() != "sqlite":
338
+ return
339
+ if not url.database or url.database == ":memory:":
340
+ return
341
+ Path(url.database).parent.mkdir(parents=True, exist_ok=True)
342
+
343
+
326
344
  def get_engine(database_url: str) -> sa.Engine:
327
345
  kwargs = {}
328
346
  if database_url.startswith("sqlite"):
@@ -0,0 +1,112 @@
1
+ #!/bin/bash
2
+ # dct global Stop hook — non-blocking reminder after every turn.
3
+ # Checks if app files changed without unreleased changelog entries.
4
+ # Install: ~/.claude/hooks/dct-check-changelog-on-stop.sh
5
+ #
6
+ # Latency design (#354): the cheap git change-detection runs FIRST, so the
7
+ # common "nothing changed" and "only exempt files changed" turns exit with
8
+ # ZERO Python cold starts. Only when un-exempt app files changed without a
9
+ # changelog touch do we need the project verdict — and that verdict
10
+ # (project detect + manages-changelog) is cached per project-root path for
11
+ # an hour, so at most one detection per project per hour instead of two
12
+ # Python starts every turn.
13
+
14
+ # Rule 2: drain stdin (Claude Code supplies Stop-hook JSON there).
15
+ input=$(cat 2>/dev/null || true)
16
+
17
+ # Rule 3: graceful on missing deps. git gates the whole hook and is checked
18
+ # first because change-detection is the cheapest possible short-circuit.
19
+ command -v git >/dev/null || exit 0
20
+ command -v dct >/dev/null || exit 0
21
+
22
+ # ── Fast path 1: no changed files → nothing to remind about (0 Python). ──
23
+ changed=$(git diff --name-only 2>/dev/null; git diff --cached --name-only 2>/dev/null; git ls-files --others --exclude-standard 2>/dev/null)
24
+ [ -z "$changed" ] && exit 0
25
+ changed=$(echo "$changed" | sort -u)
26
+
27
+ # Exempt patterns — docs/tests/config/meta files never require a changelog.
28
+ exempt_re="^\.(claude|github|cursor|vscode|gstack)/|^docs/|^scripts/|^tests/|^test_|conftest\.py$|CLAUDE\.md$|README\.md$|LICENSE|\.gitignore$|^changelog|^CHANGELOG|^VERSION$|pyproject\.toml$|Cargo\.toml$|package\.json$"
29
+
30
+ # ── Fast path 2: every change is exempt → nothing to remind about (0 Python). ──
31
+ app_files=$(echo "$changed" | grep -vE "$exempt_re")
32
+ [ -z "$app_files" ] && exit 0
33
+
34
+ # ── Fast path 3: changelog itself was touched → already satisfied (0 Python). ──
35
+ changelog_changed=$(echo "$changed" | grep -iE "^changelog|^CHANGELOG" | head -1)
36
+ [ -n "$changelog_changed" ] && exit 0
37
+
38
+ # Un-exempt app files changed and no changelog touched. Only now do we need
39
+ # to know whether this project opts into dct changelog enforcement (#222).
40
+ # Cache that verdict per project-root path to avoid two Python cold starts
41
+ # on every turn — key = checksum of the git top-level path, TTL 3600s.
42
+ root=$(git rev-parse --show-toplevel 2>/dev/null || echo "$PWD")
43
+ key=$(printf '%s' "$root" | cksum | cut -d' ' -f1)
44
+ cache_dir="${TMPDIR:-/tmp}"
45
+ cache_file="${cache_dir%/}/dct-changelog-verdict-${key}"
46
+ ttl_min=60 # 3600s
47
+
48
+ # Security: the cache lives under a shared TMPDIR at a predictable path. A
49
+ # pre-planted symlink there would make a through-write (`>`) follow the link and
50
+ # truncate its target — any user-writable file. Remove a symlink before reading
51
+ # (treat as a miss) and write via mktemp-in-same-dir + `mv -f`: rename REPLACES
52
+ # the link itself, it never follows it. This also removes partial-read races.
53
+ [ -L "$cache_file" ] && rm -f "$cache_file"
54
+
55
+ # Atomic, symlink-safe verdict write (recompute path only — keeps the fast paths
56
+ # free of any mktemp/rename cost). $1 = verdict line.
57
+ write_verdict() {
58
+ local tmp
59
+ tmp=$(mktemp "${cache_file}.XXXXXX" 2>/dev/null) || return 0
60
+ printf '%s\n' "$1" >"$tmp" 2>/dev/null || { rm -f "$tmp"; return 0; }
61
+ mv -f "$tmp" "$cache_file" 2>/dev/null || rm -f "$tmp"
62
+ }
63
+
64
+ cached=""
65
+ if [ -f "$cache_file" ] && [ -n "$(find "$cache_file" -mmin -"$ttl_min" 2>/dev/null)" ]; then
66
+ # Strip NUL bytes BEFORE the command substitution captures the output: a
67
+ # corrupted cache holding NULs would otherwise make bash >=4.4 emit a
68
+ # "command substitution: ignored null byte in input" warning on the Stop
69
+ # hook's stderr (head's 2>/dev/null does not suppress that shell-level note).
70
+ cached=$(tr -d '\0' <"$cache_file" 2>/dev/null | head -n1 || true)
71
+ fi
72
+
73
+ case "$cached" in
74
+ skip:*) exit 0 ;; # cached: not a dct-managed project
75
+ manages:?*)
76
+ project="${cached#manages:}"
77
+ # Corrupted slug (invalid chars) → treat as a miss and recompute.
78
+ case "$project" in
79
+ *[!a-zA-Z0-9._-]*) cached="" ;;
80
+ esac
81
+ ;;
82
+ *) cached="" ;; # miss / empty / corrupted → recompute
83
+ esac
84
+
85
+ if [ -z "$cached" ]; then
86
+ # Cache miss — run the real detection (≤ two Python starts, ≤ once/hour/project).
87
+ project=$(dct project detect 2>/dev/null || true)
88
+ if [ -z "$project" ]; then
89
+ write_verdict 'skip:'
90
+ exit 0
91
+ fi
92
+ if dct project manages-changelog "$project" 2>/dev/null; then
93
+ write_verdict "manages:${project}"
94
+ else
95
+ write_verdict 'skip:'
96
+ exit 0
97
+ fi
98
+ fi
99
+
100
+ # Project manages its changelog in dct. Check dct DB for unreleased entries.
101
+ count=$(dct clog count --project "$project" 2>/dev/null)
102
+ if [ "${count:-0}" -gt 0 ]; then
103
+ exit 0
104
+ fi
105
+
106
+ # Reminder (non-blocking — always exit 0)
107
+ file_count=$(echo "$app_files" | wc -l | tr -d ' ')
108
+ echo ""
109
+ echo "📝 Reminder: ${file_count} app file(s) changed in ${project}/ but no changelog entry found."
110
+ echo " Use /clog to add an entry."
111
+ echo ""
112
+ exit 0