dctracker 1.0.0__py3-none-any.whl
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.
- dct/__init__.py +6 -0
- dct/cli.py +1659 -0
- dct/config.py +150 -0
- dct/core/__init__.py +0 -0
- dct/core/analytics.py +382 -0
- dct/core/changelog.py +112 -0
- dct/core/checkpoints.py +205 -0
- dct/core/decisions.py +238 -0
- dct/core/engine.py +32 -0
- dct/core/handoff.py +151 -0
- dct/core/ids.py +25 -0
- dct/core/items.py +382 -0
- dct/core/plans/__init__.py +6 -0
- dct/core/plans/classify.py +94 -0
- dct/core/plans/crud.py +680 -0
- dct/core/plans/ingest.py +408 -0
- dct/core/plans/parser.py +312 -0
- dct/core/projects.py +298 -0
- dct/core/sprint.py +315 -0
- dct/core/sprints.py +601 -0
- dct/core/version.py +116 -0
- dct/db.py +558 -0
- dct/export.py +107 -0
- dct/hooks/check-changelog-on-stop.sh +49 -0
- dct/hooks/check-changelog.sh +143 -0
- dct/hooks/dct-plan-autoscan.sh +54 -0
- dct/hooks/dct-task-mirror.sh +62 -0
- dct/server.py +1812 -0
- dct/setup.py +258 -0
- dct/skills/clog/SKILL.md +73 -0
- dct/skills/commit/SKILL.md +153 -0
- dct/skills/dct-import/SKILL.md +329 -0
- dct/skills/dct-init/SKILL.md +289 -0
- dct/skills/handoff/SKILL.md +136 -0
- dct/skills/pickup/SKILL.md +115 -0
- dct/skills/plan-resolve-uncertain/SKILL.md +82 -0
- dct/skills/plan-status/SKILL.md +79 -0
- dct/skills/release/SKILL.md +281 -0
- dct/skills/track/SKILL.md +121 -0
- dct/skills/track-list/SKILL.md +134 -0
- dct/skills/track-resolve/SKILL.md +53 -0
- dct/skills/track-update/SKILL.md +109 -0
- dct/web/__init__.py +1 -0
- dct/web/app.py +83 -0
- dct/web/blueprints/__init__.py +5 -0
- dct/web/blueprints/analytics.py +34 -0
- dct/web/blueprints/changelog.py +40 -0
- dct/web/blueprints/decisions.py +23 -0
- dct/web/blueprints/events.py +69 -0
- dct/web/blueprints/handoffs.py +26 -0
- dct/web/blueprints/home.py +15 -0
- dct/web/blueprints/items.py +128 -0
- dct/web/blueprints/plans.py +53 -0
- dct/web/blueprints/projects.py +23 -0
- dct/web/blueprints/sprints.py +71 -0
- dct/web/changes.py +77 -0
- dct/web/serializers.py +109 -0
- dct/web/static/app.css +609 -0
- dct/web/static/app.js +62 -0
- dct/web/static/vendor/SOURCES.txt +10 -0
- dct/web/static/vendor/htmx.min.js +1 -0
- dct/web/static/vendor/uPlot.iife.min.js +2 -0
- dct/web/static/vendor/uPlot.min.css +1 -0
- dct/web/templates/analytics.html +63 -0
- dct/web/templates/base.html +106 -0
- dct/web/templates/changelog/list.html +23 -0
- dct/web/templates/decisions/list.html +22 -0
- dct/web/templates/handoffs/list.html +45 -0
- dct/web/templates/home.html +20 -0
- dct/web/templates/item_detail.html +91 -0
- dct/web/templates/items_list.html +44 -0
- dct/web/templates/partials/_bars.html +15 -0
- dct/web/templates/partials/_checkpoint_steps.html +22 -0
- dct/web/templates/partials/_items_table.html +23 -0
- dct/web/templates/partials/_plan_section.html +24 -0
- dct/web/templates/partials/_project_card.html +24 -0
- dct/web/templates/plans/detail.html +16 -0
- dct/web/templates/plans/list.html +15 -0
- dct/web/templates/project_home.html +51 -0
- dct/web/templates/sprints/detail.html +37 -0
- dct/web/templates/sprints/list.html +35 -0
- dctracker-1.0.0.dist-info/METADATA +357 -0
- dctracker-1.0.0.dist-info/RECORD +87 -0
- dctracker-1.0.0.dist-info/WHEEL +5 -0
- dctracker-1.0.0.dist-info/entry_points.txt +2 -0
- dctracker-1.0.0.dist-info/licenses/LICENSE +21 -0
- dctracker-1.0.0.dist-info/top_level.txt +1 -0
dct/export.py
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""Export changelog entries to Keep a Changelog markdown format."""
|
|
2
|
+
|
|
3
|
+
from collections import defaultdict
|
|
4
|
+
from datetime import date
|
|
5
|
+
|
|
6
|
+
import sqlalchemy as sa
|
|
7
|
+
|
|
8
|
+
from dct.db import changelog_entries, projects, row_to_dict
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def export_changelog_md(
|
|
12
|
+
engine: sa.Engine,
|
|
13
|
+
project_slug: str,
|
|
14
|
+
include_unreleased: bool = True,
|
|
15
|
+
version: str | None = None,
|
|
16
|
+
) -> str:
|
|
17
|
+
"""Generate Keep a Changelog format markdown from DB entries.
|
|
18
|
+
|
|
19
|
+
When `version` is given, render ONLY that version's `## [version] - DATE`
|
|
20
|
+
block — WITHOUT the top-level `# Changelog` header and WITHOUT the
|
|
21
|
+
[Unreleased] section. This single-version unit is what the append-only
|
|
22
|
+
/release flow prepends into an existing changelog.md (see item #214); an
|
|
23
|
+
unknown version renders to an empty string. When `version` is None
|
|
24
|
+
(default) the full file is rendered: header + [Unreleased] + every version.
|
|
25
|
+
"""
|
|
26
|
+
with engine.begin() as conn:
|
|
27
|
+
row = conn.execute(
|
|
28
|
+
sa.select(projects.c.id).where(projects.c.slug == project_slug, projects.c.deleted_at.is_(None))
|
|
29
|
+
).first()
|
|
30
|
+
if not row:
|
|
31
|
+
raise ValueError(f"Project '{project_slug}' not found")
|
|
32
|
+
pid = row._mapping["id"]
|
|
33
|
+
q = (
|
|
34
|
+
sa.select(changelog_entries)
|
|
35
|
+
.where(changelog_entries.c.project_id == pid, changelog_entries.c.deleted_at.is_(None))
|
|
36
|
+
)
|
|
37
|
+
if version is not None:
|
|
38
|
+
q = q.where(changelog_entries.c.version == version)
|
|
39
|
+
q = q.order_by(changelog_entries.c.released_at.desc().nulls_first(), changelog_entries.c.created_at)
|
|
40
|
+
rows = [row_to_dict(r) for r in conn.execute(q)]
|
|
41
|
+
|
|
42
|
+
category_order = ["added", "changed", "fixed", "removed"]
|
|
43
|
+
|
|
44
|
+
# Group by version
|
|
45
|
+
versions: dict[str | None, list[dict]] = defaultdict(list)
|
|
46
|
+
for row in rows:
|
|
47
|
+
versions[row["version"]].append(row)
|
|
48
|
+
|
|
49
|
+
# Single-version render: emit just the one block, no file-level header, so
|
|
50
|
+
# the append-only release flow can prepend it into the existing file.
|
|
51
|
+
if version is not None:
|
|
52
|
+
entries = versions.get(version)
|
|
53
|
+
if not entries:
|
|
54
|
+
return ""
|
|
55
|
+
block: list[str] = []
|
|
56
|
+
_render_version_block(version, entries, category_order, block)
|
|
57
|
+
return "\n".join(block)
|
|
58
|
+
|
|
59
|
+
lines = ["# Changelog\n", ""]
|
|
60
|
+
|
|
61
|
+
# Unreleased first
|
|
62
|
+
if include_unreleased and None in versions:
|
|
63
|
+
lines.append("## [Unreleased]")
|
|
64
|
+
lines.append("")
|
|
65
|
+
_render_version_entries(versions[None], category_order, lines)
|
|
66
|
+
|
|
67
|
+
# Released versions (sorted by released_at of each version's first entry, desc)
|
|
68
|
+
released = {k: v for k, v in versions.items() if k is not None}
|
|
69
|
+
for ver in sorted(released, key=lambda v: released[v][0].get("released_at") or "", reverse=True):
|
|
70
|
+
_render_version_block(ver, released[ver], category_order, lines)
|
|
71
|
+
|
|
72
|
+
return "\n".join(lines)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _render_version_block(
|
|
76
|
+
version: str, entries: list[dict], category_order: list[str], lines: list[str]
|
|
77
|
+
) -> None:
|
|
78
|
+
"""Append a `## [version] - DATE` header (date from the first entry's
|
|
79
|
+
released_at, or today when unreleased) followed by its category sections.
|
|
80
|
+
Shared by the full-file render and the single-version render so the two
|
|
81
|
+
paths can never drift apart."""
|
|
82
|
+
released_at = entries[0].get("released_at")
|
|
83
|
+
if released_at:
|
|
84
|
+
date_str = released_at.strftime("%Y-%m-%d") if hasattr(released_at, "strftime") else str(released_at)[:10]
|
|
85
|
+
else:
|
|
86
|
+
date_str = date.today().isoformat()
|
|
87
|
+
lines.append(f"## [{version}] - {date_str}")
|
|
88
|
+
lines.append("")
|
|
89
|
+
_render_version_entries(entries, category_order, lines)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _render_version_entries(entries: list[dict], category_order: list[str], lines: list[str]) -> None:
|
|
93
|
+
by_category: dict[str, list[dict]] = defaultdict(list)
|
|
94
|
+
for e in entries:
|
|
95
|
+
by_category[e["category"]].append(e)
|
|
96
|
+
|
|
97
|
+
for cat in category_order:
|
|
98
|
+
if cat not in by_category:
|
|
99
|
+
continue
|
|
100
|
+
lines.append(f"### {cat.capitalize()}")
|
|
101
|
+
lines.append("")
|
|
102
|
+
for e in by_category[cat]:
|
|
103
|
+
entry_text = e["entry"]
|
|
104
|
+
if e.get("component"):
|
|
105
|
+
entry_text = f"{entry_text} ({e['component']})"
|
|
106
|
+
lines.append(f"- {entry_text}")
|
|
107
|
+
lines.append("")
|
|
@@ -0,0 +1,49 @@
|
|
|
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
|
+
# Require dct CLI
|
|
7
|
+
command -v dct >/dev/null || exit 0
|
|
8
|
+
|
|
9
|
+
# Check if we're in a dct-registered project
|
|
10
|
+
project=$(dct project detect 2>/dev/null)
|
|
11
|
+
if [ -z "$project" ]; then
|
|
12
|
+
exit 0
|
|
13
|
+
fi
|
|
14
|
+
|
|
15
|
+
# #222: opt-in — only remind for projects that manage their changelog in dct.
|
|
16
|
+
if ! dct project manages-changelog "$project" 2>/dev/null; then
|
|
17
|
+
exit 0
|
|
18
|
+
fi
|
|
19
|
+
|
|
20
|
+
# Exempt patterns
|
|
21
|
+
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$"
|
|
22
|
+
|
|
23
|
+
# Check for modified/staged/untracked app files
|
|
24
|
+
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)
|
|
25
|
+
[ -z "$changed" ] && exit 0
|
|
26
|
+
|
|
27
|
+
# Deduplicate and filter exempt files
|
|
28
|
+
app_files=$(echo "$changed" | sort -u | grep -vE "$exempt_re")
|
|
29
|
+
[ -z "$app_files" ] && exit 0
|
|
30
|
+
|
|
31
|
+
# Check if changelog was modified
|
|
32
|
+
changelog_changed=$(echo "$changed" | sort -u | grep -iE "^changelog|^CHANGELOG" | head -1)
|
|
33
|
+
if [ -n "$changelog_changed" ]; then
|
|
34
|
+
exit 0
|
|
35
|
+
fi
|
|
36
|
+
|
|
37
|
+
# Check dct DB for unreleased entries
|
|
38
|
+
count=$(dct clog count --project "$project" 2>/dev/null)
|
|
39
|
+
if [ "${count:-0}" -gt 0 ]; then
|
|
40
|
+
exit 0
|
|
41
|
+
fi
|
|
42
|
+
|
|
43
|
+
# Reminder (non-blocking — always exit 0)
|
|
44
|
+
file_count=$(echo "$app_files" | wc -l | tr -d ' ')
|
|
45
|
+
echo ""
|
|
46
|
+
echo "📝 Reminder: ${file_count} app file(s) changed in ${project}/ but no changelog entry found."
|
|
47
|
+
echo " Use /clog to add an entry."
|
|
48
|
+
echo ""
|
|
49
|
+
exit 0
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# dct global hook — PreToolUse for Bash(git commit*)
|
|
3
|
+
# Blocks commits without changelog entries (staged changelog file OR dct DB entry).
|
|
4
|
+
# Opt-in (#222): enforces ONLY projects with manages_changelog=TRUE — a project
|
|
5
|
+
# keeping its own changelog opts out via `dct project changelog <slug> off`.
|
|
6
|
+
# Install: ~/.claude/hooks/dct-check-changelog.sh
|
|
7
|
+
|
|
8
|
+
# Consume stdin (required by hook protocol)
|
|
9
|
+
input=$(cat)
|
|
10
|
+
|
|
11
|
+
# Require jq
|
|
12
|
+
command -v jq >/dev/null || exit 0
|
|
13
|
+
|
|
14
|
+
# Require dct CLI
|
|
15
|
+
command -v dct >/dev/null || exit 0
|
|
16
|
+
|
|
17
|
+
# Extract command from hook input
|
|
18
|
+
command=$(echo "$input" | jq -r '.tool_input.command // ""' 2>/dev/null)
|
|
19
|
+
|
|
20
|
+
# Only check git commit commands
|
|
21
|
+
case "$command" in
|
|
22
|
+
*"git commit"*) ;;
|
|
23
|
+
*) exit 0 ;;
|
|
24
|
+
esac
|
|
25
|
+
|
|
26
|
+
# Skip amend commits (strip message content to avoid false matches)
|
|
27
|
+
flags=$(echo "$command" | sed 's/ -m [^ ]*//g; s/ -m ".*"//g; s/ --message[= ][^ ]*//g; s/ --message ".*"//g')
|
|
28
|
+
case "$flags" in
|
|
29
|
+
*"--amend"*) exit 0 ;;
|
|
30
|
+
esac
|
|
31
|
+
|
|
32
|
+
# Release-commit bypass: skip hook entirely when commit message starts with "release:"
|
|
33
|
+
# /release skill produces clean release commits with zero unreleased entries after stamping.
|
|
34
|
+
# Two forms to handle: inline -m "release: ..." and HEREDOC heredoc where the first line
|
|
35
|
+
# of the message body is "release: ...".
|
|
36
|
+
|
|
37
|
+
# Form 1 — inline -m "..." / -m '...' / --message="..."
|
|
38
|
+
inline_msg=$(echo "$command" | sed -n \
|
|
39
|
+
-e 's/.*-m "\([^"]*\)".*/\1/p' \
|
|
40
|
+
-e "s/.*-m '\([^']*\)'.*/\1/p" \
|
|
41
|
+
-e 's/.*--message="\([^"]*\)".*/\1/p' \
|
|
42
|
+
| head -1)
|
|
43
|
+
case "$inline_msg" in
|
|
44
|
+
release:*) exit 0 ;;
|
|
45
|
+
esac
|
|
46
|
+
|
|
47
|
+
# Form 2 — HEREDOC: multi-line command containing a line that starts with "release:"
|
|
48
|
+
if echo "$command" | grep -qE '^release:'; then
|
|
49
|
+
exit 0
|
|
50
|
+
fi
|
|
51
|
+
|
|
52
|
+
# Check if we're in a dct-registered project
|
|
53
|
+
project=$(dct project detect 2>/dev/null)
|
|
54
|
+
if [ -z "$project" ]; then
|
|
55
|
+
exit 0 # Not a dct project — pass through
|
|
56
|
+
fi
|
|
57
|
+
|
|
58
|
+
# #222: opt-in — only enforce projects that manage their changelog in dct.
|
|
59
|
+
# A project registered for item-tracking but keeping its own changelog opts out
|
|
60
|
+
# via `dct project changelog <slug> off` and is skipped here.
|
|
61
|
+
if ! dct project manages-changelog "$project" 2>/dev/null; then
|
|
62
|
+
exit 0
|
|
63
|
+
fi
|
|
64
|
+
|
|
65
|
+
# Get staged files
|
|
66
|
+
staged=$(git diff --cached --name-only 2>/dev/null)
|
|
67
|
+
if [ -z "$staged" ]; then
|
|
68
|
+
exit 0 # Nothing staged
|
|
69
|
+
fi
|
|
70
|
+
|
|
71
|
+
# Exempt patterns — files that don't require changelog entries
|
|
72
|
+
exempt_patterns=(
|
|
73
|
+
"^\.claude/"
|
|
74
|
+
"^\.github/"
|
|
75
|
+
"^\.cursor/"
|
|
76
|
+
"^\.vscode/"
|
|
77
|
+
"^\.gstack/"
|
|
78
|
+
"^docs/"
|
|
79
|
+
"^scripts/"
|
|
80
|
+
"^tests/"
|
|
81
|
+
"^test_"
|
|
82
|
+
"conftest\.py$"
|
|
83
|
+
"CLAUDE\.md$"
|
|
84
|
+
"CLAUDE\.local\.md$"
|
|
85
|
+
"AGENTS\.md$"
|
|
86
|
+
"README\.md$"
|
|
87
|
+
"LICENSE"
|
|
88
|
+
"\.gitignore$"
|
|
89
|
+
"^changelog"
|
|
90
|
+
"^CHANGELOG"
|
|
91
|
+
"^VERSION$"
|
|
92
|
+
"pyproject\.toml$"
|
|
93
|
+
"Cargo\.toml$"
|
|
94
|
+
"package\.json$"
|
|
95
|
+
"biome\.json$"
|
|
96
|
+
"Dockerfile"
|
|
97
|
+
"docker-compose"
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
# Check if ALL staged files are exempt
|
|
101
|
+
has_app_code=false
|
|
102
|
+
while IFS= read -r file; do
|
|
103
|
+
is_exempt=false
|
|
104
|
+
for pattern in "${exempt_patterns[@]}"; do
|
|
105
|
+
if echo "$file" | grep -qE "$pattern"; then
|
|
106
|
+
is_exempt=true
|
|
107
|
+
break
|
|
108
|
+
fi
|
|
109
|
+
done
|
|
110
|
+
if [ "$is_exempt" = false ]; then
|
|
111
|
+
has_app_code=true
|
|
112
|
+
break
|
|
113
|
+
fi
|
|
114
|
+
done <<< "$staged"
|
|
115
|
+
|
|
116
|
+
if [ "$has_app_code" = false ]; then
|
|
117
|
+
exit 0 # All files are exempt
|
|
118
|
+
fi
|
|
119
|
+
|
|
120
|
+
# Check for changelog in staged files
|
|
121
|
+
has_changelog=false
|
|
122
|
+
while IFS= read -r file; do
|
|
123
|
+
case "$file" in
|
|
124
|
+
changelog*|CHANGELOG*|changelog_unreleased*) has_changelog=true; break ;;
|
|
125
|
+
esac
|
|
126
|
+
done <<< "$staged"
|
|
127
|
+
|
|
128
|
+
# If no changelog file staged, check dct database for unreleased entries
|
|
129
|
+
if [ "$has_changelog" = false ]; then
|
|
130
|
+
count=$(dct clog count --project "$project" 2>/dev/null)
|
|
131
|
+
if [ "${count:-0}" -gt 0 ]; then
|
|
132
|
+
has_changelog=true
|
|
133
|
+
fi
|
|
134
|
+
fi
|
|
135
|
+
|
|
136
|
+
if [ "$has_changelog" = true ]; then
|
|
137
|
+
exit 0
|
|
138
|
+
fi
|
|
139
|
+
|
|
140
|
+
# Block the commit
|
|
141
|
+
reason="App code staged but no changelog entry found for project '$project'. Add a changelog entry with /clog or stage a changelog file."
|
|
142
|
+
echo "{\"hookSpecificOutput\":{\"hookEventName\":\"PreToolUse\",\"permissionDecision\":\"deny\",\"permissionDecisionReason\":\"$reason\"}}"
|
|
143
|
+
exit 2
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# dct-plan-autoscan.sh — PostToolUse hook for Claude Code.
|
|
3
|
+
#
|
|
4
|
+
# Rescans plan-like markdown files (plans, specs, ADRs, roadmaps, backlogs,
|
|
5
|
+
# retros) after an Edit/Write/MultiEdit. Runs in the background so the user
|
|
6
|
+
# never waits. Handles brand-new plans (ULID injection on first save) and
|
|
7
|
+
# re-sync of already-tracked plans.
|
|
8
|
+
#
|
|
9
|
+
# Follows the five hook rules from dct CLAUDE.md:
|
|
10
|
+
# 1. No interactive prompts (non-interactive only).
|
|
11
|
+
# 2. Consume stdin (Claude Code hooks supply JSON there).
|
|
12
|
+
# 3. Graceful when deps missing (command -v dct / jq).
|
|
13
|
+
# 4. Exit codes meaningful: 0 = pass-through, non-zero avoided.
|
|
14
|
+
# 5. No message-content inspection needed here (not a commit hook).
|
|
15
|
+
set -euo pipefail
|
|
16
|
+
|
|
17
|
+
# Rule 2: consume stdin even if we partially inspect it.
|
|
18
|
+
input=$(cat)
|
|
19
|
+
|
|
20
|
+
# Rule 3: graceful on missing deps — dct or jq absent means no-op.
|
|
21
|
+
command -v dct >/dev/null || exit 0
|
|
22
|
+
command -v jq >/dev/null || exit 0
|
|
23
|
+
|
|
24
|
+
# Extract tool name + file path from the JSON payload CC hooks pass.
|
|
25
|
+
tool=$(echo "$input" | jq -r '.tool_name // empty' 2>/dev/null || true)
|
|
26
|
+
file=$(echo "$input" | jq -r '.tool_input.file_path // empty' 2>/dev/null || true)
|
|
27
|
+
|
|
28
|
+
# Only trigger on edit/write-style tools.
|
|
29
|
+
case "$tool" in
|
|
30
|
+
Edit|Write|MultiEdit) ;;
|
|
31
|
+
*) exit 0 ;;
|
|
32
|
+
esac
|
|
33
|
+
|
|
34
|
+
# Only act on .md files matching our discovery patterns.
|
|
35
|
+
case "$file" in
|
|
36
|
+
*/docs/plans/*.md|*/docs/specs/*.md|*/docs/adr/*.md|*/docs/retros/*.md) ;;
|
|
37
|
+
*/ROADMAP.md|*ROADMAP.md|*/TODOS.md|*TODOS.md|*/FUTURE.md|*FUTURE.md) ;;
|
|
38
|
+
*/BACKLOG.md|*BACKLOG.md) ;;
|
|
39
|
+
*) exit 0 ;;
|
|
40
|
+
esac
|
|
41
|
+
|
|
42
|
+
# Must exist (edit may have been rejected / the file was deleted).
|
|
43
|
+
[ -f "$file" ] || exit 0
|
|
44
|
+
|
|
45
|
+
# Fire-and-forget: detach a background rescan, log to a unique temp file so
|
|
46
|
+
# parallel hook invocations don't clobber each other. The `disown` is key —
|
|
47
|
+
# without it, the hook would block until the subprocess exits, defeating the
|
|
48
|
+
# whole point of the background pattern.
|
|
49
|
+
log_dir="${TMPDIR:-/tmp}"
|
|
50
|
+
log="${log_dir%/}/dct-rescan-$(date +%s)-$$.log"
|
|
51
|
+
nohup dct plan rescan "$file" --quiet --resolve-uncertain >"$log" 2>&1 &
|
|
52
|
+
disown
|
|
53
|
+
|
|
54
|
+
exit 0
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# dct-task-mirror.sh — PreToolUse hook for Claude Code.
|
|
3
|
+
#
|
|
4
|
+
# Mirrors TaskCreate / TodoWrite calls to dct checkpoints. Thin shell
|
|
5
|
+
# dispatcher: validates payload, checks config gate, detects project,
|
|
6
|
+
# then delegates actual work to `dct _task-mirror` CLI subcommand (which
|
|
7
|
+
# uses the venv-installed Python with dct importable — shell/system
|
|
8
|
+
# python cannot reliably find dct on PYTHONPATH).
|
|
9
|
+
#
|
|
10
|
+
# Each todo becomes a `kind='cc-task'` checkpoint on a session-scoped item
|
|
11
|
+
# titled "CC task list — session <id>". cc-task is non-blocking for
|
|
12
|
+
# resolve_item gate semantics.
|
|
13
|
+
#
|
|
14
|
+
# Respects [mirror].task_to_dct config: auto (default) / ask / off.
|
|
15
|
+
#
|
|
16
|
+
# Follows the five hook rules from dct CLAUDE.md:
|
|
17
|
+
# 1. No interactive prompts.
|
|
18
|
+
# 2. Consume stdin.
|
|
19
|
+
# 3. Graceful on missing deps.
|
|
20
|
+
# 4. exit 0 on every path.
|
|
21
|
+
# 5. No commit-message inspection.
|
|
22
|
+
set -euo pipefail
|
|
23
|
+
|
|
24
|
+
# Rule 2: drain stdin once; reuse for both tool detection and forwarding.
|
|
25
|
+
input=$(cat)
|
|
26
|
+
|
|
27
|
+
# Rule 3: graceful on missing deps.
|
|
28
|
+
command -v jq >/dev/null || exit 0
|
|
29
|
+
command -v dct >/dev/null || exit 0
|
|
30
|
+
|
|
31
|
+
tool=$(echo "$input" | jq -r '.tool_name // empty' 2>/dev/null || true)
|
|
32
|
+
session_id=$(echo "$input" | jq -r '.session_id // "unknown"' 2>/dev/null || true)
|
|
33
|
+
|
|
34
|
+
case "$tool" in
|
|
35
|
+
TaskCreate|TodoWrite) ;;
|
|
36
|
+
*) exit 0 ;;
|
|
37
|
+
esac
|
|
38
|
+
|
|
39
|
+
# Config gate.
|
|
40
|
+
mode=$(dct config get mirror.task_to_dct 2>/dev/null || echo "auto")
|
|
41
|
+
case "$mode" in
|
|
42
|
+
off|ask) exit 0 ;;
|
|
43
|
+
auto|"") ;;
|
|
44
|
+
*) exit 0 ;;
|
|
45
|
+
esac
|
|
46
|
+
|
|
47
|
+
# Project autodetect (hook CWD = current project).
|
|
48
|
+
project=$(dct project detect 2>/dev/null || true)
|
|
49
|
+
[ -z "$project" ] && exit 0
|
|
50
|
+
|
|
51
|
+
log_dir="${TMPDIR:-/tmp}"
|
|
52
|
+
log="${log_dir%/}/dct-task-mirror-$(date +%s)-$$.log"
|
|
53
|
+
|
|
54
|
+
# Fire-and-forget. The background subshell pipes the payload to the
|
|
55
|
+
# internal CLI worker and logs any output for diagnosis.
|
|
56
|
+
{
|
|
57
|
+
nohup bash -c "printf '%s' \"\$1\" | dct _task-mirror --session-id \"\$2\" --project \"\$3\" >>\"\$4\" 2>&1" \
|
|
58
|
+
_ "$input" "$session_id" "$project" "$log" &
|
|
59
|
+
disown
|
|
60
|
+
} </dev/null >/dev/null 2>&1
|
|
61
|
+
|
|
62
|
+
exit 0
|