dna-cli 0.1.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.
@@ -0,0 +1,284 @@
1
+ """``dna research`` — manage Research synthesis docs (kernel-bound).
2
+
3
+ A Research is a curated synthesis of N Reference docs with objective,
4
+ methodology, evidence-rated findings, and priority recommendations —
5
+ agent-facing knowledge WITH provenance.
6
+
7
+ Commands:
8
+ dna research list [--status S] [--methodology M] — tabular listing
9
+ dna research show <name> [--full] — full doc render
10
+ dna research create <path> — upsert from YAML/JSON
11
+
12
+ Kernel-bound: boots a local kernel against ``DNA_SOURCE_URL`` /
13
+ ``DNA_BASE_DIR`` (filesystem source, default ``./.dna``). No service.
14
+
15
+ Tenancy is PERMISSIVE — ``--tenant`` is OPTIONAL (Research is
16
+ repo-authored knowledge, not per-client data). Omit it to write/read the
17
+ base doc.
18
+
19
+ Semantic recall (``dna research recall``) routes through ``kernel.search()``
20
+ (i-004 resolved, s-record-search-sqlite): with the ``search-sqlite`` extra it
21
+ uses the embeddable RecordSearchProvider (dense sqlite-vec + lexical FTS5 + RRF,
22
+ offline — no server); without it, it degrades honestly to the kernel's lexical
23
+ scan.
24
+ """
25
+ from __future__ import annotations
26
+
27
+ from datetime import datetime, timezone
28
+ from pathlib import Path
29
+ from typing import Any
30
+
31
+ import click
32
+
33
+ import yaml as _yaml
34
+
35
+ from dna_cli._ctx import dna_session, fail, print_json
36
+
37
+
38
+ def _now() -> str:
39
+ return datetime.now(timezone.utc).isoformat(timespec="seconds")
40
+
41
+
42
+ def _spec_of(doc: Any) -> dict:
43
+ spec = getattr(doc, "spec", None)
44
+ if spec is None and isinstance(doc, dict):
45
+ spec = doc.get("spec")
46
+ if not isinstance(spec, dict):
47
+ spec = dict(spec) if spec else {}
48
+ return spec
49
+
50
+
51
+ def _name_of(doc: Any, fallback: str = "?") -> str:
52
+ name = getattr(doc, "name", None)
53
+ if not name and isinstance(doc, dict):
54
+ name = (doc.get("metadata") or {}).get("name")
55
+ return name or fallback
56
+
57
+
58
+ @click.group(name="research")
59
+ def research() -> None:
60
+ """Manage Research synthesis documents (curated syntheses of References)."""
61
+
62
+
63
+ @research.command("list")
64
+ @click.option("--status", type=click.Choice(list(("brief", "ready", "draft", "published", "superseded", "retracted"))))
65
+ @click.option("--methodology", default=None)
66
+ @click.option("--scope", default="dna-development")
67
+ @click.option("--tenant", default=None, help="Optional tenant (Research is PERMISSIVE — omit for base docs).")
68
+ @click.option("--json", "as_json", is_flag=True, help="Machine-readable output.")
69
+ def cmd_list(
70
+ status: str | None, methodology: str | None, scope: str, tenant: str | None,
71
+ as_json: bool,
72
+ ) -> None:
73
+ """List Research docs in the scope, with key metadata."""
74
+ with dna_session(scope) as s:
75
+ docs = s.query_list("Research", tenant=tenant) or []
76
+ rows = []
77
+ for d in docs:
78
+ spec = _spec_of(d)
79
+ if status and spec.get("status") != status:
80
+ continue
81
+ if methodology and spec.get("methodology") != methodology:
82
+ continue
83
+ findings = spec.get("findings") or []
84
+ rows.append({
85
+ "name": _name_of(d),
86
+ "title": (spec.get("title") or "")[:50],
87
+ "status": spec.get("status", "draft"),
88
+ "method": spec.get("methodology", ""),
89
+ "findings": len(findings),
90
+ "sources": len(spec.get("sources") or []),
91
+ "conducted": (spec.get("conducted_at") or "")[:10],
92
+ })
93
+ rows.sort(key=lambda r: r["name"])
94
+ if as_json:
95
+ print_json(rows)
96
+ return
97
+ if not rows:
98
+ click.echo("(no Research docs)")
99
+ return
100
+ click.echo(f"{'name':30s} {'status':10s} {'method':20s} {'#F':>3s} {'#S':>3s} {'when':10s} title")
101
+ click.echo("-" * 110)
102
+ for r in rows:
103
+ click.echo(
104
+ f"{r['name']:30s} {r['status']:10s} {r['method']:20s} "
105
+ f"{r['findings']:3d} {r['sources']:3d} {r['conducted']:10s} {r['title']}"
106
+ )
107
+
108
+
109
+ @research.command("show")
110
+ @click.argument("name")
111
+ @click.option("--scope", default="dna-development")
112
+ @click.option("--tenant", default=None, help="Optional tenant (Research is PERMISSIVE).")
113
+ @click.option("--full", is_flag=True, help="Print all findings + recommendations.")
114
+ def cmd_show(name: str, scope: str, tenant: str | None, full: bool) -> None:
115
+ """Show a Research doc + its citation graph."""
116
+ with dna_session(scope) as s:
117
+ doc = s.get_doc("Research", name, tenant=tenant)
118
+ if not doc:
119
+ raise fail(f"Not found: Research/{name}")
120
+ spec = _spec_of(doc)
121
+ click.secho(f"\n🔬 Research/{name}", bold=True)
122
+ click.echo(f" title: {spec.get('title', '?')}")
123
+ click.echo(f" status: {spec.get('status', 'draft')}")
124
+ click.echo(f" methodology: {spec.get('methodology', '')}")
125
+ click.echo(f" confidence: {spec.get('overall_confidence', '?')}")
126
+ click.echo(f" conducted_by: {spec.get('conducted_by', '?')}")
127
+ click.echo(f" conducted_at: {spec.get('conducted_at', '?')}")
128
+ click.echo(f" scope_ref: {spec.get('scope_ref', '?')}")
129
+ click.echo(f" visibility: {spec.get('visibility', 'scope-private')}")
130
+ sup = spec.get("superseded_by")
131
+ if sup:
132
+ click.secho(f" ⚠ superseded_by: {sup}", fg="yellow")
133
+ obj = spec.get("objective", "")
134
+ if obj:
135
+ click.echo(f"\n objective:\n {obj}")
136
+ takeaways = spec.get("key_takeaways") or []
137
+ if takeaways:
138
+ click.echo("\n key_takeaways:")
139
+ for t in takeaways:
140
+ click.echo(f" • {t}")
141
+ srcs = spec.get("sources", []) or []
142
+ click.echo(f"\n sources: {len(srcs)} Reference docs")
143
+ for ref_name in (srcs if full else srcs[:10]):
144
+ click.echo(f" • {ref_name}")
145
+ if not full and len(srcs) > 10:
146
+ click.echo(f" … and {len(srcs) - 10} more (use --full)")
147
+ findings = spec.get("findings", []) or []
148
+ ev_count = sum(1 for f in findings if f.get("evidence_rating") == "evidence-based")
149
+ click.echo(f"\n findings: {len(findings)} ({ev_count} evidence-based)")
150
+ for f in (findings if full else findings[:5]):
151
+ rating = f.get("evidence_rating", "?")
152
+ color = "green" if rating == "evidence-based" else "yellow"
153
+ click.echo(f" {f.get('id', '?'):34s} [", nl=False)
154
+ click.secho(rating, fg=color, nl=False)
155
+ click.echo(f"] {f.get('title', '?')}")
156
+ if full and f.get("summary"):
157
+ click.echo(f" {f['summary']}")
158
+ if not full and len(findings) > 5:
159
+ click.echo(f" … and {len(findings) - 5} more (use --full)")
160
+ recs = spec.get("recommendations", []) or []
161
+ click.echo(f"\n recommendations: {len(recs)}")
162
+ for r in (recs if full else recs[:5]):
163
+ pri = r.get("priority", "?")
164
+ color = "red" if pri == "high" else "yellow" if pri == "medium" else "white"
165
+ click.echo(f" {r.get('id', '?'):34s} [", nl=False)
166
+ click.secho(pri, fg=color, nl=False)
167
+ click.echo(f"] {(r.get('summary', '?') or '')[:70]}")
168
+ if r.get("clinical_decision"):
169
+ click.secho(" ⚠ clinical_decision — requires human approval", fg="yellow")
170
+ if not full and len(recs) > 5:
171
+ click.echo(f" … and {len(recs) - 5} more (use --full)")
172
+
173
+
174
+ def _validate_spec_or_die(path: str, spec: dict[str, Any]) -> None:
175
+ """Validate a Research spec against the Kind's own JSON schema.
176
+
177
+ ResearchKind may opt out of validate_on_parse, so ``write_document``
178
+ could persist a malformed spec silently. We validate here, at the
179
+ authoring boundary, using the live Kind schema as the single source
180
+ of truth — collecting ALL errors — and exit non-zero on any violation.
181
+ """
182
+ try:
183
+ import jsonschema
184
+ from dna.extensions.research import ResearchKind
185
+ except ImportError: # pragma: no cover — kernel always ships jsonschema
186
+ return
187
+ schema = ResearchKind().schema()
188
+ if not schema:
189
+ return
190
+ errors = sorted(
191
+ jsonschema.Draft202012Validator(schema).iter_errors(spec),
192
+ key=lambda e: list(e.absolute_path),
193
+ )
194
+ if not errors:
195
+ return
196
+ click.secho(
197
+ f"{path}: Research spec failed schema validation "
198
+ f"({len(errors)} error{'s' if len(errors) != 1 else ''}):",
199
+ fg="red", bold=True,
200
+ )
201
+ for e in errors:
202
+ loc = "/".join(str(p) for p in e.absolute_path) or "<root>"
203
+ click.secho(f" • spec/{loc}: {e.message}", fg="red")
204
+ click.secho(
205
+ "\nHint: findings[] need {id, title, evidence_rating}; "
206
+ "recommendations[] need {id, priority, summary}. "
207
+ "See the Kind schema via `dna kind show Research`.",
208
+ fg="yellow",
209
+ )
210
+ raise SystemExit(1)
211
+
212
+
213
+ @research.command("create")
214
+ @click.argument("path")
215
+ @click.option("--scope", default="dna-development")
216
+ @click.option("--tenant", default=None, help="Optional tenant (Research is PERMISSIVE — omit for base docs).")
217
+ @click.option("--status", default=None, help="Override spec.status (else the file's value, else 'draft').")
218
+ def cmd_create(path: str, scope: str, tenant: str | None, status: str | None) -> None:
219
+ """Create/upsert a Research doc from a YAML/JSON file.
220
+
221
+ First-class research authoring (no ``dna doc apply`` needed). Validates
222
+ kind == Research and the spec schema BEFORE writing. Tenancy is
223
+ permissive: ``--tenant`` optional.
224
+ """
225
+ p = Path(path)
226
+ if not p.exists():
227
+ raise fail(f"File not found: {path}")
228
+ try:
229
+ raw = _yaml.safe_load(p.read_text(encoding="utf-8"))
230
+ except _yaml.YAMLError as e:
231
+ raise fail(f"Invalid YAML/JSON in {path}: {e}")
232
+ if not isinstance(raw, dict):
233
+ raise fail(f"{path}: top-level must be a mapping (apiVersion/kind/metadata/spec).")
234
+ if raw.get("kind") != "Research":
235
+ raise fail(f"{path}: kind must be 'Research' (got {raw.get('kind')!r}).")
236
+ name = (raw.get("metadata") or {}).get("name")
237
+ if not name:
238
+ raise fail(f"{path}: missing metadata.name.")
239
+
240
+ raw.setdefault("apiVersion", "github.com/ruinosus/dna/research/v1")
241
+ spec = raw.setdefault("spec", {})
242
+ if status:
243
+ spec["status"] = status
244
+ spec.setdefault("status", "draft")
245
+ spec.setdefault("created_at", _now())
246
+ spec["updated_at"] = _now()
247
+
248
+ _validate_spec_or_die(path, spec)
249
+
250
+ with dna_session(scope) as s:
251
+ existing = s.get_doc("Research", name, tenant=tenant)
252
+ action = "UPDATED" if existing else "CREATED"
253
+ s.run(s.kernel.write_document(scope, "Research", name, raw, tenant=tenant))
254
+ suffix = f" (tenant={tenant})" if tenant else ""
255
+ click.secho(f"{action} Research/{name}{suffix}", fg="green")
256
+
257
+
258
+ @research.command("recall")
259
+ @click.argument("query")
260
+ @click.option("--scope", default="dna-development", help="Scope to search.")
261
+ @click.option("--tenant", default=None, help="Tenant overlay (base ∪ overlay).")
262
+ @click.option("-k", "--limit", "k", default=10, show_default=True, help="Max hits.")
263
+ @click.option("--json", "as_json", is_flag=True, help="Machine-readable output.")
264
+ @click.pass_context
265
+ def cmd_recall(
266
+ ctx: click.Context, query: str, scope: str, tenant: str | None,
267
+ k: int, as_json: bool,
268
+ ) -> None:
269
+ """Semantic recall over the Research catalog (resolves i-004).
270
+
271
+ Previously lexical-only (no embeddings server travelled to this repo).
272
+ Now routes through ``kernel.search()``: when the ``search-sqlite`` extra is
273
+ installed it uses the registered RecordSearchProvider (dense sqlite-vec +
274
+ lexical FTS5 fused with RRF — real semantic recall); otherwise it degrades
275
+ HONESTLY to the kernel's lexical scan. Thin wrapper over ``dna recall
276
+ --kind Research`` so the two share one code path.
277
+ """
278
+ from dna_cli import recall_cmd
279
+
280
+ ctx.invoke(
281
+ recall_cmd.recall,
282
+ query=query, scope=scope, kinds=("Research",),
283
+ tenant=tenant, k=k, as_json=as_json,
284
+ )
dna_cli/scope_cmd.py ADDED
@@ -0,0 +1,129 @@
1
+ """``dna scope`` — list scopes + dump scope inventory."""
2
+ from __future__ import annotations
3
+
4
+ import sys
5
+
6
+ import click
7
+
8
+ from dna_cli._ctx import get_holder, print_json, print_table
9
+
10
+
11
+ @click.group("scope", help="List + inspect scopes (manifest modules).")
12
+ def scope() -> None:
13
+ """Group root."""
14
+
15
+
16
+ @scope.command("list")
17
+ @click.option("--json", "as_json", is_flag=True)
18
+ @click.option("--tenant", default=None, help="Route as this tenant (overrides DNA_TENANT).")
19
+ def list_scopes(as_json: bool, tenant: str | None) -> None:
20
+ """List discoverable scopes from the configured source.
21
+
22
+ Uses dna-client (HTTP to kinds-api) instead of building a local
23
+ kernel — avoids the asyncpg "task attached to different loop"
24
+ error that happens when a click-sync entrypoint calls
25
+ kernel.list_scopes_async() against a holder built in a different
26
+ event loop.
27
+ """
28
+ from dna_cli._ctx import dna_client, run_async
29
+
30
+ with dna_client(tenant=tenant) as dna:
31
+ try:
32
+ scopes = run_async(dna.scopes.list())
33
+ except Exception as e: # noqa: BLE001
34
+ click.secho(f"scopes.list failed: {e}", err=True, fg="red")
35
+ raise click.exceptions.Exit(1) from e
36
+ # API normalizes shape — accept list[str] OR list[dict] OR {"scopes": [...]}
37
+ if isinstance(scopes, dict):
38
+ scopes = scopes.get("scopes") or list(scopes.keys())
39
+ names = [s if isinstance(s, str) else s.get("scope") or s.get("name") for s in scopes]
40
+ rows = [{"scope": n} for n in names if n]
41
+ if as_json:
42
+ print_json(rows)
43
+ else:
44
+ print_table(rows, ["scope"])
45
+
46
+
47
+ @scope.command("tree")
48
+ @click.argument("scope_name", required=False)
49
+ @click.option("--json", "as_json", is_flag=True)
50
+ @click.option("--tenant", default=None, help="Route as this tenant (overrides DNA_TENANT).")
51
+ def tree(scope_name: str | None, as_json: bool, tenant: str | None) -> None:
52
+ """Inventory all documents in a scope, grouped by Kind.
53
+
54
+ Migrated to dna-client (HTTP /scopes/{X}/tree) so it doesn't need
55
+ DNA_SOURCE_URL set in the CLI's own env — the kinds-api already
56
+ has the kernel and serves the snapshot.
57
+ """
58
+ from dna_cli._ctx import dna_client, run_async
59
+
60
+ target = scope_name or "dna-development"
61
+ with dna_client(tenant=tenant) as dna:
62
+ try:
63
+ by_kind = run_async(dna.scopes.tree(target))
64
+ except Exception as e: # noqa: BLE001
65
+ click.secho(f"scopes.tree failed: {e}", err=True, fg="red")
66
+ raise click.exceptions.Exit(1) from e
67
+
68
+ # API returns {Kind: [{name, ...}, ...]} — normalize to {Kind: [name, ...]}
69
+ normalized: dict[str, list[str]] = {}
70
+ if isinstance(by_kind, dict):
71
+ for k, v in by_kind.items():
72
+ if isinstance(v, list):
73
+ names = [
74
+ item if isinstance(item, str) else (item.get("name") or "?")
75
+ for item in v
76
+ ]
77
+ normalized[k] = sorted(names)
78
+
79
+ if as_json:
80
+ print_json(normalized)
81
+ return
82
+ for k in sorted(normalized):
83
+ click.secho(f"\n{k}", fg="cyan", bold=True)
84
+ for name in normalized[k]:
85
+ click.echo(f" • {name}")
86
+
87
+
88
+ @scope.command("detect")
89
+ @click.option("--cwd", default=None, help="Override starting directory (default: CWD).")
90
+ def detect_scope(cwd: str | None) -> None:
91
+ """Walk upward from cwd looking for the nearest .dna/<scope>/Genome.yaml.
92
+
93
+ Phase 14u — used by the Claude Code PreToolUse hook to auto-inject
94
+ scope context. Prints the scope name to stdout (no decorations) so
95
+ shell scripts can capture it via $(dna scope detect).
96
+
97
+ Genome.yaml is the canonical scope-root marker (Phase 16); the legacy
98
+ pre-Genome manifest.yaml is still accepted (i-007) — same dual-marker
99
+ contract as `dna install` and the composite source.
100
+ """
101
+ from pathlib import Path
102
+
103
+ def _detect(start):
104
+ # Walk upward looking for `.dna/<scope>/Genome.yaml` (canonical)
105
+ # or the legacy `.dna/<scope>/manifest.yaml`, max 6 levels.
106
+ here = (start or Path.cwd()).resolve()
107
+ for _ in range(6):
108
+ dna = here / ".dna"
109
+ if dna.is_dir():
110
+ for child in sorted(dna.iterdir()):
111
+ if child.is_dir() and (
112
+ (child / "Genome.yaml").is_file()
113
+ or (child / "manifest.yaml").is_file()
114
+ ):
115
+ return child.name
116
+ if here.parent == here:
117
+ break
118
+ here = here.parent
119
+ return None
120
+
121
+ name = _detect(Path(cwd).resolve() if cwd else None)
122
+ if not name:
123
+ click.secho("(no DNA scope found)", err=True, fg="yellow")
124
+ raise click.exceptions.Exit(1)
125
+ click.echo(name)
126
+
127
+
128
+ # (scope export/import/reindex — the portable scope-bundle engine —
129
+ # live in the host platform's infra package and do not ship here.)