kc-cli 0.4.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 (65) hide show
  1. kc/__init__.py +5 -0
  2. kc/__main__.py +11 -0
  3. kc/artifacts/__init__.py +1 -0
  4. kc/artifacts/diff.py +76 -0
  5. kc/artifacts/frontmatter.py +26 -0
  6. kc/artifacts/markdown.py +116 -0
  7. kc/atomic_write.py +33 -0
  8. kc/cli.py +284 -0
  9. kc/commands/__init__.py +1 -0
  10. kc/commands/artifact.py +1190 -0
  11. kc/commands/citation.py +231 -0
  12. kc/commands/common.py +346 -0
  13. kc/commands/conformance.py +293 -0
  14. kc/commands/context.py +190 -0
  15. kc/commands/doctor.py +81 -0
  16. kc/commands/eval.py +133 -0
  17. kc/commands/export.py +97 -0
  18. kc/commands/guide.py +571 -0
  19. kc/commands/index.py +54 -0
  20. kc/commands/init.py +207 -0
  21. kc/commands/lint.py +238 -0
  22. kc/commands/source.py +464 -0
  23. kc/commands/status.py +52 -0
  24. kc/commands/task.py +260 -0
  25. kc/config.py +127 -0
  26. kc/embedding_models/potion-base-8M/README.md +97 -0
  27. kc/embedding_models/potion-base-8M/config.json +13 -0
  28. kc/embedding_models/potion-base-8M/model.safetensors +0 -0
  29. kc/embedding_models/potion-base-8M/modules.json +14 -0
  30. kc/embedding_models/potion-base-8M/tokenizer.json +1 -0
  31. kc/errors.py +141 -0
  32. kc/fingerprints.py +35 -0
  33. kc/ids.py +23 -0
  34. kc/locks.py +65 -0
  35. kc/models/__init__.py +17 -0
  36. kc/models/artifact.py +34 -0
  37. kc/models/citation.py +60 -0
  38. kc/models/context.py +23 -0
  39. kc/models/eval.py +21 -0
  40. kc/models/plan.py +37 -0
  41. kc/models/source.py +37 -0
  42. kc/models/source_range.py +29 -0
  43. kc/models/source_revision.py +19 -0
  44. kc/models/task.py +35 -0
  45. kc/output.py +838 -0
  46. kc/paths.py +126 -0
  47. kc/provenance/__init__.py +1 -0
  48. kc/provenance/citations.py +296 -0
  49. kc/search/__init__.py +1 -0
  50. kc/search/extract.py +268 -0
  51. kc/search/fts.py +284 -0
  52. kc/search/semantic.py +346 -0
  53. kc/store/__init__.py +1 -0
  54. kc/store/jsonl.py +55 -0
  55. kc/store/sqlite.py +444 -0
  56. kc/store/transaction.py +67 -0
  57. kc/templates/agents/skills/kc/SKILL.md +282 -0
  58. kc/templates/agents/skills/kc/agents/openai.yaml +5 -0
  59. kc/templates/agents/skills/kc/scripts/resolve_query_citations.py +134 -0
  60. kc/workspace.py +98 -0
  61. kc_cli-0.4.0.dist-info/METADATA +522 -0
  62. kc_cli-0.4.0.dist-info/RECORD +65 -0
  63. kc_cli-0.4.0.dist-info/WHEEL +4 -0
  64. kc_cli-0.4.0.dist-info/entry_points.txt +2 -0
  65. kc_cli-0.4.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,293 @@
1
+ """Read-only V1 CLI contract conformance checks."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Mapping
6
+ from typing import Any
7
+
8
+ import typer
9
+
10
+ from kc.commands.common import run
11
+ from kc.commands.guide import build_guide
12
+ from kc.errors import ERROR_EXIT_MAP, KcError
13
+ from kc.output import HUMAN_RENDERERS, emit_success, envelope
14
+
15
+ PUBLIC_COMMAND_IDS = frozenset(
16
+ {
17
+ "guide",
18
+ "init",
19
+ "status",
20
+ "source.add",
21
+ "source.inspect",
22
+ "source.refresh",
23
+ "source.search",
24
+ "index.build",
25
+ "context.prepare",
26
+ "artifact.new",
27
+ "artifact.validate",
28
+ "artifact.diff",
29
+ "artifact.apply",
30
+ "citation.check",
31
+ "citation.rewrite",
32
+ "citation.repair",
33
+ "lint",
34
+ "task.start",
35
+ "task.status",
36
+ "task.inspect",
37
+ "task.next",
38
+ "task.resume",
39
+ "eval.run",
40
+ "export",
41
+ "doctor",
42
+ "doctor.locks",
43
+ "conformance",
44
+ }
45
+ )
46
+
47
+ REQUIRED_GUIDE_SECTIONS = frozenset(
48
+ {
49
+ "name",
50
+ "version",
51
+ "description",
52
+ "schema_version",
53
+ "compatibility",
54
+ "capabilities",
55
+ "bootstrap",
56
+ "global_options",
57
+ "output_formats",
58
+ "environment",
59
+ "commands",
60
+ "schemas",
61
+ "citation_syntax",
62
+ "workflows",
63
+ "anti_patterns",
64
+ "quality_rubric",
65
+ "concurrency",
66
+ "error_codes",
67
+ "exit_codes",
68
+ "errors",
69
+ "examples",
70
+ }
71
+ )
72
+
73
+ REQUIRED_COMMAND_FIELDS = frozenset(
74
+ {
75
+ "command_id",
76
+ "mutates",
77
+ "confirmation",
78
+ "syntax",
79
+ "important_options",
80
+ "result_summary",
81
+ "examples",
82
+ "common_errors",
83
+ "exit_codes",
84
+ }
85
+ )
86
+
87
+ REQUIRED_ERROR_FIELDS = frozenset(
88
+ {
89
+ "code",
90
+ "category",
91
+ "message",
92
+ "exit_code",
93
+ "retryable",
94
+ "suggested_action",
95
+ "details",
96
+ }
97
+ )
98
+
99
+ REQUIRED_ENVELOPE_FIELDS = frozenset(
100
+ {
101
+ "schema_version",
102
+ "request_id",
103
+ "ok",
104
+ "command",
105
+ "target",
106
+ "result",
107
+ "warnings",
108
+ "errors",
109
+ "metrics",
110
+ }
111
+ )
112
+
113
+
114
+ def _row(check_id: str, passed: bool, message: str, details: Mapping[str, Any] | None = None) -> dict[str, Any]:
115
+ return {
116
+ "check_id": check_id,
117
+ "passed": passed,
118
+ "message": message,
119
+ "details": dict(details or {}),
120
+ }
121
+
122
+
123
+ def _summarize(rows: list[dict[str, Any]]) -> dict[str, int]:
124
+ passed = sum(1 for row in rows if row["passed"])
125
+ return {"total": len(rows), "passed": passed, "failed": len(rows) - passed}
126
+
127
+
128
+ def _guide_commands(guide: Mapping[str, Any]) -> dict[str, Mapping[str, Any]]:
129
+ commands = guide.get("commands", {})
130
+ if not isinstance(commands, dict):
131
+ return {}
132
+ return {str(command_id): command for command_id, command in commands.items() if isinstance(command, Mapping)}
133
+
134
+
135
+ def _check_guide_sections(guide: Mapping[str, Any]) -> dict[str, Any]:
136
+ missing = sorted(REQUIRED_GUIDE_SECTIONS - set(guide))
137
+ return _row(
138
+ "guide.required_sections",
139
+ not missing,
140
+ "Guide exposes all required V1 sections.",
141
+ {"missing": missing} if missing else {},
142
+ )
143
+
144
+
145
+ def _check_command_fields(commands: Mapping[str, Mapping[str, Any]]) -> dict[str, Any]:
146
+ failures = []
147
+ for command_id, command in sorted(commands.items()):
148
+ missing = sorted(REQUIRED_COMMAND_FIELDS - set(command))
149
+ command_id_mismatch = command.get("command_id") != command_id
150
+ if missing or command_id_mismatch:
151
+ failures.append(
152
+ {
153
+ "command_id": command_id,
154
+ "missing": missing,
155
+ "command_id_mismatch": command_id_mismatch,
156
+ }
157
+ )
158
+ return _row(
159
+ "guide.command_fields",
160
+ not failures,
161
+ "Every guide command has the required manifest fields.",
162
+ {"failures": failures} if failures else {},
163
+ )
164
+
165
+
166
+ def _check_public_commands(commands: Mapping[str, Mapping[str, Any]], public_command_ids: set[str]) -> dict[str, Any]:
167
+ guide_command_ids = set(commands)
168
+ missing = sorted(public_command_ids - guide_command_ids)
169
+ extra = sorted(guide_command_ids - public_command_ids)
170
+ return _row(
171
+ "guide.public_commands",
172
+ not missing and not extra,
173
+ "Guide command IDs match the public command set.",
174
+ {"missing": missing, "extra": extra} if missing or extra else {},
175
+ )
176
+
177
+
178
+ def _check_renderer_coverage(commands: Mapping[str, Mapping[str, Any]], human_renderers: Mapping[str, Any]) -> dict[str, Any]:
179
+ guide_command_ids = set(commands)
180
+ renderer_ids = set(human_renderers)
181
+ missing = sorted(guide_command_ids - renderer_ids)
182
+ extra = sorted(renderer_ids - guide_command_ids)
183
+ return _row(
184
+ "renderers.coverage",
185
+ not missing and not extra,
186
+ "Human renderer coverage matches guide commands.",
187
+ {"missing": missing, "extra": extra} if missing or extra else {},
188
+ )
189
+
190
+
191
+ def _check_error_contract(guide: Mapping[str, Any], commands: Mapping[str, Mapping[str, Any]]) -> dict[str, Any]:
192
+ guide_error_codes = guide.get("error_codes", {})
193
+ if not isinstance(guide_error_codes, Mapping):
194
+ guide_error_codes = {}
195
+
196
+ unknown_guide_errors = sorted(set(guide_error_codes) - set(ERROR_EXIT_MAP))
197
+ missing_guide_errors = sorted(set(ERROR_EXIT_MAP) - set(guide_error_codes))
198
+ exit_mismatches = []
199
+ for code, metadata in guide_error_codes.items():
200
+ if code not in ERROR_EXIT_MAP or not isinstance(metadata, Mapping):
201
+ continue
202
+ if metadata.get("exit_code") != ERROR_EXIT_MAP[code]:
203
+ exit_mismatches.append({"code": code, "guide_exit": metadata.get("exit_code"), "mapped_exit": ERROR_EXIT_MAP[code]})
204
+
205
+ unknown_common_errors = []
206
+ for command_id, command in sorted(commands.items()):
207
+ common_errors = command.get("common_errors", [])
208
+ if not isinstance(common_errors, list):
209
+ unknown_common_errors.append({"command_id": command_id, "error": "<common_errors-not-list>"})
210
+ continue
211
+ for code in common_errors:
212
+ if code not in ERROR_EXIT_MAP:
213
+ unknown_common_errors.append({"command_id": command_id, "error": code})
214
+
215
+ shape_keys = set(KcError(code="KC_CONFORMANCE_FAILED", message="shape probe").to_message())
216
+ shape_missing = sorted(REQUIRED_ERROR_FIELDS - shape_keys)
217
+
218
+ failures = {
219
+ "unknown_guide_errors": unknown_guide_errors,
220
+ "missing_guide_errors": missing_guide_errors,
221
+ "exit_mismatches": exit_mismatches,
222
+ "unknown_common_errors": unknown_common_errors,
223
+ "shape_missing": shape_missing,
224
+ }
225
+ failed = any(failures.values())
226
+ return _row(
227
+ "errors.contract",
228
+ not failed,
229
+ "Guide errors and common errors resolve to the stable KcError contract.",
230
+ failures if failed else {},
231
+ )
232
+
233
+
234
+ def _check_envelope_shape() -> dict[str, Any]:
235
+ payload = envelope("conformance.shape", {"valid": True})
236
+ missing = sorted(REQUIRED_ENVELOPE_FIELDS - set(payload))
237
+ metrics = payload.get("metrics", {})
238
+ missing_metrics = [] if isinstance(metrics, Mapping) and "duration_ms" in metrics else ["duration_ms"]
239
+ return _row(
240
+ "envelope.shape",
241
+ not missing and not missing_metrics,
242
+ "JSON envelope exposes the locked V1 fields.",
243
+ {"missing": missing, "missing_metrics": missing_metrics} if missing or missing_metrics else {},
244
+ )
245
+
246
+
247
+ def build_conformance_report(
248
+ *,
249
+ guide: Mapping[str, Any] | None = None,
250
+ human_renderers: Mapping[str, Any] | None = None,
251
+ public_command_ids: set[str] | None = None,
252
+ ) -> dict[str, Any]:
253
+ guide = build_guide() if guide is None else guide
254
+ human_renderers = HUMAN_RENDERERS if human_renderers is None else human_renderers
255
+ public_command_ids = set(PUBLIC_COMMAND_IDS if public_command_ids is None else public_command_ids)
256
+ commands = _guide_commands(guide)
257
+
258
+ rows = [
259
+ _check_guide_sections(guide),
260
+ _check_command_fields(commands),
261
+ _check_public_commands(commands, public_command_ids),
262
+ _check_renderer_coverage(commands, human_renderers),
263
+ _check_error_contract(guide, commands),
264
+ _check_envelope_shape(),
265
+ ]
266
+ summary = _summarize(rows)
267
+ return {
268
+ "profile": "v1",
269
+ "valid": summary["failed"] == 0,
270
+ "summary": summary,
271
+ "checks": rows,
272
+ }
273
+
274
+
275
+ def register(app: typer.Typer) -> None:
276
+ @app.command("conformance", help="Run read-only CLI contract conformance checks.")
277
+ def conformance() -> None:
278
+ def _run() -> None:
279
+ result = build_conformance_report()
280
+ if not result["valid"]:
281
+ failed_checks = [check for check in result["checks"] if not check["passed"]]
282
+ raise KcError(
283
+ code="KC_CONFORMANCE_FAILED",
284
+ message="V1 conformance checks failed.",
285
+ details={
286
+ "profile": result["profile"],
287
+ "summary": result["summary"],
288
+ "failed_checks": failed_checks,
289
+ },
290
+ )
291
+ emit_success("conformance", result)
292
+
293
+ run("conformance", _run)
kc/commands/context.py ADDED
@@ -0,0 +1,190 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+ from typing import Annotated
5
+
6
+ import typer
7
+
8
+ from kc.atomic_write import atomic_write_text
9
+ from kc.commands.common import (
10
+ json_dumps,
11
+ load_artifacts,
12
+ load_ranges,
13
+ load_sources,
14
+ now,
15
+ parse_named_ints,
16
+ run,
17
+ stale_source_warnings,
18
+ validate_choice,
19
+ )
20
+ from kc.config import load_config
21
+ from kc.ids import new_id
22
+ from kc.models.context import ContextPackRecord
23
+ from kc.output import emit_success, warning
24
+ from kc.paths import current_paths, repo_relative, resolve_repo_path
25
+ from kc.search.fts import ensure_index, search_ranges
26
+ from kc.store.transaction import mutation_transaction
27
+
28
+ app = typer.Typer(help="Prepare grounded source context for an external agent.")
29
+ ALLOWED_GROUNDING = {"required", "optional"}
30
+
31
+
32
+ def _parse_budget(raw: str | None) -> dict[str, int]:
33
+ return parse_named_ints(raw, option="--budget", defaults={"max_sources": 12, "max_ranges": 40})
34
+
35
+
36
+ @app.command("prepare", help="Search sources and emit evidence, policies, and next commands without answering.")
37
+ def prepare(
38
+ ask: Annotated[str, typer.Option("--ask", help="Knowledge task or question.")],
39
+ shape: Annotated[
40
+ str, typer.Option("--shape", help="Output shape requested from agent.")
41
+ ] = "knowledge_page",
42
+ domain: Annotated[str | None, typer.Option("--domain", help="Domain filter.")] = None,
43
+ target: Annotated[str | None, typer.Option("--target", help="Target artifact path.")] = None,
44
+ grounding: Annotated[
45
+ str, typer.Option("--grounding", help="Grounding policy: required or optional.")
46
+ ] = "required",
47
+ budget: Annotated[
48
+ str | None, typer.Option("--budget", help="max_sources=N,max_ranges=N")
49
+ ] = None,
50
+ out: Annotated[Path | None, typer.Option("--out", help="Write a durable context pack JSON file.")] = None,
51
+ context_id: Annotated[str | None, typer.Option("--id", help="Context pack ID override.")] = None,
52
+ ) -> None:
53
+ def _run() -> None:
54
+ paths = current_paths()
55
+ validate_choice(grounding, option="--grounding", supported=ALLOWED_GROUNDING)
56
+ limits = _parse_budget(budget)
57
+ ensure_index(paths.sqlite_path, paths.sources_jsonl, paths.ranges_jsonl)
58
+ sources = load_sources()
59
+ retrieval_metadata: dict[str, str | None] = {}
60
+ candidate_ranges = search_ranges(
61
+ paths.sqlite_path,
62
+ ask,
63
+ domain=domain,
64
+ limit=limits["max_ranges"],
65
+ rrf_k=load_config(paths.root).rrf_k,
66
+ ranges=load_ranges(),
67
+ metadata=retrieval_metadata,
68
+ )
69
+ seen_sources: set[str] = set()
70
+ filtered = []
71
+ for item in candidate_ranges:
72
+ if item["source_id"] not in seen_sources and len(seen_sources) >= limits["max_sources"]:
73
+ continue
74
+ seen_sources.add(item["source_id"])
75
+ filtered.append(item)
76
+ artifacts = load_artifacts()
77
+ existing = [
78
+ {
79
+ "artifact_id": artifact.artifact_id,
80
+ "path": artifact.path,
81
+ "status": artifact.status,
82
+ "validation_status": artifact.validation_status,
83
+ "title": artifact.title,
84
+ }
85
+ for artifact in artifacts
86
+ if (target and artifact.path == target)
87
+ or (domain and domain in artifact.domain)
88
+ or (not target and not domain)
89
+ ]
90
+ warnings = []
91
+ if not filtered:
92
+ warnings.append(
93
+ warning(
94
+ "KC_NO_CONTEXT_RANGES",
95
+ "No source ranges matched the request; register or index sources first.",
96
+ {"ask": ask, "domain": domain},
97
+ )
98
+ )
99
+ if retrieval_metadata.get("mode") == "fts_fallback":
100
+ warnings.append(
101
+ warning(
102
+ "KC_RETRIEVAL_SEMANTIC_UNAVAILABLE",
103
+ "Semantic ranking is unavailable; results use SQLite FTS fallback.",
104
+ {"reason": retrieval_metadata.get("semantic_unavailable_reason")},
105
+ )
106
+ )
107
+ citation_policy = {
108
+ "material_claims_require_citations": grounding == "required",
109
+ "citation_token_formats": [
110
+ "[kc:src_<id>:rng_<id>]",
111
+ "[kc:src_<id>:rng_<id>:L<start>-L<end>]",
112
+ "[kc:src_<id>:rng_<id>:JP:<percent-encoded-json-pointer>]",
113
+ "[kc:src_<id>:rng_<id>:CSV:R<start>-R<end>]",
114
+ ],
115
+ "legacy_citation_token_formats": [
116
+ "[kc:src_<id>:L<start>-L<end>]",
117
+ "[kc:src_<id>:JP:<percent-encoded-json-pointer>]",
118
+ "[kc:src_<id>:CSV:R<start>-R<end>]",
119
+ ],
120
+ }
121
+ agent_instructions = [
122
+ "Use the returned source ranges for factual claims.",
123
+ "Prefer v2 citation_token values with range IDs.",
124
+ "If no candidate range supports a claim, mark it [kc:todo] or leave it out.",
125
+ "Do not invent owner, authority, review date, or lifecycle status.",
126
+ "If sources conflict, report the conflict instead of silently resolving it.",
127
+ "kc does not answer the question; you must write the answer or artifact.",
128
+ ]
129
+ next_commands = [
130
+ f"kc artifact validate --file {target or '<artifact>'}",
131
+ f"kc artifact diff --file {target or '<artifact>'}",
132
+ f"kc artifact apply --file {target or '<artifact>'} --dry-run",
133
+ f"kc artifact apply --file {target or '<artifact>'} --yes",
134
+ ]
135
+ result = {
136
+ "search_query": ask,
137
+ "mode": retrieval_metadata.get("mode") or "hybrid",
138
+ "budget": limits,
139
+ "candidate_ranges": filtered,
140
+ "existing_artifacts": existing,
141
+ "required_output_shape": shape,
142
+ "grounding_policy": grounding,
143
+ "citation_policy": citation_policy,
144
+ "agent_instructions": agent_instructions,
145
+ "validation_commands": [
146
+ "kc citation check --file <artifact-or-answer>",
147
+ "kc artifact validate --file <artifact>",
148
+ ],
149
+ "next_commands": next_commands,
150
+ }
151
+ if out is not None:
152
+ pack = ContextPackRecord(
153
+ context_id=context_id or new_id("ctx"),
154
+ created_at=now(),
155
+ ask=ask,
156
+ shape=shape,
157
+ target=target,
158
+ grounding_policy=grounding,
159
+ workspace={"root": paths.root.as_posix(), "project_id": load_config(paths.root).project_id},
160
+ candidate_ranges=filtered,
161
+ existing_artifacts=existing,
162
+ citation_policy=citation_policy,
163
+ artifact_policy={"requires_validation_before_apply": True},
164
+ agent_instructions=agent_instructions,
165
+ next_commands=next_commands,
166
+ validation={
167
+ "commands": [
168
+ f"kc artifact validate --file {target or '<artifact>'}",
169
+ f"kc citation check --file {target or '<artifact>'}",
170
+ ],
171
+ "expected_exit_codes": {"success": 0, "validation": 10, "provenance": 20},
172
+ },
173
+ )
174
+ out_path = resolve_repo_path(out)
175
+ with mutation_transaction(paths, "context.prepare", [out_path]) as tx:
176
+ atomic_write_text(out_path, json_dumps(pack.model_dump(mode="json")) + "\n")
177
+ tx.commit({"context_id": pack.context_id})
178
+ result["context_pack"] = {
179
+ "context_id": pack.context_id,
180
+ "path": repo_relative(out_path),
181
+ "schema_version": pack.schema_version,
182
+ }
183
+ emit_success(
184
+ "context.prepare",
185
+ result,
186
+ target={"ask": ask, "shape": shape, "target": target, "mode": retrieval_metadata.get("mode") or "hybrid", "budget": limits},
187
+ warnings=[*warnings, *stale_source_warnings(filtered, sources)],
188
+ )
189
+
190
+ run("context.prepare", _run)
kc/commands/doctor.py ADDED
@@ -0,0 +1,81 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from typing import Annotated
5
+
6
+ import typer
7
+
8
+ from kc.commands.common import load_ranges, load_sources, run
9
+ from kc.output import emit_success
10
+ from kc.paths import current_paths, current_workspace
11
+ from kc.search.semantic import semantic_index_status
12
+ from kc.store.sqlite import index_status
13
+
14
+ app = typer.Typer(help="Inspect repository health, locks, and semantic index state.")
15
+
16
+
17
+ @app.callback(invoke_without_command=True)
18
+ def doctor(ctx: typer.Context) -> None:
19
+ if ctx.invoked_subcommand is not None:
20
+ return
21
+
22
+ def _run() -> None:
23
+ workspace = current_workspace()
24
+ paths = workspace.paths
25
+ ranges = load_ranges() if paths.ranges_jsonl.exists() else []
26
+ sources = load_sources() if paths.sources_jsonl.exists() else []
27
+ emit_success(
28
+ "doctor",
29
+ {
30
+ "workspace_resolution": {
31
+ "root": workspace.root.as_posix(),
32
+ "source": workspace.source,
33
+ "project_id": workspace.config.project_id,
34
+ "data_dir": paths.data_dir.as_posix(),
35
+ "state_dir": paths.state_dir.as_posix(),
36
+ },
37
+ "config_exists": paths.config_path.exists(),
38
+ "data_dir_exists": paths.data_dir.exists(),
39
+ "state_dir_exists": paths.state_dir.exists(),
40
+ "sqlite_exists": paths.sqlite_path.exists(),
41
+ "locks": len(list(paths.locks_dir.glob("*.lock")))
42
+ if paths.locks_dir.exists()
43
+ else 0,
44
+ "index": index_status(paths.sqlite_path, sources, ranges),
45
+ "semantic": semantic_index_status(paths.sqlite_path, ranges),
46
+ },
47
+ )
48
+
49
+ run("doctor", _run)
50
+
51
+
52
+ @app.command("locks", help="List lock files and optionally clear them after confirmation.")
53
+ def locks(
54
+ clear_stale: Annotated[bool, typer.Option("--clear-stale", help="Clear lock files.")] = False,
55
+ yes: Annotated[bool, typer.Option("--yes", help="Confirm clearing lock files.")] = False,
56
+ ) -> None:
57
+ def _run() -> None:
58
+ paths = current_paths()
59
+ paths.locks_dir.mkdir(parents=True, exist_ok=True)
60
+ lock_infos = []
61
+ cleared = []
62
+ for path in sorted(paths.locks_dir.glob("*.lock")):
63
+ try:
64
+ metadata = json.loads(path.read_text(encoding="utf-8"))
65
+ except Exception:
66
+ metadata = {"lock_file": str(path)}
67
+ lock_infos.append({"path": str(path), "metadata": metadata})
68
+ if clear_stale and yes:
69
+ path.unlink(missing_ok=True)
70
+ cleared.append(str(path))
71
+ emit_success(
72
+ "doctor.locks",
73
+ {
74
+ "locks": lock_infos,
75
+ "clear_stale": clear_stale,
76
+ "cleared": cleared,
77
+ "dry_run": clear_stale and not yes,
78
+ },
79
+ )
80
+
81
+ run("doctor.locks", _run)