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/cli.py
ADDED
|
@@ -0,0 +1,1659 @@
|
|
|
1
|
+
"""CLI entry point for dct (Deterministic Context Tracker)."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import os
|
|
5
|
+
import signal
|
|
6
|
+
import socket
|
|
7
|
+
import sys
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from dct import __version__
|
|
11
|
+
from dct.config import load_config, save_config, Config, DEFAULT_DB_PATH
|
|
12
|
+
from dct.db import get_engine, create_tables
|
|
13
|
+
from dct.core.engine import bootstrap_engine
|
|
14
|
+
|
|
15
|
+
# ── web daemon constants ─────────────────────────────────────────────────────
|
|
16
|
+
WEB_PIDFILE = Path.home() / ".claude" / "dct-web.pid"
|
|
17
|
+
WEB_LOGFILE = Path.home() / ".claude" / "dct-web.log"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _get_engine():
|
|
21
|
+
return bootstrap_engine(create=True)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _detect_project(engine) -> str:
|
|
25
|
+
"""Auto-detect project slug from CWD. Exits if not found."""
|
|
26
|
+
import os
|
|
27
|
+
from dct.core.projects import get_project_by_path
|
|
28
|
+
|
|
29
|
+
cwd = os.getcwd()
|
|
30
|
+
project = get_project_by_path(engine, cwd)
|
|
31
|
+
if not project:
|
|
32
|
+
print(f"✗ Cannot detect project from {cwd}. Use: dct clog <cmd> <project>", file=sys.stderr)
|
|
33
|
+
sys.exit(1)
|
|
34
|
+
return project["slug"]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _fmt_date(dt) -> str:
|
|
38
|
+
if dt is None:
|
|
39
|
+
return ""
|
|
40
|
+
if isinstance(dt, str):
|
|
41
|
+
return dt[:10]
|
|
42
|
+
return dt.strftime("%Y-%m-%d")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _prompt_version(default: str = "0.1.0") -> str | None:
|
|
46
|
+
"""Ask the user for a starting version when auto-detect failed. Empty input → `default`.
|
|
47
|
+
|
|
48
|
+
Returns None when stdin has no tty (non-interactive) — caller must handle.
|
|
49
|
+
"""
|
|
50
|
+
if not sys.stdin.isatty():
|
|
51
|
+
return None
|
|
52
|
+
print(" Pick a starting version (empty = default):")
|
|
53
|
+
print(" 1) 0.0.1 (pre-alpha)")
|
|
54
|
+
print(" 2) 0.1.0 (alpha — recommended)")
|
|
55
|
+
print(" 3) 1.0.0 (stable)")
|
|
56
|
+
print(" or type your own (e.g. 2.3.4)")
|
|
57
|
+
choice = input(f" Version [{default}]: ").strip()
|
|
58
|
+
if not choice:
|
|
59
|
+
return default
|
|
60
|
+
shortcut = {"1": "0.0.1", "2": "0.1.0", "3": "1.0.0"}
|
|
61
|
+
return shortcut.get(choice, choice)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _print_table(headers: list[str], rows: list[list[str]], max_widths: dict[int, int] | None = None) -> None:
|
|
65
|
+
"""Print a simple aligned table."""
|
|
66
|
+
if not rows:
|
|
67
|
+
print("(no results)")
|
|
68
|
+
return
|
|
69
|
+
|
|
70
|
+
widths = [len(h) for h in headers]
|
|
71
|
+
for row in rows:
|
|
72
|
+
for i, cell in enumerate(row):
|
|
73
|
+
widths[i] = max(widths[i], len(str(cell)))
|
|
74
|
+
|
|
75
|
+
if max_widths:
|
|
76
|
+
for i, mw in max_widths.items():
|
|
77
|
+
widths[i] = min(widths[i], mw)
|
|
78
|
+
|
|
79
|
+
fmt = " ".join(f"{{:<{w}}}" for w in widths)
|
|
80
|
+
print(fmt.format(*headers))
|
|
81
|
+
print(fmt.format(*["─" * w for w in widths]))
|
|
82
|
+
for row in rows:
|
|
83
|
+
truncated = [str(c)[:widths[i]] for i, c in enumerate(row)]
|
|
84
|
+
print(fmt.format(*truncated))
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
# ── Init ──────────────────────────────────────────────────────────────────────
|
|
88
|
+
|
|
89
|
+
def cmd_init(args):
|
|
90
|
+
print("=== dct — Deterministic Context Tracker — setup ===\n")
|
|
91
|
+
|
|
92
|
+
# Database
|
|
93
|
+
db_type = input("Database backend [sqlite/postgresql] (default: sqlite): ").strip().lower() or "sqlite"
|
|
94
|
+
if db_type == "postgresql":
|
|
95
|
+
default_url = "postgresql://localhost:5432/dct"
|
|
96
|
+
url = input(f"PostgreSQL URL [{default_url}]: ").strip() or default_url
|
|
97
|
+
else:
|
|
98
|
+
url = f"sqlite:///{DEFAULT_DB_PATH}"
|
|
99
|
+
print(f"Using SQLite at {DEFAULT_DB_PATH}")
|
|
100
|
+
|
|
101
|
+
# Test connection
|
|
102
|
+
try:
|
|
103
|
+
engine = get_engine(url)
|
|
104
|
+
with engine.connect() as conn:
|
|
105
|
+
import sqlalchemy as sa_mod
|
|
106
|
+
conn.execute(sa_mod.text("SELECT 1"))
|
|
107
|
+
print("Connection OK")
|
|
108
|
+
except Exception as e:
|
|
109
|
+
print(f"Connection failed: {e}")
|
|
110
|
+
sys.exit(1)
|
|
111
|
+
|
|
112
|
+
# Save config
|
|
113
|
+
config = Config(database_url=url)
|
|
114
|
+
path = save_config(config)
|
|
115
|
+
print(f"Config saved to {path}")
|
|
116
|
+
|
|
117
|
+
# Create tables
|
|
118
|
+
create_tables(engine)
|
|
119
|
+
print("Tables created.\n")
|
|
120
|
+
|
|
121
|
+
# Register projects
|
|
122
|
+
from dct.core.projects import create_project, set_version
|
|
123
|
+
|
|
124
|
+
while True:
|
|
125
|
+
reg = input("Register a project? [Y/n]: ").strip().lower()
|
|
126
|
+
if reg == "n":
|
|
127
|
+
break
|
|
128
|
+
|
|
129
|
+
slug = input("Project slug: ").strip()
|
|
130
|
+
if not slug:
|
|
131
|
+
continue
|
|
132
|
+
proj_path = input("Project path [.]: ").strip() or "."
|
|
133
|
+
import os
|
|
134
|
+
proj_path = os.path.abspath(proj_path)
|
|
135
|
+
name = input(f"Project name [{slug}]: ").strip() or slug
|
|
136
|
+
|
|
137
|
+
try:
|
|
138
|
+
project = create_project(engine, slug=slug, name=name, path=proj_path)
|
|
139
|
+
print(f" ✓ Project '{slug}' registered")
|
|
140
|
+
version = project.get("current_version")
|
|
141
|
+
source = project.get("_detected_source")
|
|
142
|
+
if version and source:
|
|
143
|
+
print(f" ↪ Detected version {version} from {source}")
|
|
144
|
+
elif not version:
|
|
145
|
+
picked = _prompt_version()
|
|
146
|
+
if picked:
|
|
147
|
+
set_version(engine, slug, picked)
|
|
148
|
+
print(f" ✓ Version set to {picked}")
|
|
149
|
+
else:
|
|
150
|
+
print(f" ⚠ No version — run `dct project set-version {slug} <v>` later")
|
|
151
|
+
except Exception as e:
|
|
152
|
+
print(f" ✗ Error: {e}")
|
|
153
|
+
|
|
154
|
+
another = input("Register another? [Y/n]: ").strip().lower()
|
|
155
|
+
if another == "n":
|
|
156
|
+
break
|
|
157
|
+
|
|
158
|
+
# Install hook, skills, and register MCP server (user scope)
|
|
159
|
+
from dct.setup import install_hook, install_skills, register_mcp
|
|
160
|
+
|
|
161
|
+
ok, msg = register_mcp(config)
|
|
162
|
+
if ok:
|
|
163
|
+
print(f"\n ✓ MCP server — {msg}")
|
|
164
|
+
if install_hook(config):
|
|
165
|
+
print(" ✓ Global hook installed")
|
|
166
|
+
if install_skills(config):
|
|
167
|
+
print(" ✓ Skills installed")
|
|
168
|
+
|
|
169
|
+
# Wire hooks into ~/.claude/settings.json (idempotent; consent-gated).
|
|
170
|
+
# args.wire_hooks: True (--wire-hooks) / False (--no-wire-hooks) / None (ask
|
|
171
|
+
# on a tty, skip when non-interactive — hooks rule 1).
|
|
172
|
+
wire = getattr(args, "wire_hooks", None)
|
|
173
|
+
if wire is None:
|
|
174
|
+
if sys.stdin.isatty():
|
|
175
|
+
answer = input(
|
|
176
|
+
"Wire hooks into ~/.claude/settings.json? [Y/n]: "
|
|
177
|
+
).strip().lower()
|
|
178
|
+
wire = answer != "n"
|
|
179
|
+
else:
|
|
180
|
+
wire = False
|
|
181
|
+
if wire:
|
|
182
|
+
from dct.setup import wire_hooks_into_settings
|
|
183
|
+
try:
|
|
184
|
+
result = wire_hooks_into_settings()
|
|
185
|
+
except ValueError as e:
|
|
186
|
+
print(f" ⚠ Hook wiring skipped: {e}")
|
|
187
|
+
else:
|
|
188
|
+
if result["added"]:
|
|
189
|
+
print(f" ✓ Hooks wired into settings.json: {', '.join(result['added'])}")
|
|
190
|
+
if result["backup"]:
|
|
191
|
+
print(f" (backup: {result['backup']})")
|
|
192
|
+
else:
|
|
193
|
+
print(" ✓ Hooks already wired into settings.json")
|
|
194
|
+
else:
|
|
195
|
+
print(" ⚠ Hooks not wired — run `dct hooks wire` later (see docs/hooks.md).")
|
|
196
|
+
|
|
197
|
+
print("\nTo register more projects: dct project add <slug> <path>")
|
|
198
|
+
print("To import existing data: use /dct-import in a project session")
|
|
199
|
+
print("\nSetup complete!")
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
# ── Hooks commands ────────────────────────────────────────────────────────────
|
|
203
|
+
|
|
204
|
+
def cmd_hooks_wire(args):
|
|
205
|
+
from dct.setup import wire_hooks_into_settings
|
|
206
|
+
|
|
207
|
+
try:
|
|
208
|
+
result = wire_hooks_into_settings(settings_path=args.settings)
|
|
209
|
+
except ValueError as e:
|
|
210
|
+
print(f"✗ {e}")
|
|
211
|
+
sys.exit(1)
|
|
212
|
+
|
|
213
|
+
if result["added"]:
|
|
214
|
+
print(f"✓ Wired {len(result['added'])} hook(s) into {result['settings_path']}:")
|
|
215
|
+
for name in result["added"]:
|
|
216
|
+
print(f" + {name}")
|
|
217
|
+
if result["backup"]:
|
|
218
|
+
print(f" backup: {result['backup']}")
|
|
219
|
+
if result["skipped"]:
|
|
220
|
+
print(f" already wired: {', '.join(result['skipped'])}")
|
|
221
|
+
if not result["added"] and not result["skipped"]:
|
|
222
|
+
print("✓ Nothing to wire.")
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
# ── Project commands ──────────────────────────────────────────────────────────
|
|
226
|
+
|
|
227
|
+
def cmd_project_add(args):
|
|
228
|
+
import os
|
|
229
|
+
from dct.core.projects import create_project, set_version
|
|
230
|
+
|
|
231
|
+
# #227: realpath (not abspath) so a symlinked path is stored canonically and
|
|
232
|
+
# matches os.getcwd() (physical) at detect time. os.getcwd() is already physical.
|
|
233
|
+
path = os.path.realpath(args.path) if args.path else os.getcwd()
|
|
234
|
+
name = args.name or args.slug
|
|
235
|
+
engine = _get_engine()
|
|
236
|
+
|
|
237
|
+
try:
|
|
238
|
+
project = create_project(engine, slug=args.slug, name=name, path=path, current_version=args.version)
|
|
239
|
+
except Exception as e:
|
|
240
|
+
print(f"✗ Error: {e}", file=sys.stderr)
|
|
241
|
+
sys.exit(1)
|
|
242
|
+
|
|
243
|
+
print(f"✓ Project '{args.slug}' registered at {path}")
|
|
244
|
+
|
|
245
|
+
version = project.get("current_version")
|
|
246
|
+
source = project.get("_detected_source")
|
|
247
|
+
if version:
|
|
248
|
+
if source and not args.version:
|
|
249
|
+
print(f" ↪ Detected version {version} from {source}")
|
|
250
|
+
else:
|
|
251
|
+
print(f" Version: {version}")
|
|
252
|
+
return
|
|
253
|
+
|
|
254
|
+
# No version on the row — try an interactive prompt, else warn & bail out.
|
|
255
|
+
picked = _prompt_version() if sys.stdin.isatty() else None
|
|
256
|
+
if picked:
|
|
257
|
+
set_version(engine, args.slug, picked)
|
|
258
|
+
print(f" ✓ Version set to {picked}")
|
|
259
|
+
else:
|
|
260
|
+
print(
|
|
261
|
+
f" ⚠ No version detected. Run `dct project set-version {args.slug} <version>` "
|
|
262
|
+
f"before using `/release`.",
|
|
263
|
+
file=sys.stderr,
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def cmd_project_set_version(args):
|
|
268
|
+
from dct.core.projects import set_version
|
|
269
|
+
|
|
270
|
+
engine = _get_engine()
|
|
271
|
+
project = set_version(engine, args.slug, args.version)
|
|
272
|
+
if project:
|
|
273
|
+
print(f"✓ {args.slug} → {args.version}")
|
|
274
|
+
else:
|
|
275
|
+
print(f"✗ Project '{args.slug}' not found", file=sys.stderr)
|
|
276
|
+
sys.exit(1)
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def cmd_project_rename(args):
|
|
280
|
+
from dct.core.projects import update_project
|
|
281
|
+
|
|
282
|
+
engine = _get_engine()
|
|
283
|
+
new_slug = getattr(args, "new_slug", None)
|
|
284
|
+
new_name = getattr(args, "name", None)
|
|
285
|
+
new_description = getattr(args, "description", None)
|
|
286
|
+
new_path = getattr(args, "path", None)
|
|
287
|
+
|
|
288
|
+
if all(v is None for v in (new_slug, new_name, new_description, new_path)):
|
|
289
|
+
print(
|
|
290
|
+
"✗ Nothing to rename — pass at least one of --slug, --name, --description, --path",
|
|
291
|
+
file=sys.stderr,
|
|
292
|
+
)
|
|
293
|
+
sys.exit(1)
|
|
294
|
+
|
|
295
|
+
try:
|
|
296
|
+
project = update_project(
|
|
297
|
+
engine,
|
|
298
|
+
args.slug,
|
|
299
|
+
new_slug=new_slug,
|
|
300
|
+
new_name=new_name,
|
|
301
|
+
new_description=new_description,
|
|
302
|
+
new_path=new_path,
|
|
303
|
+
)
|
|
304
|
+
except ValueError as e:
|
|
305
|
+
print(f"✗ {e}", file=sys.stderr)
|
|
306
|
+
sys.exit(1)
|
|
307
|
+
|
|
308
|
+
if project is None:
|
|
309
|
+
print(f"✗ Project '{args.slug}' not found", file=sys.stderr)
|
|
310
|
+
sys.exit(1)
|
|
311
|
+
|
|
312
|
+
print(f"✓ Project '{args.slug}' updated")
|
|
313
|
+
print(f" slug: {project['slug']}")
|
|
314
|
+
print(f" name: {project['name']}")
|
|
315
|
+
if project.get("description"):
|
|
316
|
+
print(f" description: {project['description']}")
|
|
317
|
+
if project.get("path"):
|
|
318
|
+
print(f" path: {project['path']}")
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def cmd_project_changelog(args):
|
|
322
|
+
from dct.core.projects import set_manages_changelog
|
|
323
|
+
|
|
324
|
+
engine = _get_engine()
|
|
325
|
+
project = set_manages_changelog(engine, args.slug, args.state == "on")
|
|
326
|
+
if project:
|
|
327
|
+
state = "ON" if project["manages_changelog"] else "OFF"
|
|
328
|
+
print(f"✓ {args.slug}: dct changelog enforcement {state}")
|
|
329
|
+
else:
|
|
330
|
+
print(f"✗ Project '{args.slug}' not found", file=sys.stderr)
|
|
331
|
+
sys.exit(1)
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def cmd_project_manages_changelog(args):
|
|
335
|
+
"""Hook query — exit 0 if the project opts into dct changelog enforcement,
|
|
336
|
+
else exit 1. Silent by design (the changelog hook reads only the exit code).
|
|
337
|
+
"""
|
|
338
|
+
from dct.core.projects import project_manages_changelog
|
|
339
|
+
|
|
340
|
+
engine = _get_engine()
|
|
341
|
+
sys.exit(0 if project_manages_changelog(engine, args.slug) else 1)
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def cmd_project_changelog_path(args):
|
|
345
|
+
"""Get or set a project's changelog file location, relative to its root (#225).
|
|
346
|
+
|
|
347
|
+
With a relpath → set it (validated to stay within the project root). Without
|
|
348
|
+
→ print the current relpath and its resolved absolute path.
|
|
349
|
+
"""
|
|
350
|
+
from dct.core.projects import get_project, resolve_changelog_path, set_changelog_path
|
|
351
|
+
|
|
352
|
+
engine = _get_engine()
|
|
353
|
+
|
|
354
|
+
if args.relpath is None:
|
|
355
|
+
project = get_project(engine, args.slug)
|
|
356
|
+
if not project:
|
|
357
|
+
print(f"✗ Project '{args.slug}' not found", file=sys.stderr)
|
|
358
|
+
sys.exit(1)
|
|
359
|
+
rel = project.get("changelog_path") or "changelog.md"
|
|
360
|
+
print(f"{args.slug}: {rel}")
|
|
361
|
+
resolved = resolve_changelog_path(project)
|
|
362
|
+
if resolved:
|
|
363
|
+
print(f" → {resolved}")
|
|
364
|
+
return
|
|
365
|
+
|
|
366
|
+
try:
|
|
367
|
+
project = set_changelog_path(engine, args.slug, args.relpath)
|
|
368
|
+
except ValueError as e:
|
|
369
|
+
print(f"✗ {e}", file=sys.stderr)
|
|
370
|
+
sys.exit(1)
|
|
371
|
+
if not project:
|
|
372
|
+
print(f"✗ Project '{args.slug}' not found", file=sys.stderr)
|
|
373
|
+
sys.exit(1)
|
|
374
|
+
print(f"✓ {args.slug}: changelog-path → {project['changelog_path']}")
|
|
375
|
+
resolved = resolve_changelog_path(project)
|
|
376
|
+
if resolved:
|
|
377
|
+
print(f" → {resolved}")
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
# ── Plan rescan (v0.2.0) ─────────────────────────────────────────────────────
|
|
381
|
+
|
|
382
|
+
def cmd_plan_rescan(args):
|
|
383
|
+
"""Re-scan a plan markdown file — hook entrypoint and manual CLI.
|
|
384
|
+
|
|
385
|
+
Idempotent: hash short-circuits unchanged files. Brand-new files (no
|
|
386
|
+
plans row yet) get the full first-import treatment including ULID
|
|
387
|
+
injection + [x] state reading (§9.2).
|
|
388
|
+
"""
|
|
389
|
+
import os
|
|
390
|
+
from dct.core.plans.ingest import ingest_file
|
|
391
|
+
from dct.core.projects import get_project_by_path
|
|
392
|
+
|
|
393
|
+
engine = _get_engine()
|
|
394
|
+
path = os.path.abspath(args.file)
|
|
395
|
+
project = get_project_by_path(engine, path)
|
|
396
|
+
if project is None:
|
|
397
|
+
if not args.quiet:
|
|
398
|
+
print(f"✗ No registered project owns {path}", file=sys.stderr)
|
|
399
|
+
sys.exit(0) # graceful — hook must not block user action
|
|
400
|
+
|
|
401
|
+
try:
|
|
402
|
+
report = ingest_file(
|
|
403
|
+
engine, path,
|
|
404
|
+
project_slug=project["slug"],
|
|
405
|
+
auto_resolve_uncertain=args.resolve_uncertain,
|
|
406
|
+
)
|
|
407
|
+
except Exception as e:
|
|
408
|
+
if not args.quiet:
|
|
409
|
+
print(f"✗ rescan failed: {e}", file=sys.stderr)
|
|
410
|
+
sys.exit(0) # graceful — never block the user
|
|
411
|
+
|
|
412
|
+
if not args.quiet:
|
|
413
|
+
print(
|
|
414
|
+
f"✓ {path} — plan #{report['plan_id']}: "
|
|
415
|
+
f"{report['sections_created']} new sections, "
|
|
416
|
+
f"{report['checkpoints_created']} new checkpoints, "
|
|
417
|
+
f"{report['uncertain_remaining']} uncertain"
|
|
418
|
+
)
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
def cmd_project_list(args):
|
|
422
|
+
from dct.core.projects import list_projects
|
|
423
|
+
|
|
424
|
+
engine = _get_engine()
|
|
425
|
+
projects = list_projects(engine)
|
|
426
|
+
headers = ["Slug", "Version", "Name", "Path"]
|
|
427
|
+
rows = [[p["slug"], p.get("current_version") or "—", p["name"], p.get("path") or ""] for p in projects]
|
|
428
|
+
_print_table(headers, rows)
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
def cmd_project_remove(args):
|
|
432
|
+
from dct.core.projects import delete_project
|
|
433
|
+
|
|
434
|
+
engine = _get_engine()
|
|
435
|
+
if delete_project(engine, args.slug):
|
|
436
|
+
print(f"✓ Project '{args.slug}' removed (soft delete)")
|
|
437
|
+
else:
|
|
438
|
+
print(f"✗ Project '{args.slug}' not found", file=sys.stderr)
|
|
439
|
+
sys.exit(1)
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
# ── Item commands ─────────────────────────────────────────────────────────────
|
|
443
|
+
|
|
444
|
+
def cmd_add(args):
|
|
445
|
+
from dct.core.items import create_item
|
|
446
|
+
|
|
447
|
+
engine = _get_engine()
|
|
448
|
+
kwargs = {}
|
|
449
|
+
if args.priority:
|
|
450
|
+
kwargs["priority"] = args.priority
|
|
451
|
+
if args.component:
|
|
452
|
+
kwargs["component"] = args.component
|
|
453
|
+
if args.tags:
|
|
454
|
+
# Shell passes `--tags "a,b,c"` as a string; core expects list[str].
|
|
455
|
+
# Normalize at the CLI boundary so core stays pure (see #31).
|
|
456
|
+
kwargs["tags"] = [t.strip() for t in args.tags.split(",") if t.strip()]
|
|
457
|
+
if args.description:
|
|
458
|
+
kwargs["description"] = args.description
|
|
459
|
+
if args.source_file:
|
|
460
|
+
kwargs["source_file"] = args.source_file
|
|
461
|
+
|
|
462
|
+
try:
|
|
463
|
+
item = create_item(engine, args.project, args.type, args.title, **kwargs)
|
|
464
|
+
print(f"✓ #{item['id']} {item['item_type']}/{item['priority']} — {item['title']}")
|
|
465
|
+
except Exception as e:
|
|
466
|
+
print(f"✗ Error: {e}", file=sys.stderr)
|
|
467
|
+
sys.exit(1)
|
|
468
|
+
|
|
469
|
+
|
|
470
|
+
def cmd_list(args):
|
|
471
|
+
from dct.core.items import list_items
|
|
472
|
+
|
|
473
|
+
engine = _get_engine()
|
|
474
|
+
kwargs = {}
|
|
475
|
+
if args.project:
|
|
476
|
+
kwargs["project_slug"] = args.project
|
|
477
|
+
if args.status:
|
|
478
|
+
kwargs["status"] = args.status
|
|
479
|
+
if args.type:
|
|
480
|
+
kwargs["item_type"] = args.type
|
|
481
|
+
if args.priority:
|
|
482
|
+
kwargs["priority"] = args.priority
|
|
483
|
+
if args.search:
|
|
484
|
+
kwargs["search"] = args.search
|
|
485
|
+
|
|
486
|
+
items = list_items(engine, **kwargs)
|
|
487
|
+
headers = ["#", "Project", "Type", "Pri", "Status", "Title"]
|
|
488
|
+
|
|
489
|
+
# Build project_id → slug cache
|
|
490
|
+
from dct.core.projects import list_projects
|
|
491
|
+
slug_map = {p["id"]: p["slug"] for p in list_projects(engine)}
|
|
492
|
+
|
|
493
|
+
rows = []
|
|
494
|
+
for i in items:
|
|
495
|
+
slug = slug_map.get(i.get("project_id"), "?")
|
|
496
|
+
rows.append([str(i["id"]), slug, i["item_type"], i["priority"], i["status"], i["title"][:60]])
|
|
497
|
+
|
|
498
|
+
_print_table(headers, rows, max_widths={5: 60})
|
|
499
|
+
|
|
500
|
+
|
|
501
|
+
def cmd_show(args):
|
|
502
|
+
from dct.core.items import get_item
|
|
503
|
+
|
|
504
|
+
engine = _get_engine()
|
|
505
|
+
item = get_item(engine, args.id)
|
|
506
|
+
if not item:
|
|
507
|
+
print(f"✗ Item #{args.id} not found", file=sys.stderr)
|
|
508
|
+
sys.exit(1)
|
|
509
|
+
|
|
510
|
+
print(f"#{item['id']} {item['item_type']}/{item['priority']} [{item['status']}]")
|
|
511
|
+
print(f"Title: {item['title']}")
|
|
512
|
+
if item.get("description"):
|
|
513
|
+
print(f"Description: {item['description']}")
|
|
514
|
+
if item.get("component"):
|
|
515
|
+
print(f"Component: {item['component']}")
|
|
516
|
+
if item.get("source_file"):
|
|
517
|
+
loc = item["source_file"]
|
|
518
|
+
if item.get("source_lines"):
|
|
519
|
+
loc += f":{item['source_lines']}"
|
|
520
|
+
print(f"Location: {loc}")
|
|
521
|
+
if item.get("tags"):
|
|
522
|
+
print(f"Tags: {', '.join(item['tags'])}")
|
|
523
|
+
if item.get("resolution"):
|
|
524
|
+
print(f"Resolution: {item['resolution']}")
|
|
525
|
+
print(f"Created: {_fmt_date(item['created_at'])}")
|
|
526
|
+
if item.get("resolved_at"):
|
|
527
|
+
print(f"Resolved: {_fmt_date(item['resolved_at'])}")
|
|
528
|
+
|
|
529
|
+
if item.get("notes"):
|
|
530
|
+
print(f"\n── Notes ({len(item['notes'])}) ──")
|
|
531
|
+
for n in item["notes"]:
|
|
532
|
+
print(f" [{_fmt_date(n['created_at'])}] {n['note']}")
|
|
533
|
+
|
|
534
|
+
|
|
535
|
+
def cmd_update(args):
|
|
536
|
+
from dct.core.items import update_item
|
|
537
|
+
|
|
538
|
+
engine = _get_engine()
|
|
539
|
+
kwargs = {}
|
|
540
|
+
if args.title:
|
|
541
|
+
kwargs["title"] = args.title
|
|
542
|
+
if args.status:
|
|
543
|
+
kwargs["status"] = args.status
|
|
544
|
+
if args.priority:
|
|
545
|
+
kwargs["priority"] = args.priority
|
|
546
|
+
if args.component:
|
|
547
|
+
kwargs["component"] = args.component
|
|
548
|
+
if args.resolution:
|
|
549
|
+
kwargs["resolution"] = args.resolution
|
|
550
|
+
|
|
551
|
+
try:
|
|
552
|
+
item = update_item(engine, args.id, **kwargs)
|
|
553
|
+
if item:
|
|
554
|
+
print(f"✓ #{item['id']} updated — {item['title']}")
|
|
555
|
+
else:
|
|
556
|
+
print(f"✗ Item #{args.id} not found", file=sys.stderr)
|
|
557
|
+
sys.exit(1)
|
|
558
|
+
except Exception as e:
|
|
559
|
+
print(f"✗ Error: {e}", file=sys.stderr)
|
|
560
|
+
sys.exit(1)
|
|
561
|
+
|
|
562
|
+
|
|
563
|
+
def cmd_resolve(args):
|
|
564
|
+
from dct.core.items import resolve_item
|
|
565
|
+
|
|
566
|
+
engine = _get_engine()
|
|
567
|
+
try:
|
|
568
|
+
item = resolve_item(engine, args.id, status=args.status, resolution=args.resolution)
|
|
569
|
+
if item:
|
|
570
|
+
print(f"✓ #{item['id']} → {item['status']}")
|
|
571
|
+
else:
|
|
572
|
+
print(f"✗ Item #{args.id} not found", file=sys.stderr)
|
|
573
|
+
sys.exit(1)
|
|
574
|
+
except Exception as e:
|
|
575
|
+
print(f"✗ Error: {e}", file=sys.stderr)
|
|
576
|
+
sys.exit(1)
|
|
577
|
+
|
|
578
|
+
|
|
579
|
+
def cmd_note(args):
|
|
580
|
+
from dct.core.items import add_note
|
|
581
|
+
|
|
582
|
+
engine = _get_engine()
|
|
583
|
+
try:
|
|
584
|
+
add_note(engine, args.id, args.note)
|
|
585
|
+
print(f"✓ Note added to #{args.id}")
|
|
586
|
+
except Exception as e:
|
|
587
|
+
print(f"✗ Error: {e}", file=sys.stderr)
|
|
588
|
+
sys.exit(1)
|
|
589
|
+
|
|
590
|
+
|
|
591
|
+
def cmd_delete(args):
|
|
592
|
+
from dct.core.items import delete_item
|
|
593
|
+
|
|
594
|
+
engine = _get_engine()
|
|
595
|
+
if delete_item(engine, args.id):
|
|
596
|
+
print(f"✓ #{args.id} soft-deleted")
|
|
597
|
+
else:
|
|
598
|
+
print(f"✗ Item #{args.id} not found", file=sys.stderr)
|
|
599
|
+
sys.exit(1)
|
|
600
|
+
|
|
601
|
+
|
|
602
|
+
# ── Handoff commands ──────────────────────────────────────────────────────────
|
|
603
|
+
|
|
604
|
+
def cmd_handoff_update(args):
|
|
605
|
+
from dct.core.handoff import update_handoff
|
|
606
|
+
|
|
607
|
+
engine = _get_engine()
|
|
608
|
+
|
|
609
|
+
def _read(text, path):
|
|
610
|
+
if path:
|
|
611
|
+
return sys.stdin.read() if path == "-" else Path(path).read_text()
|
|
612
|
+
return text
|
|
613
|
+
|
|
614
|
+
prompt = _read(args.prompt, args.prompt_file)
|
|
615
|
+
|
|
616
|
+
try:
|
|
617
|
+
result = update_handoff(
|
|
618
|
+
engine, args.id,
|
|
619
|
+
prompt=prompt or None,
|
|
620
|
+
scope=args.scope,
|
|
621
|
+
context_summary=args.context_summary,
|
|
622
|
+
)
|
|
623
|
+
if result is None:
|
|
624
|
+
print(f"✗ Handoff #{args.id} not found, consumed, or deleted", file=sys.stderr)
|
|
625
|
+
sys.exit(1)
|
|
626
|
+
print(f"✓ Handoff #{args.id} updated")
|
|
627
|
+
except Exception as e:
|
|
628
|
+
print(f"✗ Error: {e}", file=sys.stderr)
|
|
629
|
+
sys.exit(1)
|
|
630
|
+
|
|
631
|
+
|
|
632
|
+
# ── Changelog commands ────────────────────────────────────────────────────────
|
|
633
|
+
|
|
634
|
+
def cmd_clog_add(args):
|
|
635
|
+
from dct.core.changelog import add_entry
|
|
636
|
+
|
|
637
|
+
engine = _get_engine()
|
|
638
|
+
kwargs = {}
|
|
639
|
+
if args.component:
|
|
640
|
+
kwargs["component"] = args.component
|
|
641
|
+
if args.source_file:
|
|
642
|
+
kwargs["source_file"] = args.source_file
|
|
643
|
+
if args.item_id:
|
|
644
|
+
kwargs["related_item_id"] = args.item_id
|
|
645
|
+
|
|
646
|
+
try:
|
|
647
|
+
project_slug = args.project or _detect_project(engine)
|
|
648
|
+
e = add_entry(engine, project_slug, args.category, args.entry, **kwargs)
|
|
649
|
+
print(f"✓ Changelog #{e['id']} [{e['category']}] — {e['entry'][:60]}")
|
|
650
|
+
except Exception as e:
|
|
651
|
+
print(f"✗ Error: {e}", file=sys.stderr)
|
|
652
|
+
sys.exit(1)
|
|
653
|
+
|
|
654
|
+
|
|
655
|
+
def cmd_clog_list(args):
|
|
656
|
+
from dct.core.changelog import list_entries
|
|
657
|
+
|
|
658
|
+
engine = _get_engine()
|
|
659
|
+
project_slug = args.project or _detect_project(engine)
|
|
660
|
+
entries = list_entries(engine, project_slug, unreleased_only=args.unreleased)
|
|
661
|
+
headers = ["#", "Category", "Entry", "Version", "Created"]
|
|
662
|
+
rows = []
|
|
663
|
+
for e in entries:
|
|
664
|
+
rows.append([
|
|
665
|
+
str(e["id"]),
|
|
666
|
+
e["category"],
|
|
667
|
+
e["entry"][:50],
|
|
668
|
+
e["version"] or "(unreleased)",
|
|
669
|
+
_fmt_date(e["created_at"]),
|
|
670
|
+
])
|
|
671
|
+
_print_table(headers, rows, max_widths={2: 50})
|
|
672
|
+
|
|
673
|
+
|
|
674
|
+
def cmd_clog_count(args):
|
|
675
|
+
from dct.core.changelog import count_unreleased
|
|
676
|
+
|
|
677
|
+
engine = _get_engine()
|
|
678
|
+
project_slug = args.project or _detect_project(engine)
|
|
679
|
+
count = count_unreleased(engine, project_slug)
|
|
680
|
+
print(count)
|
|
681
|
+
|
|
682
|
+
|
|
683
|
+
def cmd_clog_release(args):
|
|
684
|
+
import subprocess
|
|
685
|
+
from dct.core.changelog import release_entries, count_unreleased
|
|
686
|
+
from dct.core.projects import get_project
|
|
687
|
+
from dct.core.version import bump
|
|
688
|
+
|
|
689
|
+
engine = _get_engine()
|
|
690
|
+
project_slug = args.project or _detect_project(engine)
|
|
691
|
+
|
|
692
|
+
version = args.version
|
|
693
|
+
if not version:
|
|
694
|
+
bump_type = args.bump
|
|
695
|
+
if not bump_type:
|
|
696
|
+
print("✗ Provide a version or --patch/--minor/--major", file=sys.stderr)
|
|
697
|
+
sys.exit(1)
|
|
698
|
+
project = get_project(engine, project_slug)
|
|
699
|
+
if not project:
|
|
700
|
+
print(f"✗ Project '{project_slug}' not found", file=sys.stderr)
|
|
701
|
+
sys.exit(1)
|
|
702
|
+
current = project.get("current_version")
|
|
703
|
+
if not current:
|
|
704
|
+
print(f"✗ No current version for '{project_slug}'. Use explicit version: dct clog release {project_slug} 0.1.0", file=sys.stderr)
|
|
705
|
+
sys.exit(1)
|
|
706
|
+
version = bump(current, bump_type)
|
|
707
|
+
print(f" {current} → {version} ({bump_type})")
|
|
708
|
+
|
|
709
|
+
unreleased = count_unreleased(engine, project_slug)
|
|
710
|
+
if unreleased == 0:
|
|
711
|
+
print(f"✗ No unreleased entries for '{project_slug}'", file=sys.stderr)
|
|
712
|
+
sys.exit(1)
|
|
713
|
+
|
|
714
|
+
count = release_entries(engine, project_slug, version)
|
|
715
|
+
print(f"✓ Released {count} entries as {version}")
|
|
716
|
+
|
|
717
|
+
project = get_project(engine, project_slug)
|
|
718
|
+
if project and project.get("path"):
|
|
719
|
+
import os
|
|
720
|
+
for sync_path in ["scripts/dct-version-sync.sh", "scripts/version-sync.sh"]:
|
|
721
|
+
full_path = os.path.join(project["path"], sync_path)
|
|
722
|
+
if os.path.isfile(full_path):
|
|
723
|
+
print(f" Running {sync_path}...")
|
|
724
|
+
subprocess.run(["bash", full_path, version], check=False)
|
|
725
|
+
break
|
|
726
|
+
|
|
727
|
+
|
|
728
|
+
def cmd_clog_export(args):
|
|
729
|
+
from dct.export import export_changelog_md
|
|
730
|
+
|
|
731
|
+
engine = _get_engine()
|
|
732
|
+
project_slug = args.project or _detect_project(engine)
|
|
733
|
+
md = export_changelog_md(engine, project_slug)
|
|
734
|
+
if args.output:
|
|
735
|
+
with open(args.output, "w") as f:
|
|
736
|
+
f.write(md)
|
|
737
|
+
print(f"✓ Exported to {args.output}")
|
|
738
|
+
else:
|
|
739
|
+
print(md)
|
|
740
|
+
|
|
741
|
+
|
|
742
|
+
# ── Checkpoint commands ──────────────────────────────────────────────────────
|
|
743
|
+
|
|
744
|
+
def cmd_internal_task_mirror(args):
|
|
745
|
+
"""Internal: worker for hooks/dct-task-mirror.sh.
|
|
746
|
+
|
|
747
|
+
Reads a Claude Code hook JSON payload on stdin, extracts TaskCreate /
|
|
748
|
+
TodoWrite todos, find-or-creates a session-scoped item in dct, and
|
|
749
|
+
appends each todo as a `kind='cc-task'` checkpoint. Non-blocking —
|
|
750
|
+
safe to fail silently (hook is fire-and-forget).
|
|
751
|
+
|
|
752
|
+
Intentionally named with an underscore prefix — not user-facing, just
|
|
753
|
+
the target of the task-mirror hook. Re-expose via the `dct` entry point
|
|
754
|
+
so the venv-installed Python is used (hooks cannot reliably find it).
|
|
755
|
+
|
|
756
|
+
Args:
|
|
757
|
+
session-id: Claude Code session_id (from hook payload .session_id).
|
|
758
|
+
project: dct project slug. Empty → autodetect from CWD.
|
|
759
|
+
"""
|
|
760
|
+
import json
|
|
761
|
+
|
|
762
|
+
try:
|
|
763
|
+
payload = json.load(sys.stdin)
|
|
764
|
+
except json.JSONDecodeError:
|
|
765
|
+
return # fire-and-forget: swallow, exit clean
|
|
766
|
+
|
|
767
|
+
tool_input = payload.get("tool_input") or {}
|
|
768
|
+
todos: list[str] = []
|
|
769
|
+
if isinstance(tool_input.get("todos"), list):
|
|
770
|
+
todos = [
|
|
771
|
+
(t.get("content") or t.get("subject") or "")
|
|
772
|
+
for t in tool_input["todos"]
|
|
773
|
+
if isinstance(t, dict)
|
|
774
|
+
]
|
|
775
|
+
else:
|
|
776
|
+
single = (
|
|
777
|
+
tool_input.get("subject")
|
|
778
|
+
or tool_input.get("description")
|
|
779
|
+
or tool_input.get("prompt")
|
|
780
|
+
or ""
|
|
781
|
+
)
|
|
782
|
+
if single:
|
|
783
|
+
todos = [single]
|
|
784
|
+
|
|
785
|
+
todos = [t.strip() for t in todos if t and t.strip()]
|
|
786
|
+
if not todos:
|
|
787
|
+
return
|
|
788
|
+
|
|
789
|
+
from dct.core.items import list_items, create_item
|
|
790
|
+
from dct.core.checkpoints import add_checkpoint
|
|
791
|
+
|
|
792
|
+
engine = _get_engine()
|
|
793
|
+
|
|
794
|
+
project_slug = args.project or _detect_project(engine)
|
|
795
|
+
session_id = args.session_id or "unknown"
|
|
796
|
+
title = f"CC task list — session {session_id}"
|
|
797
|
+
|
|
798
|
+
existing = [
|
|
799
|
+
row
|
|
800
|
+
for row in list_items(
|
|
801
|
+
engine, project_slug=project_slug, search="CC task list",
|
|
802
|
+
status=None, limit=100,
|
|
803
|
+
)
|
|
804
|
+
if row.get("title") == title
|
|
805
|
+
]
|
|
806
|
+
if existing:
|
|
807
|
+
item_id = existing[0]["id"]
|
|
808
|
+
else:
|
|
809
|
+
item = create_item(
|
|
810
|
+
engine, project_slug, "todo", title,
|
|
811
|
+
tags=["cc-task-list", "auto"],
|
|
812
|
+
)
|
|
813
|
+
item_id = item["id"]
|
|
814
|
+
|
|
815
|
+
for t in todos:
|
|
816
|
+
add_checkpoint(engine, item_id, f"[cc] {t}", kind="cc-task")
|
|
817
|
+
|
|
818
|
+
print(f"mirror ok: item #{item_id}, {len(todos)} checkpoint(s) added")
|
|
819
|
+
|
|
820
|
+
|
|
821
|
+
def cmd_config_get(args):
|
|
822
|
+
"""Read a dotted config key — e.g. `dct config get mirror.task_to_dct`.
|
|
823
|
+
|
|
824
|
+
Supports: database.url, dct_root, plans.<field>, mirror.<field>.
|
|
825
|
+
Prints the value (or empty string for unset). Exit 1 on unknown key.
|
|
826
|
+
"""
|
|
827
|
+
from dct.config import load_config
|
|
828
|
+
|
|
829
|
+
cfg = load_config()
|
|
830
|
+
key = args.key.strip()
|
|
831
|
+
|
|
832
|
+
# Flat keys first
|
|
833
|
+
if key == "database.url":
|
|
834
|
+
print(cfg.database_url)
|
|
835
|
+
return
|
|
836
|
+
if key == "dct_root":
|
|
837
|
+
print(cfg.dct_root)
|
|
838
|
+
return
|
|
839
|
+
|
|
840
|
+
# Nested dataclass access
|
|
841
|
+
section, _, field_name = key.partition(".")
|
|
842
|
+
if not field_name:
|
|
843
|
+
print(f"✗ Unknown config key: '{key}'", file=sys.stderr)
|
|
844
|
+
sys.exit(1)
|
|
845
|
+
|
|
846
|
+
obj = getattr(cfg, section, None)
|
|
847
|
+
if obj is None or not hasattr(obj, field_name):
|
|
848
|
+
print(f"✗ Unknown config key: '{key}'", file=sys.stderr)
|
|
849
|
+
sys.exit(1)
|
|
850
|
+
|
|
851
|
+
value = getattr(obj, field_name)
|
|
852
|
+
if isinstance(value, bool):
|
|
853
|
+
print(str(value).lower())
|
|
854
|
+
else:
|
|
855
|
+
print(value)
|
|
856
|
+
|
|
857
|
+
|
|
858
|
+
def cmd_checkpoint_add(args):
|
|
859
|
+
from dct.core.checkpoints import add_checkpoint
|
|
860
|
+
|
|
861
|
+
engine = _get_engine()
|
|
862
|
+
try:
|
|
863
|
+
cp = add_checkpoint(engine, args.item_id, args.text, kind=args.kind)
|
|
864
|
+
kind_tag = "" if cp.get("kind", "task") == "task" else f" [{cp['kind']}]"
|
|
865
|
+
print(f"✓ Checkpoint #{cp['id']}{kind_tag} added to item #{args.item_id}")
|
|
866
|
+
except Exception as e:
|
|
867
|
+
print(f"✗ Error: {e}", file=sys.stderr)
|
|
868
|
+
sys.exit(1)
|
|
869
|
+
|
|
870
|
+
|
|
871
|
+
def cmd_checkpoint_list(args):
|
|
872
|
+
from dct.core.checkpoints import list_checkpoints, get_progress
|
|
873
|
+
|
|
874
|
+
engine = _get_engine()
|
|
875
|
+
cps = list_checkpoints(engine, args.item_id)
|
|
876
|
+
progress = get_progress(engine, args.item_id)
|
|
877
|
+
|
|
878
|
+
headers = ["#", "Status", "Text"]
|
|
879
|
+
icon = {"done": "✓", "pending": "·", "rejected": "✗"}
|
|
880
|
+
rows = [[str(c["id"]), f"{icon.get(c['status'], '?')} {c['status']}", c["text"][:80]] for c in cps]
|
|
881
|
+
_print_table(headers, rows, max_widths={2: 80})
|
|
882
|
+
print(f"\nProgress: {progress['done']}/{progress['active']} done ({progress['percent']}%), {progress['rejected']} rejected")
|
|
883
|
+
|
|
884
|
+
|
|
885
|
+
def cmd_checkpoint_done(args):
|
|
886
|
+
from dct.core.checkpoints import set_status
|
|
887
|
+
|
|
888
|
+
engine = _get_engine()
|
|
889
|
+
result = set_status(engine, args.checkpoint_id, "done")
|
|
890
|
+
if result:
|
|
891
|
+
print(f"✓ #{args.checkpoint_id} → done")
|
|
892
|
+
else:
|
|
893
|
+
print(f"✗ Checkpoint #{args.checkpoint_id} not found", file=sys.stderr)
|
|
894
|
+
sys.exit(1)
|
|
895
|
+
|
|
896
|
+
|
|
897
|
+
def cmd_checkpoint_reject(args):
|
|
898
|
+
from dct.core.checkpoints import set_status
|
|
899
|
+
|
|
900
|
+
engine = _get_engine()
|
|
901
|
+
result = set_status(engine, args.checkpoint_id, "rejected")
|
|
902
|
+
if result:
|
|
903
|
+
print(f"✗ #{args.checkpoint_id} → rejected")
|
|
904
|
+
else:
|
|
905
|
+
print(f"✗ Checkpoint #{args.checkpoint_id} not found", file=sys.stderr)
|
|
906
|
+
sys.exit(1)
|
|
907
|
+
|
|
908
|
+
|
|
909
|
+
def cmd_checkpoint_reopen(args):
|
|
910
|
+
from dct.core.checkpoints import set_status
|
|
911
|
+
|
|
912
|
+
engine = _get_engine()
|
|
913
|
+
result = set_status(engine, args.checkpoint_id, "pending")
|
|
914
|
+
if result:
|
|
915
|
+
print(f"↺ #{args.checkpoint_id} → pending")
|
|
916
|
+
else:
|
|
917
|
+
print(f"✗ Checkpoint #{args.checkpoint_id} not found", file=sys.stderr)
|
|
918
|
+
sys.exit(1)
|
|
919
|
+
|
|
920
|
+
|
|
921
|
+
def cmd_checkpoint_delete(args):
|
|
922
|
+
from dct.core.checkpoints import delete_checkpoint
|
|
923
|
+
|
|
924
|
+
engine = _get_engine()
|
|
925
|
+
if delete_checkpoint(engine, args.checkpoint_id):
|
|
926
|
+
print(f"✓ Checkpoint #{args.checkpoint_id} soft-deleted")
|
|
927
|
+
else:
|
|
928
|
+
print(f"✗ Checkpoint #{args.checkpoint_id} not found", file=sys.stderr)
|
|
929
|
+
sys.exit(1)
|
|
930
|
+
|
|
931
|
+
|
|
932
|
+
# ── Sprints ──────────────────────────────────────────────────────────────────
|
|
933
|
+
|
|
934
|
+
def cmd_sprint_list(args):
|
|
935
|
+
from dct.core.sprints import list_sprints
|
|
936
|
+
|
|
937
|
+
engine = _get_engine()
|
|
938
|
+
project = args.project or _detect_project(engine)
|
|
939
|
+
sprints = list_sprints(
|
|
940
|
+
engine, project_slug=project,
|
|
941
|
+
status=args.status or None, kind=args.kind or None,
|
|
942
|
+
)
|
|
943
|
+
headers = ["#", "Code", "Kind", "Status", "Started", "Ended", "Name"]
|
|
944
|
+
rows = [
|
|
945
|
+
[
|
|
946
|
+
str(s["id"]), s.get("code") or "", s["kind"], s["status"],
|
|
947
|
+
_fmt_date(s.get("started_at")), _fmt_date(s.get("ended_at")), s["name"],
|
|
948
|
+
]
|
|
949
|
+
for s in sprints
|
|
950
|
+
]
|
|
951
|
+
_print_table(headers, rows, max_widths={6: 50})
|
|
952
|
+
|
|
953
|
+
|
|
954
|
+
def cmd_sprint_show(args):
|
|
955
|
+
from dct.core.sprints import get_sprint
|
|
956
|
+
|
|
957
|
+
engine = _get_engine()
|
|
958
|
+
s = get_sprint(engine, args.id)
|
|
959
|
+
if not s:
|
|
960
|
+
print(f"✗ Sprint #{args.id} not found", file=sys.stderr)
|
|
961
|
+
sys.exit(1)
|
|
962
|
+
|
|
963
|
+
print(f"#{s['id']} {s['kind']}/{s['status']}")
|
|
964
|
+
print(f"Name: {s['name']}")
|
|
965
|
+
if s.get("code"):
|
|
966
|
+
print(f"Code: {s['code']}")
|
|
967
|
+
if s.get("goal"):
|
|
968
|
+
print(f"Goal: {s['goal']}")
|
|
969
|
+
print(f"Started: {_fmt_date(s.get('started_at'))}")
|
|
970
|
+
print(f"Ended: {_fmt_date(s.get('ended_at'))}")
|
|
971
|
+
print(f"Created: {_fmt_date(s['created_at'])}")
|
|
972
|
+
|
|
973
|
+
|
|
974
|
+
def cmd_sprint_create(args):
|
|
975
|
+
from dct.core.sprints import create_sprint
|
|
976
|
+
|
|
977
|
+
engine = _get_engine()
|
|
978
|
+
project = args.project or _detect_project(engine)
|
|
979
|
+
kwargs = {}
|
|
980
|
+
if args.code:
|
|
981
|
+
kwargs["code"] = args.code
|
|
982
|
+
if args.kind:
|
|
983
|
+
kwargs["kind"] = args.kind
|
|
984
|
+
if args.status:
|
|
985
|
+
kwargs["status"] = args.status
|
|
986
|
+
if args.goal:
|
|
987
|
+
kwargs["goal"] = args.goal
|
|
988
|
+
try:
|
|
989
|
+
s = create_sprint(engine, project, args.name, **kwargs)
|
|
990
|
+
print(f"✓ Sprint #{s['id']} [{s['status']}] — {s['name']}")
|
|
991
|
+
except Exception as e:
|
|
992
|
+
print(f"✗ Error: {e}", file=sys.stderr)
|
|
993
|
+
sys.exit(1)
|
|
994
|
+
|
|
995
|
+
|
|
996
|
+
def cmd_sprint_update(args):
|
|
997
|
+
"""Handles `sprint update` and the defer/activate/complete verb aliases.
|
|
998
|
+
|
|
999
|
+
The aliases set `_forced_status` via argparse defaults; the plain update
|
|
1000
|
+
subcommand carries the explicit --status/--name/... flags. getattr keeps
|
|
1001
|
+
both namespaces working through one code path."""
|
|
1002
|
+
from dct.core.sprints import update_sprint
|
|
1003
|
+
|
|
1004
|
+
engine = _get_engine()
|
|
1005
|
+
kwargs = {}
|
|
1006
|
+
status = getattr(args, "_forced_status", None) or getattr(args, "status", None)
|
|
1007
|
+
if status:
|
|
1008
|
+
kwargs["status"] = status
|
|
1009
|
+
if getattr(args, "name", None):
|
|
1010
|
+
kwargs["name"] = args.name
|
|
1011
|
+
if getattr(args, "goal", None):
|
|
1012
|
+
kwargs["goal"] = args.goal
|
|
1013
|
+
if getattr(args, "code", None):
|
|
1014
|
+
kwargs["code"] = args.code
|
|
1015
|
+
if getattr(args, "kind", None):
|
|
1016
|
+
kwargs["kind"] = args.kind
|
|
1017
|
+
try:
|
|
1018
|
+
s = update_sprint(engine, args.id, **kwargs)
|
|
1019
|
+
if s is None:
|
|
1020
|
+
print(f"✗ Sprint #{args.id} not found", file=sys.stderr)
|
|
1021
|
+
sys.exit(1)
|
|
1022
|
+
print(f"✓ Sprint #{s['id']} → {s['status']} — {s['name']}")
|
|
1023
|
+
except Exception as e:
|
|
1024
|
+
print(f"✗ Error: {e}", file=sys.stderr)
|
|
1025
|
+
sys.exit(1)
|
|
1026
|
+
|
|
1027
|
+
|
|
1028
|
+
# ── MCP server (stdio) ───────────────────────────────────────────────────────
|
|
1029
|
+
|
|
1030
|
+
def cmd_server(args):
|
|
1031
|
+
try:
|
|
1032
|
+
from dct.server import mcp
|
|
1033
|
+
except ImportError as e:
|
|
1034
|
+
print(
|
|
1035
|
+
f"MCP server requires extras. Install with: pip install 'dct[mcp]'\n (or reinstall: uv tool install --editable . --with 'mcp[cli]')\nOriginal error: {e}",
|
|
1036
|
+
file=sys.stderr,
|
|
1037
|
+
)
|
|
1038
|
+
sys.exit(1)
|
|
1039
|
+
mcp.run()
|
|
1040
|
+
|
|
1041
|
+
|
|
1042
|
+
# ── Project detect (for hooks) ───────────────────────────────────────────────
|
|
1043
|
+
|
|
1044
|
+
def cmd_project_detect(args):
|
|
1045
|
+
import os
|
|
1046
|
+
from dct.core.projects import get_project_by_path
|
|
1047
|
+
|
|
1048
|
+
engine = _get_engine()
|
|
1049
|
+
cwd = os.getcwd()
|
|
1050
|
+
project = get_project_by_path(engine, cwd)
|
|
1051
|
+
if project:
|
|
1052
|
+
print(project["slug"])
|
|
1053
|
+
else:
|
|
1054
|
+
sys.exit(1)
|
|
1055
|
+
|
|
1056
|
+
|
|
1057
|
+
# ── web daemon helpers ───────────────────────────────────────────────────────
|
|
1058
|
+
|
|
1059
|
+
def _port_free(port: int, host: str = "127.0.0.1") -> bool:
|
|
1060
|
+
"""True if `host:port` can be bound right now (no SO_REUSEADDR — we want a
|
|
1061
|
+
truthful 'is anyone holding this port' probe, not a tolerant one)."""
|
|
1062
|
+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
|
1063
|
+
try:
|
|
1064
|
+
s.bind((host, port))
|
|
1065
|
+
return True
|
|
1066
|
+
except OSError:
|
|
1067
|
+
return False
|
|
1068
|
+
|
|
1069
|
+
|
|
1070
|
+
def _find_free_port(preferred: int, host: str = "127.0.0.1") -> int:
|
|
1071
|
+
"""Return `preferred` if free, else the first free port scanning upward."""
|
|
1072
|
+
port = preferred
|
|
1073
|
+
while port < 65536:
|
|
1074
|
+
if _port_free(port, host):
|
|
1075
|
+
return port
|
|
1076
|
+
port += 1
|
|
1077
|
+
raise RuntimeError(f"no free port available at or above {preferred}")
|
|
1078
|
+
|
|
1079
|
+
|
|
1080
|
+
def _write_pidfile(pid: int, url: str, path: Path = WEB_PIDFILE) -> None:
|
|
1081
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
1082
|
+
path.write_text(f"{pid}\n{url}\n")
|
|
1083
|
+
|
|
1084
|
+
|
|
1085
|
+
def _detach_stdio(logfile: Path = WEB_LOGFILE) -> None:
|
|
1086
|
+
"""Detach a daemonized child from inherited stdio.
|
|
1087
|
+
|
|
1088
|
+
Without this the serving child keeps the parent's stdout open, so a caller
|
|
1089
|
+
doing ``$(dct web start)`` (or any pipe / CI capture) blocks forever waiting
|
|
1090
|
+
for EOF. Point stdin at /dev/null and stdout+stderr at a log file, then drop
|
|
1091
|
+
the inherited descriptors.
|
|
1092
|
+
"""
|
|
1093
|
+
sys.stdout.flush()
|
|
1094
|
+
sys.stderr.flush()
|
|
1095
|
+
null_fd = os.open(os.devnull, os.O_RDWR)
|
|
1096
|
+
out_fd = null_fd
|
|
1097
|
+
try:
|
|
1098
|
+
logfile.parent.mkdir(parents=True, exist_ok=True)
|
|
1099
|
+
out_fd = os.open(str(logfile), os.O_WRONLY | os.O_CREAT | os.O_APPEND, 0o644)
|
|
1100
|
+
except OSError:
|
|
1101
|
+
out_fd = null_fd # log path unwritable → discard output but still detach
|
|
1102
|
+
os.dup2(null_fd, 0)
|
|
1103
|
+
os.dup2(out_fd, 1)
|
|
1104
|
+
os.dup2(out_fd, 2)
|
|
1105
|
+
for fd in {null_fd, out_fd}:
|
|
1106
|
+
if fd > 2:
|
|
1107
|
+
os.close(fd)
|
|
1108
|
+
|
|
1109
|
+
|
|
1110
|
+
def _read_pidfile(path: Path = WEB_PIDFILE) -> "tuple[int, str] | None":
|
|
1111
|
+
if not path.exists():
|
|
1112
|
+
return None
|
|
1113
|
+
lines = path.read_text().splitlines()
|
|
1114
|
+
if not lines:
|
|
1115
|
+
return None
|
|
1116
|
+
try:
|
|
1117
|
+
pid = int(lines[0].strip())
|
|
1118
|
+
except ValueError:
|
|
1119
|
+
return None
|
|
1120
|
+
url = lines[1].strip() if len(lines) > 1 else ""
|
|
1121
|
+
return pid, url
|
|
1122
|
+
|
|
1123
|
+
|
|
1124
|
+
def _clear_pidfile(path: Path = WEB_PIDFILE) -> None:
|
|
1125
|
+
try:
|
|
1126
|
+
path.unlink()
|
|
1127
|
+
except FileNotFoundError:
|
|
1128
|
+
pass
|
|
1129
|
+
|
|
1130
|
+
|
|
1131
|
+
def _pid_alive(pid: int) -> bool:
|
|
1132
|
+
"""True if a process with `pid` exists (signal 0 is a no-op probe)."""
|
|
1133
|
+
try:
|
|
1134
|
+
os.kill(pid, 0)
|
|
1135
|
+
except ProcessLookupError:
|
|
1136
|
+
return False
|
|
1137
|
+
except PermissionError:
|
|
1138
|
+
return True # exists but owned by another user
|
|
1139
|
+
return True
|
|
1140
|
+
|
|
1141
|
+
|
|
1142
|
+
# ── web daemon commands ──────────────────────────────────────────────────────
|
|
1143
|
+
|
|
1144
|
+
def cmd_web_start(args):
|
|
1145
|
+
from dct.core.engine import bootstrap_engine
|
|
1146
|
+
from dct.web.app import create_app
|
|
1147
|
+
|
|
1148
|
+
cfg = load_config()
|
|
1149
|
+
web = cfg.web
|
|
1150
|
+
host = getattr(args, "host", None) or web.host
|
|
1151
|
+
preferred = getattr(args, "port", None) or web.port
|
|
1152
|
+
no_fallback = getattr(args, "no_fallback", False)
|
|
1153
|
+
open_browser = getattr(args, "open", False) or web.open_browser
|
|
1154
|
+
fallback = web.auto_fallback and not no_fallback
|
|
1155
|
+
|
|
1156
|
+
existing = _read_pidfile()
|
|
1157
|
+
if existing and _pid_alive(existing[0]):
|
|
1158
|
+
print(f"✗ dct web already running (pid {existing[0]}) → {existing[1]}", file=sys.stderr)
|
|
1159
|
+
sys.exit(1)
|
|
1160
|
+
if existing:
|
|
1161
|
+
_clear_pidfile() # stale pidfile from a dead process
|
|
1162
|
+
|
|
1163
|
+
if fallback:
|
|
1164
|
+
port = _find_free_port(preferred, host)
|
|
1165
|
+
else:
|
|
1166
|
+
if not _port_free(preferred, host):
|
|
1167
|
+
print(f"✗ Port {preferred} is busy and --no-fallback is set", file=sys.stderr)
|
|
1168
|
+
sys.exit(1)
|
|
1169
|
+
port = preferred
|
|
1170
|
+
|
|
1171
|
+
url = f"http://localhost:{port}"
|
|
1172
|
+
|
|
1173
|
+
# Daemonize: parent records the child pid + URL and returns; child detaches
|
|
1174
|
+
# from the controlling terminal and serves. No tty needed — safe under CC/CI.
|
|
1175
|
+
pid = os.fork()
|
|
1176
|
+
if pid > 0: # parent
|
|
1177
|
+
_write_pidfile(pid, url)
|
|
1178
|
+
print(f"✓ dct web started (pid {pid}) → {url}")
|
|
1179
|
+
if open_browser:
|
|
1180
|
+
import webbrowser
|
|
1181
|
+
webbrowser.open(url)
|
|
1182
|
+
return
|
|
1183
|
+
|
|
1184
|
+
# child: detach from the controlling terminal AND from inherited stdio so a
|
|
1185
|
+
# captured `$(dct web start)` does not block on the pipe the server keeps open.
|
|
1186
|
+
os.setsid()
|
|
1187
|
+
_detach_stdio()
|
|
1188
|
+
try:
|
|
1189
|
+
engine = bootstrap_engine(create=False) # READ-ONLY: never create_tables()
|
|
1190
|
+
app = create_app(engine, read_only=True)
|
|
1191
|
+
app.run(host=host, port=port, threaded=True, use_reloader=False)
|
|
1192
|
+
finally:
|
|
1193
|
+
os._exit(0)
|
|
1194
|
+
|
|
1195
|
+
|
|
1196
|
+
def cmd_web_stop(args):
|
|
1197
|
+
info = _read_pidfile()
|
|
1198
|
+
if not info:
|
|
1199
|
+
print("dct web: not running (no pidfile)")
|
|
1200
|
+
return
|
|
1201
|
+
pid, _url = info
|
|
1202
|
+
try:
|
|
1203
|
+
os.kill(pid, signal.SIGTERM)
|
|
1204
|
+
print(f"✓ Sent SIGTERM to dct web (pid {pid})")
|
|
1205
|
+
except ProcessLookupError:
|
|
1206
|
+
print(f" (pid {pid} not alive — clearing stale pidfile)")
|
|
1207
|
+
except PermissionError:
|
|
1208
|
+
print(f"✗ No permission to signal pid {pid}", file=sys.stderr)
|
|
1209
|
+
sys.exit(1)
|
|
1210
|
+
_clear_pidfile()
|
|
1211
|
+
|
|
1212
|
+
|
|
1213
|
+
def cmd_web_status(args):
|
|
1214
|
+
info = _read_pidfile()
|
|
1215
|
+
if not info:
|
|
1216
|
+
print("dct web: stopped")
|
|
1217
|
+
return
|
|
1218
|
+
pid, url = info
|
|
1219
|
+
if _pid_alive(pid):
|
|
1220
|
+
print(f"dct web: running (pid {pid}) → {url}")
|
|
1221
|
+
else:
|
|
1222
|
+
print(f"dct web: stale pidfile (pid {pid} not alive). Run `dct web stop` to clear.")
|
|
1223
|
+
|
|
1224
|
+
|
|
1225
|
+
# ── Argument parser ──────────────────────────────────────────────────────────
|
|
1226
|
+
|
|
1227
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
1228
|
+
parser = argparse.ArgumentParser(prog="dct", description="Deterministic Context Tracker — cross-project tracking")
|
|
1229
|
+
parser.add_argument("--version", action="version", version=f"dct {__version__}")
|
|
1230
|
+
|
|
1231
|
+
sub = parser.add_subparsers(dest="command")
|
|
1232
|
+
|
|
1233
|
+
# init
|
|
1234
|
+
p_init = sub.add_parser("init", help="Interactive setup")
|
|
1235
|
+
p_init.add_argument(
|
|
1236
|
+
"--wire-hooks", dest="wire_hooks", action="store_true", default=None,
|
|
1237
|
+
help="Wire dct hooks into ~/.claude/settings.json without prompting",
|
|
1238
|
+
)
|
|
1239
|
+
p_init.add_argument(
|
|
1240
|
+
"--no-wire-hooks", dest="wire_hooks", action="store_false",
|
|
1241
|
+
help="Skip settings.json hook wiring",
|
|
1242
|
+
)
|
|
1243
|
+
|
|
1244
|
+
# hooks
|
|
1245
|
+
p_hooks = sub.add_parser("hooks", help="Manage Claude Code hook wiring")
|
|
1246
|
+
p_hooks_sub = p_hooks.add_subparsers(dest="hooks_command")
|
|
1247
|
+
p_hooks_wire = p_hooks_sub.add_parser(
|
|
1248
|
+
"wire", help="Wire dct hooks into ~/.claude/settings.json (idempotent merge)"
|
|
1249
|
+
)
|
|
1250
|
+
p_hooks_wire.add_argument(
|
|
1251
|
+
"--settings", help="Path to settings.json (default: ~/.claude/settings.json)"
|
|
1252
|
+
)
|
|
1253
|
+
|
|
1254
|
+
# server (MCP stdio entry point)
|
|
1255
|
+
sub.add_parser("server", help="Start the MCP server (stdio transport)")
|
|
1256
|
+
|
|
1257
|
+
# project
|
|
1258
|
+
p_project = sub.add_parser("project", help="Manage projects")
|
|
1259
|
+
p_proj_sub = p_project.add_subparsers(dest="project_command")
|
|
1260
|
+
|
|
1261
|
+
p_proj_add = p_proj_sub.add_parser("add", help="Register a project")
|
|
1262
|
+
p_proj_add.add_argument("slug")
|
|
1263
|
+
p_proj_add.add_argument("path", nargs="?")
|
|
1264
|
+
p_proj_add.add_argument("--name")
|
|
1265
|
+
p_proj_add.add_argument("--version", "-v")
|
|
1266
|
+
|
|
1267
|
+
p_proj_sub.add_parser("list", help="List projects")
|
|
1268
|
+
|
|
1269
|
+
p_proj_rm = p_proj_sub.add_parser("remove", help="Remove a project (soft delete)")
|
|
1270
|
+
p_proj_rm.add_argument("slug")
|
|
1271
|
+
|
|
1272
|
+
p_proj_sub.add_parser("detect", help="Detect project from CWD")
|
|
1273
|
+
|
|
1274
|
+
p_proj_setv = p_proj_sub.add_parser("set-version", help="Set current_version for a project")
|
|
1275
|
+
p_proj_setv.add_argument("slug")
|
|
1276
|
+
p_proj_setv.add_argument("version")
|
|
1277
|
+
|
|
1278
|
+
p_proj_rename = p_proj_sub.add_parser(
|
|
1279
|
+
"rename",
|
|
1280
|
+
help="Rename a project (any of slug/name/description/path) — project_id stable, FKs preserved",
|
|
1281
|
+
)
|
|
1282
|
+
p_proj_rename.add_argument("slug", help="Current slug of the project to rename")
|
|
1283
|
+
p_proj_rename.add_argument("--slug", dest="new_slug", help="New slug (must be unique among live projects)")
|
|
1284
|
+
p_proj_rename.add_argument("--name", help="New display name")
|
|
1285
|
+
p_proj_rename.add_argument("--description", help="New description")
|
|
1286
|
+
p_proj_rename.add_argument("--path", help="New filesystem path (does not move files on disk)")
|
|
1287
|
+
|
|
1288
|
+
p_proj_clog = p_proj_sub.add_parser(
|
|
1289
|
+
"changelog", help="Opt a project in/out of dct changelog-hook enforcement (#222)",
|
|
1290
|
+
)
|
|
1291
|
+
p_proj_clog.add_argument("slug")
|
|
1292
|
+
p_proj_clog.add_argument("state", choices=["on", "off"])
|
|
1293
|
+
|
|
1294
|
+
p_proj_mc = p_proj_sub.add_parser(
|
|
1295
|
+
"manages-changelog",
|
|
1296
|
+
help="Exit 0 if the project opts into dct changelog enforcement, else 1 (hook query)",
|
|
1297
|
+
)
|
|
1298
|
+
p_proj_mc.add_argument("slug")
|
|
1299
|
+
|
|
1300
|
+
p_proj_clogpath = p_proj_sub.add_parser(
|
|
1301
|
+
"changelog-path",
|
|
1302
|
+
help="Get/set a project's changelog file location relative to its root (#225)",
|
|
1303
|
+
)
|
|
1304
|
+
p_proj_clogpath.add_argument("slug")
|
|
1305
|
+
p_proj_clogpath.add_argument(
|
|
1306
|
+
"relpath", nargs="?",
|
|
1307
|
+
help="Relative changelog path (e.g. pkg/changelog.md). Omit to query the current value.",
|
|
1308
|
+
)
|
|
1309
|
+
|
|
1310
|
+
# plan
|
|
1311
|
+
p_plan = sub.add_parser("plan", help="Plan operations (v0.2.0)")
|
|
1312
|
+
p_plan_sub = p_plan.add_subparsers(dest="plan_command")
|
|
1313
|
+
|
|
1314
|
+
p_plan_rescan = p_plan_sub.add_parser(
|
|
1315
|
+
"rescan",
|
|
1316
|
+
help="Re-scan a plan markdown file (hook entrypoint). Idempotent + quiet-safe.",
|
|
1317
|
+
)
|
|
1318
|
+
p_plan_rescan.add_argument("file")
|
|
1319
|
+
p_plan_rescan.add_argument("--quiet", "-q", action="store_true",
|
|
1320
|
+
help="Suppress all output on stdout/stderr")
|
|
1321
|
+
p_plan_rescan.add_argument("--resolve-uncertain", action="store_true",
|
|
1322
|
+
help="Call claude --print for any uncertain sections")
|
|
1323
|
+
|
|
1324
|
+
# add (item)
|
|
1325
|
+
p_add = sub.add_parser("add", help="Create a tracked item")
|
|
1326
|
+
p_add.add_argument("project")
|
|
1327
|
+
p_add.add_argument("type", choices=["issue", "todo", "feature", "idea", "improvement"])
|
|
1328
|
+
p_add.add_argument("title")
|
|
1329
|
+
p_add.add_argument("--priority", "-p", default="P2")
|
|
1330
|
+
p_add.add_argument("--component", "-c")
|
|
1331
|
+
p_add.add_argument("--tags", "-t")
|
|
1332
|
+
p_add.add_argument("--description", "-d")
|
|
1333
|
+
p_add.add_argument("--source-file", "-f")
|
|
1334
|
+
|
|
1335
|
+
# list (items)
|
|
1336
|
+
p_list = sub.add_parser("list", help="List items")
|
|
1337
|
+
p_list.add_argument("project", nargs="?")
|
|
1338
|
+
p_list.add_argument("--status", "-s")
|
|
1339
|
+
p_list.add_argument("--type", "-t")
|
|
1340
|
+
p_list.add_argument("--priority", "-p")
|
|
1341
|
+
p_list.add_argument("--search", "-q")
|
|
1342
|
+
|
|
1343
|
+
# show
|
|
1344
|
+
p_show = sub.add_parser("show", help="Show item details")
|
|
1345
|
+
p_show.add_argument("id", type=int)
|
|
1346
|
+
|
|
1347
|
+
# update
|
|
1348
|
+
p_update = sub.add_parser("update", help="Update an item")
|
|
1349
|
+
p_update.add_argument("id", type=int)
|
|
1350
|
+
p_update.add_argument("--title")
|
|
1351
|
+
p_update.add_argument("--status", "-s")
|
|
1352
|
+
p_update.add_argument("--priority", "-p")
|
|
1353
|
+
p_update.add_argument("--component", "-c")
|
|
1354
|
+
p_update.add_argument("--resolution")
|
|
1355
|
+
|
|
1356
|
+
# resolve
|
|
1357
|
+
p_resolve = sub.add_parser("resolve", help="Resolve/close an item")
|
|
1358
|
+
p_resolve.add_argument("id", type=int)
|
|
1359
|
+
p_resolve.add_argument("--status", "-s", default="resolved", choices=["resolved", "wont_fix"])
|
|
1360
|
+
p_resolve.add_argument("--resolution", "-r")
|
|
1361
|
+
|
|
1362
|
+
# note
|
|
1363
|
+
p_note = sub.add_parser("note", help="Add a note to an item")
|
|
1364
|
+
p_note.add_argument("id", type=int)
|
|
1365
|
+
p_note.add_argument("note")
|
|
1366
|
+
|
|
1367
|
+
# delete
|
|
1368
|
+
p_delete = sub.add_parser("delete", help="Soft-delete an item")
|
|
1369
|
+
p_delete.add_argument("id", type=int)
|
|
1370
|
+
|
|
1371
|
+
# clog (changelog)
|
|
1372
|
+
p_clog = sub.add_parser("clog", help="Changelog management")
|
|
1373
|
+
p_clog_sub = p_clog.add_subparsers(dest="clog_command")
|
|
1374
|
+
|
|
1375
|
+
p_clog_add = p_clog_sub.add_parser("add", help="Add changelog entry")
|
|
1376
|
+
p_clog_add.add_argument("category", choices=["added", "changed", "fixed", "removed"])
|
|
1377
|
+
p_clog_add.add_argument("entry")
|
|
1378
|
+
p_clog_add.add_argument("--project", "-P")
|
|
1379
|
+
p_clog_add.add_argument("--component", "-c")
|
|
1380
|
+
p_clog_add.add_argument("--source-file", "-f")
|
|
1381
|
+
p_clog_add.add_argument("--item-id", type=int)
|
|
1382
|
+
|
|
1383
|
+
p_clog_list = p_clog_sub.add_parser("list", help="List changelog entries")
|
|
1384
|
+
p_clog_list.add_argument("--project", "-P")
|
|
1385
|
+
p_clog_list.add_argument("--unreleased", "-u", action="store_true")
|
|
1386
|
+
|
|
1387
|
+
p_clog_count = p_clog_sub.add_parser("count", help="Count unreleased entries")
|
|
1388
|
+
p_clog_count.add_argument("--project", "-P")
|
|
1389
|
+
|
|
1390
|
+
p_clog_release = p_clog_sub.add_parser("release", help="Release: bump version and stamp changelog")
|
|
1391
|
+
p_clog_release.add_argument("version", nargs="?")
|
|
1392
|
+
p_clog_release.add_argument("--project", "-P")
|
|
1393
|
+
p_clog_bump = p_clog_release.add_mutually_exclusive_group()
|
|
1394
|
+
p_clog_bump.add_argument("--patch", dest="bump", action="store_const", const="patch")
|
|
1395
|
+
p_clog_bump.add_argument("--minor", dest="bump", action="store_const", const="minor")
|
|
1396
|
+
p_clog_bump.add_argument("--major", dest="bump", action="store_const", const="major")
|
|
1397
|
+
|
|
1398
|
+
p_clog_export = p_clog_sub.add_parser("export", help="Export to Keep a Changelog markdown")
|
|
1399
|
+
p_clog_export.add_argument("--project", "-P")
|
|
1400
|
+
p_clog_export.add_argument("--output", "-o")
|
|
1401
|
+
|
|
1402
|
+
# _task-mirror (internal: worker for hooks/dct-task-mirror.sh)
|
|
1403
|
+
p_tm = sub.add_parser(
|
|
1404
|
+
"_task-mirror",
|
|
1405
|
+
help="Internal — worker for dct-task-mirror.sh hook. Reads hook JSON on stdin.",
|
|
1406
|
+
)
|
|
1407
|
+
p_tm.add_argument("--session-id", default="")
|
|
1408
|
+
p_tm.add_argument("--project", default="")
|
|
1409
|
+
|
|
1410
|
+
# config
|
|
1411
|
+
p_config = sub.add_parser("config", help="Inspect dct configuration")
|
|
1412
|
+
p_config_sub = p_config.add_subparsers(dest="config_command")
|
|
1413
|
+
p_config_get = p_config_sub.add_parser("get", help="Read a dotted config key")
|
|
1414
|
+
p_config_get.add_argument("key", help="e.g. mirror.task_to_dct, plans.auto_resolve_uncertain")
|
|
1415
|
+
|
|
1416
|
+
# checkpoint
|
|
1417
|
+
p_cp = sub.add_parser("checkpoint", aliases=["cp"], help="Manage item checkpoints (acceptance criteria)")
|
|
1418
|
+
p_cp_sub = p_cp.add_subparsers(dest="checkpoint_command")
|
|
1419
|
+
|
|
1420
|
+
p_cp_add = p_cp_sub.add_parser("add", help="Add a checkpoint to an item")
|
|
1421
|
+
p_cp_add.add_argument("item_id", type=int)
|
|
1422
|
+
p_cp_add.add_argument("text")
|
|
1423
|
+
p_cp_add.add_argument(
|
|
1424
|
+
"--kind",
|
|
1425
|
+
default="task",
|
|
1426
|
+
help="Checkpoint kind: task (default, non-blocking) or a gate kind "
|
|
1427
|
+
"(dogfood/security/approval/review). Gates block resolve_item.",
|
|
1428
|
+
)
|
|
1429
|
+
|
|
1430
|
+
p_cp_list = p_cp_sub.add_parser("list", help="List checkpoints for an item with progress")
|
|
1431
|
+
p_cp_list.add_argument("item_id", type=int)
|
|
1432
|
+
|
|
1433
|
+
p_cp_done = p_cp_sub.add_parser("done", help="Mark checkpoint as done")
|
|
1434
|
+
p_cp_done.add_argument("checkpoint_id", type=int)
|
|
1435
|
+
|
|
1436
|
+
p_cp_reject = p_cp_sub.add_parser("reject", help="Mark checkpoint as rejected (scope change, N/A)")
|
|
1437
|
+
p_cp_reject.add_argument("checkpoint_id", type=int)
|
|
1438
|
+
|
|
1439
|
+
p_cp_reopen = p_cp_sub.add_parser("reopen", help="Revert checkpoint to pending")
|
|
1440
|
+
p_cp_reopen.add_argument("checkpoint_id", type=int)
|
|
1441
|
+
|
|
1442
|
+
p_cp_delete = p_cp_sub.add_parser("delete", help="Soft-delete a checkpoint (prefer reject for ledger)")
|
|
1443
|
+
p_cp_delete.add_argument("checkpoint_id", type=int)
|
|
1444
|
+
|
|
1445
|
+
# sprint (lifecycle: planned → active → [deferred] → completed/abandoned)
|
|
1446
|
+
p_sprint = sub.add_parser("sprint", help="Manage sprints")
|
|
1447
|
+
p_sprint_sub = p_sprint.add_subparsers(dest="sprint_command")
|
|
1448
|
+
|
|
1449
|
+
p_sp_list = p_sprint_sub.add_parser("list", help="List sprints")
|
|
1450
|
+
p_sp_list.add_argument("--project", help="Project slug (default: auto-detect from CWD)")
|
|
1451
|
+
p_sp_list.add_argument("--status", help="Filter by status "
|
|
1452
|
+
"(planned/active/deferred/completed/abandoned)")
|
|
1453
|
+
p_sp_list.add_argument("--kind", help="Filter by kind (sprint/phase/milestone/block)")
|
|
1454
|
+
|
|
1455
|
+
p_sp_show = p_sprint_sub.add_parser("show", help="Show sprint details")
|
|
1456
|
+
p_sp_show.add_argument("id", type=int)
|
|
1457
|
+
|
|
1458
|
+
p_sp_create = p_sprint_sub.add_parser("create", help="Create a sprint")
|
|
1459
|
+
p_sp_create.add_argument("name")
|
|
1460
|
+
p_sp_create.add_argument("--project", help="Project slug (default: auto-detect from CWD)")
|
|
1461
|
+
p_sp_create.add_argument("--code", help="Short handle, unique per project")
|
|
1462
|
+
p_sp_create.add_argument("--kind", help="sprint (default) / phase / milestone / block")
|
|
1463
|
+
p_sp_create.add_argument("--status", help="planned (default) / active / completed / abandoned "
|
|
1464
|
+
"('deferred' is not a valid creation status)")
|
|
1465
|
+
p_sp_create.add_argument("--goal", help="Free-text objective")
|
|
1466
|
+
|
|
1467
|
+
p_sp_update = p_sprint_sub.add_parser(
|
|
1468
|
+
"update", help="Update a sprint (status/name/goal/code/kind)"
|
|
1469
|
+
)
|
|
1470
|
+
p_sp_update.add_argument("id", type=int)
|
|
1471
|
+
p_sp_update.add_argument("--status", help="New status (transition whitelist enforced)")
|
|
1472
|
+
p_sp_update.add_argument("--name", help="New name")
|
|
1473
|
+
p_sp_update.add_argument("--goal", help="New goal")
|
|
1474
|
+
p_sp_update.add_argument("--code", help="New short handle (unique per project)")
|
|
1475
|
+
p_sp_update.add_argument("--kind", help="New kind")
|
|
1476
|
+
|
|
1477
|
+
_VERB_STATUS = {"defer": "deferred", "activate": "active", "complete": "completed"}
|
|
1478
|
+
for _verb, _st in _VERB_STATUS.items():
|
|
1479
|
+
p_verb = p_sprint_sub.add_parser(
|
|
1480
|
+
_verb, help=f"Shortcut for `sprint update <id> --status {_st}`"
|
|
1481
|
+
)
|
|
1482
|
+
p_verb.add_argument("id", type=int)
|
|
1483
|
+
p_verb.set_defaults(_forced_status=_st)
|
|
1484
|
+
|
|
1485
|
+
# web (read-only browser dashboard daemon — v0.4.1)
|
|
1486
|
+
p_handoff = sub.add_parser("handoff", help="Manage session handoffs")
|
|
1487
|
+
p_ho_sub = p_handoff.add_subparsers(dest="handoff_command")
|
|
1488
|
+
|
|
1489
|
+
p_ho_update = p_ho_sub.add_parser("update", help="Amend an unconsumed handoff in place")
|
|
1490
|
+
p_ho_update.add_argument("id", type=int, help="Handoff id")
|
|
1491
|
+
p_ho_update.add_argument("--prompt", default=None, help="New prompt text")
|
|
1492
|
+
p_ho_update.add_argument("--prompt-file", default=None, help="Read new prompt from file ('-' = stdin)")
|
|
1493
|
+
p_ho_update.add_argument("--scope", default=None, help="New scope tag")
|
|
1494
|
+
p_ho_update.add_argument("--context-summary", default=None, help="New context summary snapshot")
|
|
1495
|
+
|
|
1496
|
+
p_web = sub.add_parser("web", help="Read-only browser dashboard daemon")
|
|
1497
|
+
p_web_sub = p_web.add_subparsers(dest="web_command")
|
|
1498
|
+
|
|
1499
|
+
p_web_start = p_web_sub.add_parser("start", help="Start the dashboard daemon")
|
|
1500
|
+
p_web_start.add_argument("--port", type=int, help="Preferred port (overrides config)")
|
|
1501
|
+
p_web_start.add_argument("--host", help="Bind address (default 127.0.0.1)")
|
|
1502
|
+
p_web_start.add_argument("--open", action="store_true", help="Open the browser after start")
|
|
1503
|
+
p_web_start.add_argument("--no-fallback", action="store_true",
|
|
1504
|
+
help="Fail if the preferred port is busy instead of scanning upward")
|
|
1505
|
+
|
|
1506
|
+
p_web_sub.add_parser("stop", help="Stop the daemon (SIGTERM via pidfile)")
|
|
1507
|
+
p_web_sub.add_parser("status", help="Show daemon pid + URL")
|
|
1508
|
+
|
|
1509
|
+
return parser
|
|
1510
|
+
|
|
1511
|
+
|
|
1512
|
+
COMMAND_MAP = {
|
|
1513
|
+
"init": cmd_init,
|
|
1514
|
+
"server": cmd_server,
|
|
1515
|
+
"add": cmd_add,
|
|
1516
|
+
"list": cmd_list,
|
|
1517
|
+
"show": cmd_show,
|
|
1518
|
+
"update": cmd_update,
|
|
1519
|
+
"resolve": cmd_resolve,
|
|
1520
|
+
"note": cmd_note,
|
|
1521
|
+
"delete": cmd_delete,
|
|
1522
|
+
"_task-mirror": cmd_internal_task_mirror,
|
|
1523
|
+
}
|
|
1524
|
+
|
|
1525
|
+
HOOKS_COMMAND_MAP = {
|
|
1526
|
+
"wire": cmd_hooks_wire,
|
|
1527
|
+
}
|
|
1528
|
+
|
|
1529
|
+
PROJECT_COMMAND_MAP = {
|
|
1530
|
+
"add": cmd_project_add,
|
|
1531
|
+
"list": cmd_project_list,
|
|
1532
|
+
"remove": cmd_project_remove,
|
|
1533
|
+
"detect": cmd_project_detect,
|
|
1534
|
+
"set-version": cmd_project_set_version,
|
|
1535
|
+
"rename": cmd_project_rename,
|
|
1536
|
+
"changelog": cmd_project_changelog,
|
|
1537
|
+
"manages-changelog": cmd_project_manages_changelog,
|
|
1538
|
+
"changelog-path": cmd_project_changelog_path,
|
|
1539
|
+
}
|
|
1540
|
+
|
|
1541
|
+
PLAN_COMMAND_MAP = {
|
|
1542
|
+
"rescan": cmd_plan_rescan,
|
|
1543
|
+
}
|
|
1544
|
+
|
|
1545
|
+
CLOG_COMMAND_MAP = {
|
|
1546
|
+
"add": cmd_clog_add,
|
|
1547
|
+
"list": cmd_clog_list,
|
|
1548
|
+
"count": cmd_clog_count,
|
|
1549
|
+
"release": cmd_clog_release,
|
|
1550
|
+
"export": cmd_clog_export,
|
|
1551
|
+
}
|
|
1552
|
+
|
|
1553
|
+
CONFIG_COMMAND_MAP = {
|
|
1554
|
+
"get": cmd_config_get,
|
|
1555
|
+
}
|
|
1556
|
+
|
|
1557
|
+
WEB_COMMAND_MAP = {
|
|
1558
|
+
"start": cmd_web_start,
|
|
1559
|
+
"stop": cmd_web_stop,
|
|
1560
|
+
"status": cmd_web_status,
|
|
1561
|
+
}
|
|
1562
|
+
|
|
1563
|
+
CHECKPOINT_COMMAND_MAP = {
|
|
1564
|
+
"add": cmd_checkpoint_add,
|
|
1565
|
+
"list": cmd_checkpoint_list,
|
|
1566
|
+
"done": cmd_checkpoint_done,
|
|
1567
|
+
"reject": cmd_checkpoint_reject,
|
|
1568
|
+
"reopen": cmd_checkpoint_reopen,
|
|
1569
|
+
"delete": cmd_checkpoint_delete,
|
|
1570
|
+
}
|
|
1571
|
+
|
|
1572
|
+
HANDOFF_COMMAND_MAP = {
|
|
1573
|
+
"update": cmd_handoff_update,
|
|
1574
|
+
}
|
|
1575
|
+
|
|
1576
|
+
SPRINT_COMMAND_MAP = {
|
|
1577
|
+
"list": cmd_sprint_list,
|
|
1578
|
+
"show": cmd_sprint_show,
|
|
1579
|
+
"create": cmd_sprint_create,
|
|
1580
|
+
"update": cmd_sprint_update,
|
|
1581
|
+
# verb-sugar aliases route onto the same update path
|
|
1582
|
+
"defer": cmd_sprint_update,
|
|
1583
|
+
"activate": cmd_sprint_update,
|
|
1584
|
+
"complete": cmd_sprint_update,
|
|
1585
|
+
}
|
|
1586
|
+
|
|
1587
|
+
|
|
1588
|
+
def main():
|
|
1589
|
+
parser = build_parser()
|
|
1590
|
+
args = parser.parse_args()
|
|
1591
|
+
|
|
1592
|
+
if not args.command:
|
|
1593
|
+
parser.print_help()
|
|
1594
|
+
sys.exit(0)
|
|
1595
|
+
|
|
1596
|
+
if args.command == "project":
|
|
1597
|
+
handler = PROJECT_COMMAND_MAP.get(args.project_command)
|
|
1598
|
+
if not handler:
|
|
1599
|
+
parser.parse_args(["project", "--help"])
|
|
1600
|
+
return
|
|
1601
|
+
handler(args)
|
|
1602
|
+
elif args.command == "clog":
|
|
1603
|
+
handler = CLOG_COMMAND_MAP.get(args.clog_command)
|
|
1604
|
+
if not handler:
|
|
1605
|
+
parser.parse_args(["clog", "--help"])
|
|
1606
|
+
return
|
|
1607
|
+
handler(args)
|
|
1608
|
+
elif args.command in ("checkpoint", "cp"):
|
|
1609
|
+
handler = CHECKPOINT_COMMAND_MAP.get(args.checkpoint_command)
|
|
1610
|
+
if not handler:
|
|
1611
|
+
parser.parse_args(["checkpoint", "--help"])
|
|
1612
|
+
return
|
|
1613
|
+
handler(args)
|
|
1614
|
+
elif args.command == "handoff":
|
|
1615
|
+
handler = HANDOFF_COMMAND_MAP.get(args.handoff_command)
|
|
1616
|
+
if not handler:
|
|
1617
|
+
parser.parse_args(["handoff", "--help"])
|
|
1618
|
+
return
|
|
1619
|
+
handler(args)
|
|
1620
|
+
elif args.command == "sprint":
|
|
1621
|
+
handler = SPRINT_COMMAND_MAP.get(args.sprint_command)
|
|
1622
|
+
if not handler:
|
|
1623
|
+
parser.parse_args(["sprint", "--help"])
|
|
1624
|
+
return
|
|
1625
|
+
handler(args)
|
|
1626
|
+
elif args.command == "plan":
|
|
1627
|
+
handler = PLAN_COMMAND_MAP.get(args.plan_command)
|
|
1628
|
+
if not handler:
|
|
1629
|
+
parser.parse_args(["plan", "--help"])
|
|
1630
|
+
return
|
|
1631
|
+
handler(args)
|
|
1632
|
+
elif args.command == "config":
|
|
1633
|
+
handler = CONFIG_COMMAND_MAP.get(args.config_command)
|
|
1634
|
+
if not handler:
|
|
1635
|
+
parser.parse_args(["config", "--help"])
|
|
1636
|
+
return
|
|
1637
|
+
handler(args)
|
|
1638
|
+
elif args.command == "web":
|
|
1639
|
+
handler = WEB_COMMAND_MAP.get(args.web_command or "start")
|
|
1640
|
+
if not handler:
|
|
1641
|
+
parser.parse_args(["web", "--help"])
|
|
1642
|
+
return
|
|
1643
|
+
handler(args)
|
|
1644
|
+
elif args.command == "hooks":
|
|
1645
|
+
handler = HOOKS_COMMAND_MAP.get(args.hooks_command)
|
|
1646
|
+
if not handler:
|
|
1647
|
+
parser.parse_args(["hooks", "--help"])
|
|
1648
|
+
return
|
|
1649
|
+
handler(args)
|
|
1650
|
+
else:
|
|
1651
|
+
handler = COMMAND_MAP.get(args.command)
|
|
1652
|
+
if handler:
|
|
1653
|
+
handler(args)
|
|
1654
|
+
else:
|
|
1655
|
+
parser.print_help()
|
|
1656
|
+
|
|
1657
|
+
|
|
1658
|
+
if __name__ == "__main__":
|
|
1659
|
+
main()
|