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.
Files changed (87) hide show
  1. dct/__init__.py +6 -0
  2. dct/cli.py +1659 -0
  3. dct/config.py +150 -0
  4. dct/core/__init__.py +0 -0
  5. dct/core/analytics.py +382 -0
  6. dct/core/changelog.py +112 -0
  7. dct/core/checkpoints.py +205 -0
  8. dct/core/decisions.py +238 -0
  9. dct/core/engine.py +32 -0
  10. dct/core/handoff.py +151 -0
  11. dct/core/ids.py +25 -0
  12. dct/core/items.py +382 -0
  13. dct/core/plans/__init__.py +6 -0
  14. dct/core/plans/classify.py +94 -0
  15. dct/core/plans/crud.py +680 -0
  16. dct/core/plans/ingest.py +408 -0
  17. dct/core/plans/parser.py +312 -0
  18. dct/core/projects.py +298 -0
  19. dct/core/sprint.py +315 -0
  20. dct/core/sprints.py +601 -0
  21. dct/core/version.py +116 -0
  22. dct/db.py +558 -0
  23. dct/export.py +107 -0
  24. dct/hooks/check-changelog-on-stop.sh +49 -0
  25. dct/hooks/check-changelog.sh +143 -0
  26. dct/hooks/dct-plan-autoscan.sh +54 -0
  27. dct/hooks/dct-task-mirror.sh +62 -0
  28. dct/server.py +1812 -0
  29. dct/setup.py +258 -0
  30. dct/skills/clog/SKILL.md +73 -0
  31. dct/skills/commit/SKILL.md +153 -0
  32. dct/skills/dct-import/SKILL.md +329 -0
  33. dct/skills/dct-init/SKILL.md +289 -0
  34. dct/skills/handoff/SKILL.md +136 -0
  35. dct/skills/pickup/SKILL.md +115 -0
  36. dct/skills/plan-resolve-uncertain/SKILL.md +82 -0
  37. dct/skills/plan-status/SKILL.md +79 -0
  38. dct/skills/release/SKILL.md +281 -0
  39. dct/skills/track/SKILL.md +121 -0
  40. dct/skills/track-list/SKILL.md +134 -0
  41. dct/skills/track-resolve/SKILL.md +53 -0
  42. dct/skills/track-update/SKILL.md +109 -0
  43. dct/web/__init__.py +1 -0
  44. dct/web/app.py +83 -0
  45. dct/web/blueprints/__init__.py +5 -0
  46. dct/web/blueprints/analytics.py +34 -0
  47. dct/web/blueprints/changelog.py +40 -0
  48. dct/web/blueprints/decisions.py +23 -0
  49. dct/web/blueprints/events.py +69 -0
  50. dct/web/blueprints/handoffs.py +26 -0
  51. dct/web/blueprints/home.py +15 -0
  52. dct/web/blueprints/items.py +128 -0
  53. dct/web/blueprints/plans.py +53 -0
  54. dct/web/blueprints/projects.py +23 -0
  55. dct/web/blueprints/sprints.py +71 -0
  56. dct/web/changes.py +77 -0
  57. dct/web/serializers.py +109 -0
  58. dct/web/static/app.css +609 -0
  59. dct/web/static/app.js +62 -0
  60. dct/web/static/vendor/SOURCES.txt +10 -0
  61. dct/web/static/vendor/htmx.min.js +1 -0
  62. dct/web/static/vendor/uPlot.iife.min.js +2 -0
  63. dct/web/static/vendor/uPlot.min.css +1 -0
  64. dct/web/templates/analytics.html +63 -0
  65. dct/web/templates/base.html +106 -0
  66. dct/web/templates/changelog/list.html +23 -0
  67. dct/web/templates/decisions/list.html +22 -0
  68. dct/web/templates/handoffs/list.html +45 -0
  69. dct/web/templates/home.html +20 -0
  70. dct/web/templates/item_detail.html +91 -0
  71. dct/web/templates/items_list.html +44 -0
  72. dct/web/templates/partials/_bars.html +15 -0
  73. dct/web/templates/partials/_checkpoint_steps.html +22 -0
  74. dct/web/templates/partials/_items_table.html +23 -0
  75. dct/web/templates/partials/_plan_section.html +24 -0
  76. dct/web/templates/partials/_project_card.html +24 -0
  77. dct/web/templates/plans/detail.html +16 -0
  78. dct/web/templates/plans/list.html +15 -0
  79. dct/web/templates/project_home.html +51 -0
  80. dct/web/templates/sprints/detail.html +37 -0
  81. dct/web/templates/sprints/list.html +35 -0
  82. dctracker-1.0.0.dist-info/METADATA +357 -0
  83. dctracker-1.0.0.dist-info/RECORD +87 -0
  84. dctracker-1.0.0.dist-info/WHEEL +5 -0
  85. dctracker-1.0.0.dist-info/entry_points.txt +2 -0
  86. dctracker-1.0.0.dist-info/licenses/LICENSE +21 -0
  87. dctracker-1.0.0.dist-info/top_level.txt +1 -0
dct/core/items.py ADDED
@@ -0,0 +1,382 @@
1
+ """Item CRUD operations with soft deletes and validation."""
2
+
3
+ import json
4
+ from datetime import datetime, timezone
5
+
6
+ import sqlalchemy as sa
7
+
8
+ from dct.db import items, item_notes, item_checkpoints, item_sprints, sprints, row_to_dict
9
+ from dct.core.projects import resolve_project_id
10
+
11
+ VALID_TYPES = {"issue", "todo", "feature", "idea", "improvement"}
12
+ VALID_PRIORITIES = {"P1", "P2", "P3", "future"}
13
+ VALID_STATUSES = {"open", "in_progress", "resolved", "wont_fix"}
14
+ TERMINAL_STATUSES = {"resolved", "wont_fix"}
15
+ OPEN_STATUSES = {"open", "in_progress"}
16
+
17
+
18
+ def _validate_enum(value: str, valid: set[str], field_name: str) -> str:
19
+ if value not in valid:
20
+ raise ValueError(f"Invalid {field_name}: '{value}'. Valid: {', '.join(sorted(valid))}")
21
+ return value
22
+
23
+
24
+ def _serialize_tags(tags: list[str] | None) -> str:
25
+ """Serialize tags for DB storage. Accepts list[str] only — callers that
26
+ receive CSV/string input (CLI `--tags "a,b"`) MUST split into list before
27
+ calling core. Historical bug: accepting `str` here meant MCP clients that
28
+ couldn't send array types ended up passing the JSON-stringified list,
29
+ which was then naively split on `,` into mangled fragments. Fix lifts
30
+ normalization to the boundary (MCP = list[str] schema; CLI = split in
31
+ `cmd_add`); core stays pure.
32
+ """
33
+ if tags is None:
34
+ return "[]"
35
+ assert isinstance(tags, list), f"tags must be list[str], got {type(tags).__name__}"
36
+ return json.dumps([t for t in tags if t])
37
+
38
+
39
+ def _parse_tags(tags_str: str | None) -> list[str]:
40
+ if not tags_str:
41
+ return []
42
+ try:
43
+ return json.loads(tags_str)
44
+ except (json.JSONDecodeError, TypeError):
45
+ return []
46
+
47
+
48
+ def _current_sprint_exists():
49
+ """Correlated EXISTS: the item has an active item_sprints link to its
50
+ project's active sprint. This IS `in_current_sprint` (#216) — derived on
51
+ read, never stored. Use as a labeled column in SELECT or directly in WHERE."""
52
+ return (
53
+ sa.select(sa.literal(1))
54
+ .select_from(item_sprints.join(sprints, sprints.c.id == item_sprints.c.sprint_id))
55
+ .where(
56
+ item_sprints.c.item_id == items.c.id,
57
+ item_sprints.c.removed_at.is_(None),
58
+ sprints.c.status == "active",
59
+ sprints.c.deleted_at.is_(None),
60
+ )
61
+ .exists()
62
+ )
63
+
64
+
65
+ def _item_to_dict(row: sa.Row) -> dict:
66
+ d = row_to_dict(row)
67
+ d["tags"] = _parse_tags(d.get("tags"))
68
+ if "in_current_sprint" in d: # present only when the derived column was selected
69
+ d["in_current_sprint"] = bool(d["in_current_sprint"])
70
+ return d
71
+
72
+
73
+ def _item_with_cp_to_dict(row_mapping) -> dict:
74
+ """Convert an items row enriched with cp_done/cp_total aggregates into a dict
75
+ with a compact `cp: [done, total]` field replacing the two raw columns.
76
+ """
77
+ d = dict(row_mapping)
78
+ d["tags"] = _parse_tags(d.get("tags"))
79
+ if "in_current_sprint" in d:
80
+ d["in_current_sprint"] = bool(d["in_current_sprint"])
81
+ done = int(d.pop("cp_done", 0) or 0)
82
+ total = int(d.pop("cp_total", 0) or 0)
83
+ d["cp"] = [done, total]
84
+ return d
85
+
86
+
87
+ def create_item(
88
+ engine: sa.Engine,
89
+ project_slug: str,
90
+ item_type: str,
91
+ title: str,
92
+ description: str | None = None,
93
+ priority: str = "P2",
94
+ status: str = "open",
95
+ component: str | None = None,
96
+ source_file: str | None = None,
97
+ source_lines: str | None = None,
98
+ tags: list[str] | None = None,
99
+ ) -> dict:
100
+ if not title or not title.strip():
101
+ raise ValueError("Item title cannot be empty")
102
+ _validate_enum(item_type, VALID_TYPES, "item_type")
103
+ _validate_enum(priority, VALID_PRIORITIES, "priority")
104
+ _validate_enum(status, VALID_STATUSES, "status")
105
+
106
+ with engine.begin() as conn:
107
+ project_id = resolve_project_id(conn, project_slug)
108
+ row = conn.execute(
109
+ items.insert()
110
+ .values(
111
+ project_id=project_id,
112
+ item_type=item_type,
113
+ title=title,
114
+ description=description,
115
+ priority=priority,
116
+ status=status,
117
+ component=component,
118
+ source_file=source_file,
119
+ source_lines=source_lines,
120
+ tags=_serialize_tags(tags),
121
+ )
122
+ .returning(items)
123
+ ).first()
124
+ assert row is not None
125
+ d = _item_to_dict(row)
126
+ d["in_current_sprint"] = False # a brand-new item belongs to no sprint
127
+ return d
128
+
129
+
130
+ def list_items(
131
+ engine: sa.Engine,
132
+ project_slug: str | None = None,
133
+ status: str | None = None,
134
+ item_type: str | None = None,
135
+ priority: str | None = None,
136
+ component: str | None = None,
137
+ search: str | None = None,
138
+ in_current_sprint: bool | None = None,
139
+ limit: int = 50,
140
+ ) -> list[dict]:
141
+ # Priority ordering: P1 first, future last
142
+ priority_order = sa.case(
143
+ (items.c.priority == "P1", 1),
144
+ (items.c.priority == "P2", 2),
145
+ (items.c.priority == "P3", 3),
146
+ (items.c.priority == "future", 4),
147
+ else_=5,
148
+ )
149
+
150
+ # Checkpoint progress aggregated in a single query — avoids N+1.
151
+ # cp_done = count of done, cp_total = done + pending (rejected excluded from "real" total).
152
+ cp_done = sa.func.coalesce(
153
+ sa.func.sum(sa.case((item_checkpoints.c.status == "done", 1), else_=0)),
154
+ 0,
155
+ ).label("cp_done")
156
+ cp_total = sa.func.coalesce(
157
+ sa.func.sum(sa.case((item_checkpoints.c.status != "rejected", 1), else_=0)),
158
+ 0,
159
+ ).label("cp_total")
160
+
161
+ q = (
162
+ sa.select(items, cp_done, cp_total, _current_sprint_exists().label("in_current_sprint"))
163
+ .select_from(
164
+ items.outerjoin(
165
+ item_checkpoints,
166
+ sa.and_(
167
+ item_checkpoints.c.item_id == items.c.id,
168
+ item_checkpoints.c.deleted_at.is_(None),
169
+ ),
170
+ )
171
+ )
172
+ .where(items.c.deleted_at.is_(None))
173
+ .group_by(items.c.id)
174
+ .order_by(priority_order, items.c.created_at.desc())
175
+ )
176
+
177
+ if status:
178
+ q = q.where(items.c.status == status)
179
+ if item_type:
180
+ q = q.where(items.c.item_type == item_type)
181
+ if priority:
182
+ q = q.where(items.c.priority == priority)
183
+ if component:
184
+ q = q.where(items.c.component == component)
185
+ if in_current_sprint is not None:
186
+ exists_active = _current_sprint_exists()
187
+ q = q.where(exists_active if in_current_sprint else ~exists_active)
188
+ if search:
189
+ pattern = f"%{search}%"
190
+ q = q.where(sa.or_(items.c.title.ilike(pattern), items.c.description.ilike(pattern)))
191
+ if limit:
192
+ q = q.limit(limit)
193
+
194
+ with engine.begin() as conn:
195
+ if project_slug:
196
+ pid = resolve_project_id(conn, project_slug)
197
+ q = q.where(items.c.project_id == pid)
198
+ return [_item_with_cp_to_dict(r._mapping) for r in conn.execute(q)]
199
+
200
+
201
+ def get_item(engine: sa.Engine, item_id: int) -> dict | None:
202
+ from dct.db import item_checkpoints
203
+ from dct.core.checkpoints import get_progress
204
+
205
+ with engine.begin() as conn:
206
+ row = conn.execute(
207
+ sa.select(items, _current_sprint_exists().label("in_current_sprint"))
208
+ .where(items.c.id == item_id, items.c.deleted_at.is_(None))
209
+ ).first()
210
+ if not row:
211
+ return None
212
+ d = _item_to_dict(row)
213
+ notes = conn.execute(
214
+ sa.select(item_notes)
215
+ .where(item_notes.c.item_id == item_id, item_notes.c.deleted_at.is_(None))
216
+ .order_by(item_notes.c.created_at)
217
+ ).fetchall()
218
+ d["notes"] = [row_to_dict(n) for n in notes]
219
+
220
+ checkpoints = conn.execute(
221
+ sa.select(item_checkpoints)
222
+ .where(item_checkpoints.c.item_id == item_id, item_checkpoints.c.deleted_at.is_(None))
223
+ .order_by(item_checkpoints.c.sort_order, item_checkpoints.c.created_at)
224
+ ).fetchall()
225
+ d["checkpoints"] = [row_to_dict(c) for c in checkpoints]
226
+
227
+ d["progress"] = get_progress(engine, item_id)
228
+ return d
229
+
230
+
231
+ def update_item(engine: sa.Engine, item_id: int, **kwargs) -> dict | None:
232
+ # v0.2+ guard: direct writes to `in_current_sprint` are forbidden.
233
+ # The boolean is now derived from item_sprints; callers must use
234
+ # assign_item_to_sprint / remove_item_from_sprint instead. Internal
235
+ # auto-clear on terminal status transitions remains (see below).
236
+ if "in_current_sprint" in kwargs:
237
+ raise ValueError(
238
+ "items.in_current_sprint is deprecated in v0.2.0 — use "
239
+ "assign_item_to_sprint(item_id, sprint_id) or "
240
+ "remove_item_from_sprint(item_id, sprint_id) instead. "
241
+ "See docs/superpowers/specs/2026-04-17-plans-roadmaps-decisions-design.md §5.2."
242
+ )
243
+
244
+ allowed = {"title", "description", "priority", "status", "component", "source_file", "source_lines", "tags", "resolution", "item_type"}
245
+ values = {}
246
+ for k, v in kwargs.items():
247
+ if k not in allowed or v is None:
248
+ continue
249
+ if k == "priority":
250
+ _validate_enum(v, VALID_PRIORITIES, "priority")
251
+ elif k == "status":
252
+ _validate_enum(v, VALID_STATUSES, "status")
253
+ elif k == "item_type":
254
+ _validate_enum(v, VALID_TYPES, "item_type")
255
+ elif k == "tags":
256
+ v = _serialize_tags(v)
257
+ if v == "" and k in {"component", "source_file", "source_lines", "resolution", "description"}:
258
+ v = None
259
+ values[k] = v
260
+
261
+ if not values:
262
+ raise ValueError("No valid fields to update")
263
+
264
+ now = datetime.now(timezone.utc)
265
+ values["updated_at"] = now
266
+
267
+ # Invariant: terminal status ↔ resolved_at set, AND terminal status ⇒ the
268
+ # item leaves the current sprint. Membership lives in item_sprints now, so
269
+ # "leave the sprint" = soft-remove its active links (no boolean to flip).
270
+ leaving_sprint = False
271
+ if "status" in values:
272
+ if values["status"] in TERMINAL_STATUSES:
273
+ values.setdefault("resolved_at", now)
274
+ leaving_sprint = True
275
+ elif values["status"] in OPEN_STATUSES:
276
+ values["resolved_at"] = None
277
+
278
+ with engine.begin() as conn:
279
+ result = conn.execute(items.update().where(items.c.id == item_id, items.c.deleted_at.is_(None)).values(**values))
280
+ if result.rowcount == 0:
281
+ return None
282
+ if leaving_sprint:
283
+ conn.execute(
284
+ item_sprints.update()
285
+ .where(item_sprints.c.item_id == item_id, item_sprints.c.removed_at.is_(None))
286
+ .values(removed_at=now)
287
+ )
288
+
289
+ return get_item(engine, item_id)
290
+
291
+
292
+ def resolve_item(
293
+ engine: sa.Engine,
294
+ item_id: int,
295
+ status: str = "resolved",
296
+ resolution: str | None = None,
297
+ ) -> dict | None:
298
+ """Transition item to terminal status (resolved / wont_fix).
299
+
300
+ Gate check: if any pending gate checkpoint exists for this item
301
+ (kind not in {'task','cc-task'}, status='pending'), raises ValueError.
302
+ Gates must be explicitly completed (done) or rejected (not applicable).
303
+
304
+ Dogfood gate rationale: user-facing features (new skills, CLI commands,
305
+ MCP tools) carry a `kind='dogfood'` checkpoint as their last one — this
306
+ enforces that the feature was validated by the user in a fresh session,
307
+ not just self-reviewed by the implementer. See CLAUDE.md "Gate
308
+ checkpoints" section.
309
+ """
310
+ from dct.core.checkpoints import list_pending_gates
311
+
312
+ _validate_enum(status, {"resolved", "wont_fix"}, "resolve status")
313
+
314
+ pending_gates = list_pending_gates(engine, item_id)
315
+ if pending_gates:
316
+ gate_summary = ", ".join(f"#{g['id']} [{g['kind']}]" for g in pending_gates[:3])
317
+ more = f" (+{len(pending_gates) - 3} more)" if len(pending_gates) > 3 else ""
318
+ raise ValueError(
319
+ f"Cannot resolve item #{item_id}: {len(pending_gates)} gate checkpoint(s) "
320
+ f"pending — {gate_summary}{more}. Complete them (done) or reject them "
321
+ f"(not applicable) before resolving. See CLAUDE.md 'Gate checkpoints'."
322
+ )
323
+
324
+ now = datetime.now(timezone.utc)
325
+ values: dict = {
326
+ "status": status,
327
+ "resolved_at": now,
328
+ "updated_at": now,
329
+ }
330
+ if resolution:
331
+ values["resolution"] = resolution
332
+
333
+ with engine.begin() as conn:
334
+ result = conn.execute(items.update().where(items.c.id == item_id, items.c.deleted_at.is_(None)).values(**values))
335
+ if result.rowcount == 0:
336
+ return None
337
+ # Terminal status ⇒ leave the current sprint: soft-remove active links.
338
+ conn.execute(
339
+ item_sprints.update()
340
+ .where(item_sprints.c.item_id == item_id, item_sprints.c.removed_at.is_(None))
341
+ .values(removed_at=now)
342
+ )
343
+
344
+ return get_item(engine, item_id)
345
+
346
+
347
+ def add_note(engine: sa.Engine, item_id: int, note: str) -> dict:
348
+ if not note or not note.strip():
349
+ raise ValueError("Note cannot be empty")
350
+ with engine.begin() as conn:
351
+ # Verify item exists
352
+ row = conn.execute(
353
+ sa.select(items.c.id).where(items.c.id == item_id, items.c.deleted_at.is_(None))
354
+ ).first()
355
+ if not row:
356
+ raise ValueError(f"Item #{item_id} not found")
357
+
358
+ row = conn.execute(
359
+ item_notes.insert().values(item_id=item_id, note=note).returning(item_notes)
360
+ ).first()
361
+ assert row is not None
362
+ return row_to_dict(row)
363
+
364
+
365
+ def list_notes(engine: sa.Engine, item_id: int) -> list[dict]:
366
+ with engine.begin() as conn:
367
+ rows = conn.execute(
368
+ sa.select(item_notes)
369
+ .where(item_notes.c.item_id == item_id, item_notes.c.deleted_at.is_(None))
370
+ .order_by(item_notes.c.created_at)
371
+ ).fetchall()
372
+ return [row_to_dict(r) for r in rows]
373
+
374
+
375
+ def delete_item(engine: sa.Engine, item_id: int) -> bool:
376
+ with engine.begin() as conn:
377
+ result = conn.execute(
378
+ items.update()
379
+ .where(items.c.id == item_id, items.c.deleted_at.is_(None))
380
+ .values(deleted_at=datetime.now(timezone.utc))
381
+ )
382
+ return result.rowcount > 0
@@ -0,0 +1,6 @@
1
+ """dct plans subsystem: parser, classifier, ingest, rescan.
2
+
3
+ This is where the v0.2.0 plans/specs/roadmap/ADR handling lives. See the
4
+ spec at docs/superpowers/specs/2026-04-17-plans-roadmaps-decisions-design.md
5
+ and the implementation checkpoints on dct item #26.
6
+ """
@@ -0,0 +1,94 @@
1
+ """LLM classification of uncertain plan sections — headless Claude CLI wrapper.
2
+
3
+ We do NOT take an Anthropic SDK dependency; instead we delegate to whichever
4
+ `claude` binary the user already has in PATH (i.e. Claude Code's CLI). This
5
+ reuses the user's credentials, billing, and model configuration with zero
6
+ extra setup. Hidden cost: CC prepends its system prompt + CLAUDE.md, adding
7
+ a few k input tokens per call. Haiku at current prices makes this noise
8
+ ($0.001/classification).
9
+
10
+ If `claude` is not in PATH, the function returns None — callers treat that
11
+ as "leave the section uncertain; no classification possible".
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import shutil
17
+ import subprocess
18
+
19
+ VALID_SECTION_TYPES: frozenset[str] = frozenset({
20
+ "sprint", "phase", "task", "block", "milestone",
21
+ "decision", "prose", "changelog", "adr_section",
22
+ "reference", "generic",
23
+ })
24
+
25
+ DEFAULT_MODEL = "claude-haiku-4-5-20251001"
26
+ DEFAULT_TIMEOUT_SECONDS = 30
27
+
28
+
29
+ def _build_prompt(heading: str, parent_heading: str | None, preview: str) -> str:
30
+ """Assemble the classification prompt sent to `claude --print`."""
31
+ labels = ", ".join(sorted(VALID_SECTION_TYPES))
32
+ parts = [
33
+ "Classify this plan section.",
34
+ f"Heading: {heading!r}.",
35
+ ]
36
+ if parent_heading:
37
+ parts.append(f"Parent heading: {parent_heading!r}.")
38
+ if preview:
39
+ parts.append(f"First line: {preview!r}.")
40
+ parts.append(f"Respond with exactly one label from: {labels}.")
41
+ parts.append("Respond with just the label — no prose, no quotes, no markdown.")
42
+ return " ".join(parts)
43
+
44
+
45
+ def _sanitize_response(raw: str) -> str | None:
46
+ """Normalize model output and return a valid section_type or None.
47
+
48
+ Handles: whitespace, trailing newlines, wrapping quotes/backticks,
49
+ accidental multi-word output (we take the first word). Case-insensitive.
50
+ """
51
+ stripped = raw.strip().strip("`\"' ")
52
+ if not stripped:
53
+ return None
54
+ # First "word" — splits on any whitespace including newlines.
55
+ first = stripped.split()[0].strip("`\"',.")
56
+ label = first.lower()
57
+ return label if label in VALID_SECTION_TYPES else None
58
+
59
+
60
+ def classify_uncertain(
61
+ heading: str,
62
+ parent_heading: str | None = None,
63
+ preview: str = "",
64
+ model: str = DEFAULT_MODEL,
65
+ timeout_seconds: int = DEFAULT_TIMEOUT_SECONDS,
66
+ ) -> str | None:
67
+ """Classify an uncertain plan section via `claude --print` headless mode.
68
+
69
+ Returns a label from VALID_SECTION_TYPES, or None if:
70
+ - `claude` binary is not in PATH,
71
+ - the subprocess fails / times out / exits non-zero,
72
+ - the model response cannot be sanitized to a valid label.
73
+
74
+ Never raises — the caller treats None as "leave uncertain".
75
+ """
76
+ if not shutil.which("claude"):
77
+ return None
78
+
79
+ prompt = _build_prompt(heading, parent_heading, preview)
80
+ try:
81
+ result = subprocess.run(
82
+ ["claude", "--print", "--model", model, prompt],
83
+ capture_output=True,
84
+ text=True,
85
+ timeout=timeout_seconds,
86
+ check=False,
87
+ )
88
+ except (subprocess.TimeoutExpired, OSError):
89
+ return None
90
+
91
+ if result.returncode != 0:
92
+ return None
93
+
94
+ return _sanitize_response(result.stdout)