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.
- dna_cli/__init__.py +87 -0
- dna_cli/_active_story.py +134 -0
- dna_cli/_banner.py +61 -0
- dna_cli/_ctx.py +433 -0
- dna_cli/_git_symbiosis.py +167 -0
- dna_cli/_github_bridge.py +294 -0
- dna_cli/_methodology_gates.py +164 -0
- dna_cli/data/git-hooks/prepare-commit-msg +132 -0
- dna_cli/doc_cmd.py +842 -0
- dna_cli/docs_cmd.py +79 -0
- dna_cli/eval_cmd.py +274 -0
- dna_cli/hooks_cmd.py +146 -0
- dna_cli/install_cmd.py +496 -0
- dna_cli/issue_bridge_cmd.py +305 -0
- dna_cli/kind_cmd.py +84 -0
- dna_cli/memory_cmd.py +298 -0
- dna_cli/pr_cmd.py +251 -0
- dna_cli/recall_cmd.py +161 -0
- dna_cli/research_cmd.py +284 -0
- dna_cli/scope_cmd.py +129 -0
- dna_cli/sdlc_cmd.py +6549 -0
- dna_cli/source_cmd.py +437 -0
- dna_cli/testkit_cmd.py +352 -0
- dna_cli-0.1.0.dist-info/METADATA +57 -0
- dna_cli-0.1.0.dist-info/RECORD +28 -0
- dna_cli-0.1.0.dist-info/WHEEL +4 -0
- dna_cli-0.1.0.dist-info/entry_points.txt +2 -0
- dna_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
dna_cli/research_cmd.py
ADDED
|
@@ -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.)
|