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.
- {dctracker-1.0.0/dctracker.egg-info → dctracker-1.1.0}/PKG-INFO +18 -8
- {dctracker-1.0.0 → dctracker-1.1.0}/README.md +17 -7
- {dctracker-1.0.0 → dctracker-1.1.0}/dct/__init__.py +1 -1
- {dctracker-1.0.0 → dctracker-1.1.0}/dct/cli.py +174 -3
- {dctracker-1.0.0 → dctracker-1.1.0}/dct/config.py +21 -1
- {dctracker-1.0.0 → dctracker-1.1.0}/dct/core/engine.py +8 -4
- {dctracker-1.0.0 → dctracker-1.1.0}/dct/db.py +18 -0
- dctracker-1.1.0/dct/hooks/check-changelog-on-stop.sh +112 -0
- {dctracker-1.0.0 → dctracker-1.1.0}/dct/hooks/dct-task-mirror.sh +15 -25
- {dctracker-1.0.0 → dctracker-1.1.0}/dct/server.py +372 -194
- {dctracker-1.0.0 → dctracker-1.1.0}/dct/setup.py +41 -2
- {dctracker-1.0.0 → dctracker-1.1.0}/dct/skills/clog/SKILL.md +4 -9
- dctracker-1.1.0/dct/skills/commit/SKILL.md +114 -0
- {dctracker-1.0.0 → dctracker-1.1.0}/dct/skills/dct-import/SKILL.md +2 -1
- {dctracker-1.0.0 → dctracker-1.1.0}/dct/skills/dct-init/SKILL.md +2 -1
- dctracker-1.1.0/dct/skills/handoff/SKILL.md +129 -0
- dctracker-1.1.0/dct/skills/pickup/SKILL.md +110 -0
- {dctracker-1.0.0 → dctracker-1.1.0}/dct/skills/plan-resolve-uncertain/SKILL.md +2 -1
- {dctracker-1.0.0 → dctracker-1.1.0}/dct/skills/plan-status/SKILL.md +5 -6
- {dctracker-1.0.0 → dctracker-1.1.0}/dct/skills/release/SKILL.md +73 -60
- {dctracker-1.0.0 → dctracker-1.1.0}/dct/skills/track/SKILL.md +4 -8
- {dctracker-1.0.0 → dctracker-1.1.0}/dct/skills/track-list/SKILL.md +4 -5
- {dctracker-1.0.0 → dctracker-1.1.0}/dct/skills/track-resolve/SKILL.md +3 -4
- {dctracker-1.0.0 → dctracker-1.1.0}/dct/skills/track-update/SKILL.md +4 -7
- dctracker-1.1.0/dct/update.py +318 -0
- {dctracker-1.0.0 → dctracker-1.1.0/dctracker.egg-info}/PKG-INFO +18 -8
- {dctracker-1.0.0 → dctracker-1.1.0}/dctracker.egg-info/SOURCES.txt +5 -0
- {dctracker-1.0.0 → dctracker-1.1.0}/pyproject.toml +1 -1
- {dctracker-1.0.0 → dctracker-1.1.0}/tests/test_engine.py +37 -0
- dctracker-1.1.0/tests/test_hook_changelog_stop.py +241 -0
- dctracker-1.1.0/tests/test_hook_task_mirror.py +211 -0
- dctracker-1.1.0/tests/test_server_acks.py +425 -0
- dctracker-1.1.0/tests/test_server_alwaysload.py +71 -0
- dctracker-1.1.0/tests/test_server_handoff_view.py +111 -0
- dctracker-1.1.0/tests/test_update.py +532 -0
- dctracker-1.0.0/dct/hooks/check-changelog-on-stop.sh +0 -49
- dctracker-1.0.0/dct/skills/commit/SKILL.md +0 -153
- dctracker-1.0.0/dct/skills/handoff/SKILL.md +0 -136
- dctracker-1.0.0/dct/skills/pickup/SKILL.md +0 -115
- dctracker-1.0.0/tests/test_hook_task_mirror.py +0 -101
- dctracker-1.0.0/tests/test_server_alwaysload.py +0 -47
- {dctracker-1.0.0 → dctracker-1.1.0}/LICENSE +0 -0
- {dctracker-1.0.0 → dctracker-1.1.0}/MANIFEST.in +0 -0
- {dctracker-1.0.0 → dctracker-1.1.0}/dct/core/__init__.py +0 -0
- {dctracker-1.0.0 → dctracker-1.1.0}/dct/core/analytics.py +0 -0
- {dctracker-1.0.0 → dctracker-1.1.0}/dct/core/changelog.py +0 -0
- {dctracker-1.0.0 → dctracker-1.1.0}/dct/core/checkpoints.py +0 -0
- {dctracker-1.0.0 → dctracker-1.1.0}/dct/core/decisions.py +0 -0
- {dctracker-1.0.0 → dctracker-1.1.0}/dct/core/handoff.py +0 -0
- {dctracker-1.0.0 → dctracker-1.1.0}/dct/core/ids.py +0 -0
- {dctracker-1.0.0 → dctracker-1.1.0}/dct/core/items.py +0 -0
- {dctracker-1.0.0 → dctracker-1.1.0}/dct/core/plans/__init__.py +0 -0
- {dctracker-1.0.0 → dctracker-1.1.0}/dct/core/plans/classify.py +0 -0
- {dctracker-1.0.0 → dctracker-1.1.0}/dct/core/plans/crud.py +0 -0
- {dctracker-1.0.0 → dctracker-1.1.0}/dct/core/plans/ingest.py +0 -0
- {dctracker-1.0.0 → dctracker-1.1.0}/dct/core/plans/parser.py +0 -0
- {dctracker-1.0.0 → dctracker-1.1.0}/dct/core/projects.py +0 -0
- {dctracker-1.0.0 → dctracker-1.1.0}/dct/core/sprint.py +0 -0
- {dctracker-1.0.0 → dctracker-1.1.0}/dct/core/sprints.py +0 -0
- {dctracker-1.0.0 → dctracker-1.1.0}/dct/core/version.py +0 -0
- {dctracker-1.0.0 → dctracker-1.1.0}/dct/export.py +0 -0
- {dctracker-1.0.0 → dctracker-1.1.0}/dct/hooks/check-changelog.sh +0 -0
- {dctracker-1.0.0 → dctracker-1.1.0}/dct/hooks/dct-plan-autoscan.sh +0 -0
- {dctracker-1.0.0 → dctracker-1.1.0}/dct/web/__init__.py +0 -0
- {dctracker-1.0.0 → dctracker-1.1.0}/dct/web/app.py +0 -0
- {dctracker-1.0.0 → dctracker-1.1.0}/dct/web/blueprints/__init__.py +0 -0
- {dctracker-1.0.0 → dctracker-1.1.0}/dct/web/blueprints/analytics.py +0 -0
- {dctracker-1.0.0 → dctracker-1.1.0}/dct/web/blueprints/changelog.py +0 -0
- {dctracker-1.0.0 → dctracker-1.1.0}/dct/web/blueprints/decisions.py +0 -0
- {dctracker-1.0.0 → dctracker-1.1.0}/dct/web/blueprints/events.py +0 -0
- {dctracker-1.0.0 → dctracker-1.1.0}/dct/web/blueprints/handoffs.py +0 -0
- {dctracker-1.0.0 → dctracker-1.1.0}/dct/web/blueprints/home.py +0 -0
- {dctracker-1.0.0 → dctracker-1.1.0}/dct/web/blueprints/items.py +0 -0
- {dctracker-1.0.0 → dctracker-1.1.0}/dct/web/blueprints/plans.py +0 -0
- {dctracker-1.0.0 → dctracker-1.1.0}/dct/web/blueprints/projects.py +0 -0
- {dctracker-1.0.0 → dctracker-1.1.0}/dct/web/blueprints/sprints.py +0 -0
- {dctracker-1.0.0 → dctracker-1.1.0}/dct/web/changes.py +0 -0
- {dctracker-1.0.0 → dctracker-1.1.0}/dct/web/serializers.py +0 -0
- {dctracker-1.0.0 → dctracker-1.1.0}/dct/web/static/app.css +0 -0
- {dctracker-1.0.0 → dctracker-1.1.0}/dct/web/static/app.js +0 -0
- {dctracker-1.0.0 → dctracker-1.1.0}/dct/web/static/vendor/SOURCES.txt +0 -0
- {dctracker-1.0.0 → dctracker-1.1.0}/dct/web/static/vendor/htmx.min.js +0 -0
- {dctracker-1.0.0 → dctracker-1.1.0}/dct/web/static/vendor/uPlot.iife.min.js +0 -0
- {dctracker-1.0.0 → dctracker-1.1.0}/dct/web/static/vendor/uPlot.min.css +0 -0
- {dctracker-1.0.0 → dctracker-1.1.0}/dct/web/templates/analytics.html +0 -0
- {dctracker-1.0.0 → dctracker-1.1.0}/dct/web/templates/base.html +0 -0
- {dctracker-1.0.0 → dctracker-1.1.0}/dct/web/templates/changelog/list.html +0 -0
- {dctracker-1.0.0 → dctracker-1.1.0}/dct/web/templates/decisions/list.html +0 -0
- {dctracker-1.0.0 → dctracker-1.1.0}/dct/web/templates/handoffs/list.html +0 -0
- {dctracker-1.0.0 → dctracker-1.1.0}/dct/web/templates/home.html +0 -0
- {dctracker-1.0.0 → dctracker-1.1.0}/dct/web/templates/item_detail.html +0 -0
- {dctracker-1.0.0 → dctracker-1.1.0}/dct/web/templates/items_list.html +0 -0
- {dctracker-1.0.0 → dctracker-1.1.0}/dct/web/templates/partials/_bars.html +0 -0
- {dctracker-1.0.0 → dctracker-1.1.0}/dct/web/templates/partials/_checkpoint_steps.html +0 -0
- {dctracker-1.0.0 → dctracker-1.1.0}/dct/web/templates/partials/_items_table.html +0 -0
- {dctracker-1.0.0 → dctracker-1.1.0}/dct/web/templates/partials/_plan_section.html +0 -0
- {dctracker-1.0.0 → dctracker-1.1.0}/dct/web/templates/partials/_project_card.html +0 -0
- {dctracker-1.0.0 → dctracker-1.1.0}/dct/web/templates/plans/detail.html +0 -0
- {dctracker-1.0.0 → dctracker-1.1.0}/dct/web/templates/plans/list.html +0 -0
- {dctracker-1.0.0 → dctracker-1.1.0}/dct/web/templates/project_home.html +0 -0
- {dctracker-1.0.0 → dctracker-1.1.0}/dct/web/templates/sprints/detail.html +0 -0
- {dctracker-1.0.0 → dctracker-1.1.0}/dct/web/templates/sprints/list.html +0 -0
- {dctracker-1.0.0 → dctracker-1.1.0}/dctracker.egg-info/dependency_links.txt +0 -0
- {dctracker-1.0.0 → dctracker-1.1.0}/dctracker.egg-info/entry_points.txt +0 -0
- {dctracker-1.0.0 → dctracker-1.1.0}/dctracker.egg-info/requires.txt +0 -0
- {dctracker-1.0.0 → dctracker-1.1.0}/dctracker.egg-info/top_level.txt +0 -0
- {dctracker-1.0.0 → dctracker-1.1.0}/setup.cfg +0 -0
- {dctracker-1.0.0 → dctracker-1.1.0}/tests/test_adversarial.py +0 -0
- {dctracker-1.0.0 → dctracker-1.1.0}/tests/test_analytics.py +0 -0
- {dctracker-1.0.0 → dctracker-1.1.0}/tests/test_changelog.py +0 -0
- {dctracker-1.0.0 → dctracker-1.1.0}/tests/test_changes.py +0 -0
- {dctracker-1.0.0 → dctracker-1.1.0}/tests/test_checkpoints.py +0 -0
- {dctracker-1.0.0 → dctracker-1.1.0}/tests/test_cli_prompts.py +0 -0
- {dctracker-1.0.0 → dctracker-1.1.0}/tests/test_cli_sprints.py +0 -0
- {dctracker-1.0.0 → dctracker-1.1.0}/tests/test_cli_web.py +0 -0
- {dctracker-1.0.0 → dctracker-1.1.0}/tests/test_config.py +0 -0
- {dctracker-1.0.0 → dctracker-1.1.0}/tests/test_decisions_crud.py +0 -0
- {dctracker-1.0.0 → dctracker-1.1.0}/tests/test_export.py +0 -0
- {dctracker-1.0.0 → dctracker-1.1.0}/tests/test_handoff.py +0 -0
- {dctracker-1.0.0 → dctracker-1.1.0}/tests/test_hook_bypass.py +0 -0
- {dctracker-1.0.0 → dctracker-1.1.0}/tests/test_hook_plan_autoscan.py +0 -0
- {dctracker-1.0.0 → dctracker-1.1.0}/tests/test_ids.py +0 -0
- {dctracker-1.0.0 → dctracker-1.1.0}/tests/test_ingest_dogfood.py +0 -0
- {dctracker-1.0.0 → dctracker-1.1.0}/tests/test_items.py +0 -0
- {dctracker-1.0.0 → dctracker-1.1.0}/tests/test_migration.py +0 -0
- {dctracker-1.0.0 → dctracker-1.1.0}/tests/test_packaging.py +0 -0
- {dctracker-1.0.0 → dctracker-1.1.0}/tests/test_plan_classify.py +0 -0
- {dctracker-1.0.0 → dctracker-1.1.0}/tests/test_plan_ingest.py +0 -0
- {dctracker-1.0.0 → dctracker-1.1.0}/tests/test_plan_parser.py +0 -0
- {dctracker-1.0.0 → dctracker-1.1.0}/tests/test_plans_crud.py +0 -0
- {dctracker-1.0.0 → dctracker-1.1.0}/tests/test_projects.py +0 -0
- {dctracker-1.0.0 → dctracker-1.1.0}/tests/test_setup.py +0 -0
- {dctracker-1.0.0 → dctracker-1.1.0}/tests/test_setup_wiring.py +0 -0
- {dctracker-1.0.0 → dctracker-1.1.0}/tests/test_sprint.py +0 -0
- {dctracker-1.0.0 → dctracker-1.1.0}/tests/test_sprints_crud.py +0 -0
- {dctracker-1.0.0 → dctracker-1.1.0}/tests/test_version.py +0 -0
- {dctracker-1.0.0 → dctracker-1.1.0}/tests/test_web_analytics.py +0 -0
- {dctracker-1.0.0 → dctracker-1.1.0}/tests/test_web_app.py +0 -0
- {dctracker-1.0.0 → dctracker-1.1.0}/tests/test_web_items.py +0 -0
- {dctracker-1.0.0 → dctracker-1.1.0}/tests/test_web_structural.py +0 -0
- {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.
|
|
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
|
[](https://github.com/fotodeveloper/dct/actions/workflows/ci.yml)
|
|
48
48
|
[](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,
|
|
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,
|
|
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 **
|
|
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
|
|
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
|
[](https://github.com/fotodeveloper/dct/actions/workflows/ci.yml)
|
|
6
6
|
[](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,
|
|
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,
|
|
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 **
|
|
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
|
|
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
|
|
|
@@ -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
|
|
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
|
-
|
|
795
|
-
|
|
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(
|
|
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)
|
|
22
|
-
|
|
23
|
-
|
|
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
|