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.
- kc/__init__.py +5 -0
- kc/__main__.py +11 -0
- kc/artifacts/__init__.py +1 -0
- kc/artifacts/diff.py +76 -0
- kc/artifacts/frontmatter.py +26 -0
- kc/artifacts/markdown.py +116 -0
- kc/atomic_write.py +33 -0
- kc/cli.py +284 -0
- kc/commands/__init__.py +1 -0
- kc/commands/artifact.py +1190 -0
- kc/commands/citation.py +231 -0
- kc/commands/common.py +346 -0
- kc/commands/conformance.py +293 -0
- kc/commands/context.py +190 -0
- kc/commands/doctor.py +81 -0
- kc/commands/eval.py +133 -0
- kc/commands/export.py +97 -0
- kc/commands/guide.py +571 -0
- kc/commands/index.py +54 -0
- kc/commands/init.py +207 -0
- kc/commands/lint.py +238 -0
- kc/commands/source.py +464 -0
- kc/commands/status.py +52 -0
- kc/commands/task.py +260 -0
- kc/config.py +127 -0
- kc/embedding_models/potion-base-8M/README.md +97 -0
- kc/embedding_models/potion-base-8M/config.json +13 -0
- kc/embedding_models/potion-base-8M/model.safetensors +0 -0
- kc/embedding_models/potion-base-8M/modules.json +14 -0
- kc/embedding_models/potion-base-8M/tokenizer.json +1 -0
- kc/errors.py +141 -0
- kc/fingerprints.py +35 -0
- kc/ids.py +23 -0
- kc/locks.py +65 -0
- kc/models/__init__.py +17 -0
- kc/models/artifact.py +34 -0
- kc/models/citation.py +60 -0
- kc/models/context.py +23 -0
- kc/models/eval.py +21 -0
- kc/models/plan.py +37 -0
- kc/models/source.py +37 -0
- kc/models/source_range.py +29 -0
- kc/models/source_revision.py +19 -0
- kc/models/task.py +35 -0
- kc/output.py +838 -0
- kc/paths.py +126 -0
- kc/provenance/__init__.py +1 -0
- kc/provenance/citations.py +296 -0
- kc/search/__init__.py +1 -0
- kc/search/extract.py +268 -0
- kc/search/fts.py +284 -0
- kc/search/semantic.py +346 -0
- kc/store/__init__.py +1 -0
- kc/store/jsonl.py +55 -0
- kc/store/sqlite.py +444 -0
- kc/store/transaction.py +67 -0
- kc/templates/agents/skills/kc/SKILL.md +282 -0
- kc/templates/agents/skills/kc/agents/openai.yaml +5 -0
- kc/templates/agents/skills/kc/scripts/resolve_query_citations.py +134 -0
- kc/workspace.py +98 -0
- kc_cli-0.4.0.dist-info/METADATA +522 -0
- kc_cli-0.4.0.dist-info/RECORD +65 -0
- kc_cli-0.4.0.dist-info/WHEEL +4 -0
- kc_cli-0.4.0.dist-info/entry_points.txt +2 -0
- 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)
|