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,305 @@
1
+ """``dna sdlc issue publish|import|sync`` β€” the GitHub Issues bridge.
2
+
3
+ The third side of the git↔SDLC symbiosis triangle (s-github-issues-
4
+ bridge): commits carry the ``Work-Item:`` trailer, PRs carry the 🧬
5
+ footer, and now GitHub issues do too. GitHub Issues are artifacts of
6
+ the github.com domain β€” DNA **bridges** with provenance, it does not
7
+ replace them:
8
+
9
+ - ``issue publish <i-x>`` β€” creates the GitHub twin via ``gh issue
10
+ create`` (title from the doc + ``(i-x)`` suffix, body = description
11
+ + facts + doc link + 🧬 footer) and stamps ``github_number`` /
12
+ ``github_url`` / ``github_state`` / ``github_synced_at`` back onto
13
+ the doc through the kernel (the write path validates β€” the fields
14
+ live on the Issue Kind schema). Idempotent: an already-published doc
15
+ just prints its link.
16
+ - ``issue import <#N | url>`` β€” creates an Issue doc FROM a GitHub
17
+ issue (``gh issue view --json``), board-convention name
18
+ (``i-NNN-<slug>`` via the next free number), labels mapped to
19
+ type/severity by the documented heuristic in ``_github_bridge``,
20
+ reporter = the GitHub author, provenance filled. Idempotent: a doc
21
+ already bridged to ``#N`` on that repo wins.
22
+ - ``issue sync <i-x>`` β€” refreshes ``github_state`` from the remote;
23
+ a remotely-closed issue leaves a note on the local timeline.
24
+
25
+ ``--repo`` defaults to the ``origin`` remote of the enclosing repo
26
+ (``owner/name``); pass it explicitly to bridge across repos. Honest
27
+ degradation: no ``gh`` / auth / network β†’ a didactic error, never a
28
+ traceback (the fail-SOFT half β€” closing the twin on ``issue resolve``
29
+ β€” lives in ``sdlc_cmd.cmd_issue_resolve``).
30
+
31
+ Mechanics (pure builders + gh plumbing) live in ``_github_bridge.py``;
32
+ this module is only the click surface. Registered onto the ``sdlc
33
+ issue`` group at import time (same pattern as ``pr_cmd``) β€” see
34
+ ``dna_cli/__init__.py``.
35
+ """
36
+ from __future__ import annotations
37
+
38
+ import re
39
+ from typing import Any
40
+
41
+ import click
42
+
43
+ from dna_cli import _github_bridge as gb
44
+ from dna_cli._ctx import dna_session, fail
45
+ from dna_cli.pr_cmd import _anchor_source_to_repo_root
46
+ from dna_cli.sdlc_cmd import (
47
+ _append_timeline,
48
+ _build_raw,
49
+ _next_issue_number,
50
+ _now_iso,
51
+ _scope_option,
52
+ issue_group,
53
+ )
54
+
55
+
56
+ def _repo_option(f):
57
+ return click.option(
58
+ "--repo", default=None,
59
+ help="GitHub repo 'owner/name' (default: derivado do remote origin).",
60
+ )(f)
61
+
62
+
63
+ def _resolve_repo(repo: str | None, *, context: str) -> str:
64
+ """Explicit ``--repo`` wins; else derive from origin; else didactic fail."""
65
+ if repo:
66
+ return repo
67
+ derived = gb.default_repo()
68
+ if derived:
69
+ return derived
70
+ raise fail(
71
+ f"nΓ£o consegui derivar o repositΓ³rio GitHub do remote origin β€” "
72
+ f"passe --repo owner/name pro {context}."
73
+ )
74
+
75
+
76
+ def _issue_number_from_url(url: str) -> int | None:
77
+ m = re.search(r"/issues/(\d+)/?$", url.strip())
78
+ return int(m.group(1)) if m else None
79
+
80
+
81
+ # ─── publish ──────────────────────────────────────────────────────────
82
+
83
+
84
+ @issue_group.command("publish")
85
+ @click.argument("name")
86
+ @_repo_option
87
+ @click.option("--dry-run", "dry_run", is_flag=True,
88
+ help="SΓ³ imprime title + body montados; nΓ£o chama gh.")
89
+ @_scope_option
90
+ def cmd_issue_publish(
91
+ name: str, repo: str | None, dry_run: bool, scope: str,
92
+ ) -> None:
93
+ """Publish the Issue to GitHub β€” ``gh issue create`` born FROM the doc.
94
+
95
+ Title ``<tΓ­tulo> (<i-x>)``; body = description + type/severity + link
96
+ pro doc no repo + footer 🧬 de atribuição. Grava github_number/url/
97
+ state/synced_at de volta no doc (proveniΓͺncia). Idempotente: doc jΓ‘
98
+ publicado sΓ³ mostra o link.
99
+ """
100
+ _anchor_source_to_repo_root()
101
+ with dna_session(scope) as s:
102
+ doc = s.get_doc("Issue", name)
103
+ if doc is None:
104
+ raise fail(f"Issue '{name}' not found in scope {scope!r}")
105
+ spec = dict(doc.spec) if isinstance(doc.spec, dict) else {}
106
+
107
+ if spec.get("github_number"):
108
+ click.secho(
109
+ f"Issue/{name} jΓ‘ publicada β€” GitHub #{spec['github_number']}",
110
+ fg="yellow",
111
+ )
112
+ if spec.get("github_url"):
113
+ click.echo(spec["github_url"])
114
+ return
115
+
116
+ repo_full = _resolve_repo(repo, context="publish")
117
+ title = gb.build_issue_title(name, spec)
118
+ body = gb.build_issue_body(name, spec, scope=s.scope, repo=repo_full)
119
+
120
+ if dry_run:
121
+ click.secho("── title ──", fg="cyan")
122
+ click.echo(title)
123
+ click.secho("── body ──", fg="cyan")
124
+ click.echo(body, nl=False)
125
+ click.secho("(dry-run — gh issue create NÃO foi chamado)",
126
+ fg="yellow", err=True)
127
+ return
128
+
129
+ try:
130
+ out = gb.run_gh(
131
+ ["gh", "issue", "create", "--repo", repo_full,
132
+ "--title", title, "--body", body],
133
+ context="issue publish",
134
+ )
135
+ except gb.GhError as e:
136
+ raise fail(str(e))
137
+ url = out.strip().splitlines()[-1] if out.strip() else ""
138
+ number = _issue_number_from_url(url) if url else None
139
+ if not number:
140
+ raise fail(
141
+ f"gh issue create nΓ£o devolveu uma URL de issue reconhecΓ­vel "
142
+ f"(saΓ­da: {out.strip()[:200]!r}) β€” confira no GitHub antes de repetir."
143
+ )
144
+
145
+ spec["github_number"] = number
146
+ spec["github_url"] = url
147
+ spec["github_state"] = "open"
148
+ spec["github_synced_at"] = _now_iso()
149
+ spec["updated_at"] = _now_iso()
150
+ _append_timeline(
151
+ spec, "github_published",
152
+ summary=f"Publicada no GitHub: #{number}", github_url=url,
153
+ )
154
+ raw = _build_raw("Issue", name, spec)
155
+ s.run(s.kernel.write_document(scope, "Issue", name, raw))
156
+
157
+ click.secho(f"PUBLISHED Issue/{name} β†’ {repo_full}#{number}", fg="green")
158
+ click.echo(url)
159
+
160
+
161
+ # ─── import ───────────────────────────────────────────────────────────
162
+
163
+
164
+ def _spec_from_github(gh_issue: dict[str, Any]) -> dict[str, Any]:
165
+ """Build the Issue spec from a ``gh issue view --json`` payload β€” pure.
166
+
167
+ Heuristics (documented in ``_github_bridge``): labels β†’
168
+ type/severity; GitHub state → status (OPEN→open, CLOSED→resolved
169
+ with ``closed_at`` from the GitHub close time); reporter = author.
170
+ """
171
+ labels = [
172
+ lbl.get("name", "") for lbl in gh_issue.get("labels") or []
173
+ if isinstance(lbl, dict)
174
+ ]
175
+ issue_type, severity = gb.map_labels(labels)
176
+ title = str(gh_issue.get("title") or "").strip()
177
+ body = str(gh_issue.get("body") or "").strip()
178
+ state = str(gh_issue.get("state") or "open").lower()
179
+ spec: dict[str, Any] = {
180
+ "title": title,
181
+ "description": body or title,
182
+ "type": issue_type,
183
+ "severity": severity,
184
+ "status": "open" if state == "open" else "resolved",
185
+ "reporter": str((gh_issue.get("author") or {}).get("login") or "github"),
186
+ "github_number": int(gh_issue["number"]),
187
+ "github_url": str(gh_issue.get("url") or ""),
188
+ "github_state": "open" if state == "open" else "closed",
189
+ "github_synced_at": _now_iso(),
190
+ }
191
+ if labels:
192
+ spec["labels"] = labels
193
+ if spec["status"] == "resolved":
194
+ spec["closed_at"] = str(gh_issue.get("closedAt") or _now_iso())
195
+ if gh_issue.get("createdAt"):
196
+ spec["created_at"] = str(gh_issue["createdAt"])
197
+ return spec
198
+
199
+
200
+ @issue_group.command("import")
201
+ @click.argument("ref")
202
+ @_repo_option
203
+ @_scope_option
204
+ def cmd_issue_import(ref: str, repo: str | None, scope: str) -> None:
205
+ """Import a GitHub issue as an Issue doc (``#N``, ``N`` or the URL).
206
+
207
+ Nome segue a convenΓ§Γ£o do board (``i-NNN-<slug-do-tΓ­tulo>`` no
208
+ próximo número livre). Labels→type/severity por heurística simples
209
+ (documentada em ``_github_bridge``); reporter = autor GitHub;
210
+ proveniΓͺncia (github_number/url/state/synced_at) preenchida.
211
+ Idempotente: um doc jΓ‘ bridged pra essa issue vence.
212
+ """
213
+ _anchor_source_to_repo_root()
214
+ try:
215
+ number, repo_from_url = gb.parse_issue_ref(ref)
216
+ except ValueError as e:
217
+ raise fail(str(e))
218
+ repo_full = repo_from_url or _resolve_repo(repo, context="import")
219
+
220
+ try:
221
+ gh_issue = gb.fetch_issue(number, repo_full, context="issue import")
222
+ except gb.GhError as e:
223
+ raise fail(str(e))
224
+
225
+ spec = _spec_from_github(gh_issue)
226
+ _append_timeline(
227
+ spec, "github_imported",
228
+ summary=f"Importada do GitHub: {repo_full}#{number}",
229
+ github_url=spec.get("github_url"),
230
+ )
231
+
232
+ with dna_session(scope) as s:
233
+ for existing in s.query_list("Issue"):
234
+ espec = existing.spec if isinstance(existing.spec, dict) else {}
235
+ if espec.get("github_number") == number:
236
+ click.secho(
237
+ f"GitHub #{number} jΓ‘ importada como Issue/{existing.name} "
238
+ f"β€” nada a fazer.", fg="yellow",
239
+ )
240
+ return
241
+ name = f"i-{_next_issue_number(scope):03d}-gh{number}-{gb.slug_from_title(spec['title'])}"
242
+ raw = _build_raw("Issue", name, spec)
243
+ s.run(s.kernel.write_document(scope, "Issue", name, raw))
244
+
245
+ click.secho(
246
+ f"IMPORTED {repo_full}#{number} β†’ Issue/{name} "
247
+ f"({spec['type']}/{spec['severity']}, status={spec['status']})",
248
+ fg="green",
249
+ )
250
+ if spec.get("github_url"):
251
+ click.echo(spec["github_url"])
252
+
253
+
254
+ # ─── sync ─────────────────────────────────────────────────────────────
255
+
256
+
257
+ @issue_group.command("sync")
258
+ @click.argument("name")
259
+ @_repo_option
260
+ @_scope_option
261
+ def cmd_issue_sync(name: str, repo: str | None, scope: str) -> None:
262
+ """Refresh ``github_state`` from the remote twin.
263
+
264
+ Fechada lΓ‘ β†’ alΓ©m do refresh, deixa uma nota na timeline local (o
265
+ board fica sabendo sem ninguΓ©m vigiar o GitHub). NΓ£o mexe no status
266
+ local β€” decidir se "closed no GitHub" vira "resolved" Γ© triage humana.
267
+ """
268
+ _anchor_source_to_repo_root()
269
+ with dna_session(scope) as s:
270
+ doc = s.get_doc("Issue", name)
271
+ if doc is None:
272
+ raise fail(f"Issue '{name}' not found in scope {scope!r}")
273
+ spec = dict(doc.spec) if isinstance(doc.spec, dict) else {}
274
+ number = spec.get("github_number")
275
+ if not number:
276
+ raise fail(
277
+ f"Issue/{name} nΓ£o tem github_number β€” publique primeiro "
278
+ f"(dna sdlc issue publish {name}) ou importe do GitHub."
279
+ )
280
+ repo_full = _resolve_repo(repo, context="sync")
281
+
282
+ try:
283
+ gh_issue = gb.fetch_issue(int(number), repo_full, context="issue sync")
284
+ except gb.GhError as e:
285
+ raise fail(str(e))
286
+
287
+ prev_state = spec.get("github_state")
288
+ new_state = "open" if str(gh_issue.get("state") or "").lower() == "open" else "closed"
289
+ spec["github_state"] = new_state
290
+ spec["github_synced_at"] = _now_iso()
291
+ spec["updated_at"] = _now_iso()
292
+ if new_state == "closed" and prev_state != "closed":
293
+ _append_timeline(
294
+ spec, "comment",
295
+ summary=(
296
+ f"GitHub #{number} foi fechada no remoto "
297
+ f"({repo_full}) β€” avaliar resolve local."
298
+ ),
299
+ )
300
+ raw = _build_raw("Issue", name, spec)
301
+ s.run(s.kernel.write_document(scope, "Issue", name, raw))
302
+
303
+ flip = f" ({prev_state} β†’ {new_state})" if prev_state != new_state else ""
304
+ click.secho(f"SYNCED Issue/{name} β€” GitHub #{number} is {new_state}{flip}",
305
+ fg="green")
dna_cli/kind_cmd.py ADDED
@@ -0,0 +1,84 @@
1
+ """``dna kind`` β€” list and inspect Kinds registered on the kernel.
2
+
3
+ Migrated to dna-client (Phase F9 follow-up). Uses /kernel/kinds (global)
4
+ and /scopes/{X}/kinds/{kind}/schema (per-scope) instead of touching
5
+ the local kernel.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import click
10
+
11
+ from dna_cli._ctx import dna_client, fail, print_json, print_table, run_async
12
+
13
+
14
+ @click.group("kind", help="List + inspect registered Kinds.")
15
+ def kind() -> None:
16
+ """Group root."""
17
+
18
+
19
+ @kind.command("list")
20
+ @click.option("--json", "as_json", is_flag=True, help="JSON output.")
21
+ @click.option(
22
+ "--scope",
23
+ default="dna-development",
24
+ show_default=True,
25
+ help="Scope to enumerate kinds from.",
26
+ )
27
+ @click.option("--tenant", default=None, help="Route as this tenant.")
28
+ def list_kinds(as_json: bool, scope: str, tenant: str | None) -> None:
29
+ """List all Kinds registered on the kernel (in the given scope)."""
30
+ with dna_client(tenant=tenant) as dna:
31
+ try:
32
+ body = run_async(dna.scopes.kinds(scope))
33
+ except Exception as e: # noqa: BLE001
34
+ raise fail(f"scopes.kinds failed: {e}") from e
35
+ # /scopes/{X}/kinds returns {KindName: [doc names…], ...} β€” keys
36
+ # are the registered kinds. Tabular view emits one row per key;
37
+ # we don't have the per-kind metadata (alias/api_version/is_root)
38
+ # cheaply from this endpoint (would need /scopes/{X}/kinds/{kind}/schema
39
+ # per-kind, expensive on N=60+). For full descriptor of one kind,
40
+ # use `dna kind describe <KindName>`.
41
+ if isinstance(body, dict):
42
+ kinds_list = sorted(body.keys())
43
+ elif isinstance(body, list):
44
+ kinds_list = [it if isinstance(it, str) else (it.get("kind") if isinstance(it, dict) else "?") for it in body]
45
+ else:
46
+ kinds_list = []
47
+ rows = [
48
+ {
49
+ "kind": k,
50
+ "alias": "(use describe)",
51
+ "api_version": "(use describe)",
52
+ "is_root": "",
53
+ "is_prompt_target": "",
54
+ }
55
+ for k in kinds_list
56
+ ]
57
+ rows.sort(key=lambda r: r["kind"])
58
+ if as_json:
59
+ print_json(rows)
60
+ else:
61
+ print_table(rows, ["kind", "alias", "api_version", "is_root", "is_prompt_target"])
62
+
63
+
64
+ @kind.command("describe")
65
+ @click.argument("kind_name")
66
+ @click.option(
67
+ "--scope",
68
+ default="dna-development",
69
+ show_default=True,
70
+ )
71
+ @click.option("--tenant", default=None, help="Route as this tenant.")
72
+ def describe(kind_name: str, scope: str, tenant: str | None) -> None:
73
+ """Show the JSON Schema + storage descriptor for a Kind."""
74
+ with dna_client(tenant=tenant) as dna:
75
+ try:
76
+ descriptor = run_async(dna.scopes.kind_schema(scope, kind_name))
77
+ except Exception as e: # noqa: BLE001
78
+ msg = str(e)
79
+ if "404" in msg or "not found" in msg.lower():
80
+ raise fail(f"Kind '{kind_name}' not registered in scope '{scope}'.") from e
81
+ raise fail(f"kind_schema failed: {e}") from e
82
+ # Descriptor: {kind, alias, api_version, display_label, schema,
83
+ # dep_filters, is_runtime_artifact, is_root, ...}
84
+ print_json(descriptor)
dna_cli/memory_cmd.py ADDED
@@ -0,0 +1,298 @@
1
+ """``dna memory`` β€” remember / recall / forget / list / consolidate, offline.
2
+
3
+ Kernel-bound, no server. Memory in DNA is the Kinds it already has
4
+ (LessonLearned, Research, Evidence) written + recalled through the same kernel
5
+ + ``RecordSearchProvider``. This command group drives ``dna.memory``'s verbs:
6
+
7
+ dna memory remember "always deep-copy the L2 cache before mutating" \
8
+ --area Feature/kernel --affect regret \
9
+ --reason "hit live during the JARVIS audit β€” same dict ref across calls"
10
+ dna memory recall "cache mutation" --json
11
+ dna memory forget rem-abc123 --superseded-by rem-def456
12
+ dna memory list --kind LessonLearned
13
+ dna memory consolidate --apply
14
+
15
+ With the ``search-sqlite`` extra present recall is hybrid (dense sqlite-vec +
16
+ lexical FTS5 + RRF, ``degraded=false``); without it, it degrades HONESTLY to the
17
+ kernel's lexical scan. Recall re-ranks memory hits by Ebbinghaus retention Γ—
18
+ affect, excludes bi-temporally-invalidated memories, and applies a light
19
+ reconsolidation bump (cues_history + confidence) β€” all fail-soft.
20
+ """
21
+ from __future__ import annotations
22
+
23
+ import hashlib
24
+ import re
25
+ from datetime import datetime, timezone
26
+ from typing import Any
27
+
28
+ import click
29
+
30
+ from dna_cli._ctx import dna_session, print_json, print_table
31
+ from dna_cli.recall_cmd import _register_provider
32
+
33
+ _MEMORY_KINDS = ("LessonLearned", "Research", "Evidence")
34
+
35
+
36
+ def _slug(text: str) -> str:
37
+ """Derive a stable ``rem-<hash>`` name from the summary (upstream memory
38
+ naming convention)."""
39
+ h = hashlib.sha256(text.strip().lower().encode("utf-8")).hexdigest()[:10]
40
+ return f"rem-{h}"
41
+
42
+
43
+ def _index_memory_kinds(s: Any, provider: Any, scope: str, kinds, tenant) -> None:
44
+ """Index the memory Kinds into the provider so recall finds pre-existing docs
45
+ (idempotent by text hash)."""
46
+ from dna.adapters.search.sqlite_vec import document_text
47
+
48
+ kernel = s.kernel
49
+ records: list[dict[str, Any]] = []
50
+
51
+ async def _collect() -> None:
52
+ for kind in kinds:
53
+ async for raw in kernel.query(scope, kind, tenant=tenant):
54
+ name = (raw.get("metadata") or {}).get("name") or raw.get("name")
55
+ if not name:
56
+ continue
57
+ records.append({
58
+ "scope": scope, "kind": kind, "name": name, "tenant": tenant or "",
59
+ "text": document_text(raw),
60
+ "title": (raw.get("spec") or {}).get("summary")
61
+ or (raw.get("spec") or {}).get("title") or name,
62
+ })
63
+
64
+ s.run(_collect())
65
+ if records:
66
+ s.run(provider.index(records))
67
+
68
+
69
+ @click.group(name="memory")
70
+ def memory() -> None:
71
+ """Declarative memory over existing Kinds (remember/recall/forget/consolidate)."""
72
+
73
+
74
+ @memory.command(name="remember")
75
+ @click.argument("summary")
76
+ @click.option("--kind", default="LessonLearned", type=click.Choice(_MEMORY_KINDS), show_default=True)
77
+ @click.option("--name", default=None, help="Doc name (default: rem-<hash> of summary).")
78
+ @click.option("--area", default="general", show_default=True, help="Scoped target area (Feature/X, Epic/Y, …).")
79
+ @click.option("--affect", default="triumph",
80
+ type=click.Choice(["triumph", "regret", "surprise", "wistful", "ominous"]), show_default=True)
81
+ @click.option("--reason", "affect_reason", default=None, help="Concrete justification for the affect (β‰₯20 chars).")
82
+ @click.option("--source-ref", "source_refs", multiple=True, help="Source artifact ref (repeatable).")
83
+ @click.option("--tag", "tags", multiple=True, help="Tag (repeatable) β€” also seeds encoding_context co_topics.")
84
+ @click.option("--owner", default=None, help="Authoring agent (claude-code, jarvis, …).")
85
+ @click.option("--scope", default=None, help="Scope (default: first/only scope).")
86
+ @click.option("--tenant", default=None, help="Tenant overlay.")
87
+ @click.option("--json", "as_json", is_flag=True)
88
+ def remember_cmd(
89
+ summary: str, kind: str, name: str | None, area: str, affect: str,
90
+ affect_reason: str | None, source_refs: tuple[str, ...], tags: tuple[str, ...],
91
+ owner: str | None, scope: str | None, tenant: str | None, as_json: bool,
92
+ ) -> None:
93
+ """Write a memory Kind + deterministic encoding-context + index it."""
94
+ from dna.memory import remember
95
+
96
+ name = name or _slug(summary)
97
+ spec: dict[str, Any] = {"summary": summary}
98
+ if kind == "LessonLearned":
99
+ spec.update({
100
+ "area": area,
101
+ "surface_when": ["feature_touched"],
102
+ "source_refs": list(source_refs) or [area],
103
+ "affect": affect,
104
+ })
105
+ if affect_reason:
106
+ spec["affect_reason"] = affect_reason
107
+ if tags:
108
+ spec["tags"] = list(tags)
109
+ if owner:
110
+ spec["owner"] = owner
111
+
112
+ with dna_session(scope) as s:
113
+ provider = _register_provider(s) # registered β†’ remember indexes on write
114
+ try:
115
+ out = s.run(remember(
116
+ s.kernel, s.scope, kind=kind, name=name, spec=spec, tenant=tenant,
117
+ ))
118
+ finally:
119
+ if provider is not None:
120
+ provider.close()
121
+
122
+ if as_json:
123
+ print_json({"kind": out["kind"], "name": out["name"], "indexed": out["indexed"]})
124
+ return
125
+ click.secho(f"🧠 remembered {out['kind']}/{out['name']}", fg="green", bold=True)
126
+ click.echo(f" {summary}")
127
+ if not out["indexed"]:
128
+ click.secho(" (search-sqlite extra absent β€” recall will be lexical)", fg="yellow")
129
+
130
+
131
+ @memory.command(name="recall")
132
+ @click.argument("query")
133
+ @click.option("--kind", "kinds", multiple=True, type=click.Choice(_MEMORY_KINDS),
134
+ help="Restrict to memory kind(s). Default: all.")
135
+ @click.option("--scope", default=None)
136
+ @click.option("--tenant", default=None)
137
+ @click.option("-k", "--limit", "k", default=5, show_default=True)
138
+ @click.option("--no-reconsolidate", is_flag=True, help="Skip the cue/confidence bump side-effect.")
139
+ @click.option("--actor", default="cli", show_default=True, help="Who is recalling (stamped in cues_history).")
140
+ @click.option("--json", "as_json", is_flag=True)
141
+ def recall_cmd(
142
+ query: str, kinds: tuple[str, ...], scope: str | None, tenant: str | None,
143
+ k: int, no_reconsolidate: bool, actor: str, as_json: bool,
144
+ ) -> None:
145
+ """Hybrid, bi-temporal, retention-re-scored recall over the memory Kinds."""
146
+ from dna.memory import recall
147
+ from dna.memory.verbs import MEMORY_KINDS
148
+
149
+ kind_list = list(kinds) or list(MEMORY_KINDS)
150
+ with dna_session(scope) as s:
151
+ provider = _register_provider(s)
152
+ if provider is not None:
153
+ try:
154
+ _index_memory_kinds(s, provider, s.scope, kind_list, tenant)
155
+ except Exception as exc: # noqa: BLE001 β€” indexing failure degrades to lexical
156
+ click.secho(f"⚠ index failed ({exc}); lexical fallback", fg="yellow", err=True)
157
+ else:
158
+ click.secho(
159
+ "⚠ search-sqlite extra not installed β€” degrading to lexical scan "
160
+ "(pip install 'dna-sdk[search-sqlite]' for semantic recall)",
161
+ fg="yellow", err=True,
162
+ )
163
+ try:
164
+ res = s.run(recall(
165
+ s.kernel, s.scope, query, kinds=tuple(kind_list), tenant=tenant, k=k,
166
+ reconsolidate=not no_reconsolidate, actor=actor,
167
+ ))
168
+ finally:
169
+ if provider is not None:
170
+ provider.close()
171
+
172
+ if as_json:
173
+ print_json(res)
174
+ return
175
+ mode = "lexical (degraded)" if res["degraded"] else "hybrid (dense+lexical+RRF)"
176
+ click.secho(f"\n🧠 recall · {mode} · scope={res['scope']} · '{query}'", bold=True)
177
+ hits = res["hits"]
178
+ if not hits:
179
+ click.echo(" (no memories)")
180
+ return
181
+ for i, h in enumerate(hits, 1):
182
+ score = h.get("score", 0.0)
183
+ ret = h.get("retention")
184
+ tail = f" [retention {ret:.2f}]" if ret is not None else ""
185
+ click.echo(f" {i:>2}. {h.get('kind','?')}/{h.get('name','?')} ({score:.4f}){tail}")
186
+ if h.get("snippet"):
187
+ click.secho(f" {h['snippet']}", fg="bright_black")
188
+
189
+
190
+ @memory.command(name="forget")
191
+ @click.argument("name")
192
+ @click.option("--kind", default="LessonLearned", type=click.Choice(_MEMORY_KINDS), show_default=True)
193
+ @click.option("--superseded-by", default=None, help="Name of the memory that supersedes this one.")
194
+ @click.option("--scope", default=None)
195
+ @click.option("--tenant", default=None)
196
+ @click.option("--json", "as_json", is_flag=True)
197
+ def forget_cmd(
198
+ name: str, kind: str, superseded_by: str | None,
199
+ scope: str | None, tenant: str | None, as_json: bool,
200
+ ) -> None:
201
+ """Bi-temporal DEMOTION β€” set valid_to (never hard-delete)."""
202
+ from dna.memory import forget
203
+
204
+ with dna_session(scope) as s:
205
+ try:
206
+ out = s.run(forget(
207
+ s.kernel, s.scope, name, kind=kind, tenant=tenant, superseded_by=superseded_by,
208
+ ))
209
+ except KeyError as exc:
210
+ raise click.ClickException(str(exc)) from exc
211
+
212
+ if as_json:
213
+ print_json(out)
214
+ return
215
+ verb = "already forgotten" if out["already_forgotten"] else "forgotten"
216
+ click.secho(f"πŸ•― {verb}: {out['kind']}/{out['name']} (valid_to={out['valid_to']})", fg="yellow")
217
+ click.secho(" (retained + auditable β€” bi-temporal invalidation, not deleted)", fg="bright_black")
218
+
219
+
220
+ @memory.command(name="list")
221
+ @click.option("--kind", default="LessonLearned", type=click.Choice(_MEMORY_KINDS), show_default=True)
222
+ @click.option("--all", "show_all", is_flag=True, help="Include bi-temporally-invalidated (forgotten) memories.")
223
+ @click.option("--scope", default=None)
224
+ @click.option("--tenant", default=None)
225
+ @click.option("--json", "as_json", is_flag=True)
226
+ def list_cmd(kind: str, show_all: bool, scope: str | None, tenant: str | None, as_json: bool) -> None:
227
+ """List memories in the scope (current by default; ``--all`` includes forgotten)."""
228
+ from dna.memory.decay import currently_valid
229
+
230
+ now = datetime.now(timezone.utc)
231
+ with dna_session(scope) as s:
232
+ rows: list[dict[str, Any]] = []
233
+
234
+ async def _collect() -> None:
235
+ async for raw in s.kernel.query(s.scope, kind, tenant=tenant):
236
+ spec = raw.get("spec") or {}
237
+ nm = (raw.get("metadata") or {}).get("name") or raw.get("name")
238
+ valid = currently_valid(spec.get("valid_to"), now=now)
239
+ if not show_all and not valid:
240
+ continue
241
+ rows.append({
242
+ "name": nm,
243
+ "area": spec.get("area", ""),
244
+ "affect": spec.get("affect", ""),
245
+ "state": "current" if valid else "forgotten",
246
+ "summary": (spec.get("summary") or "")[:60],
247
+ })
248
+
249
+ s.run(_collect())
250
+
251
+ rows.sort(key=lambda r: r["name"])
252
+ if as_json:
253
+ print_json({"kind": kind, "count": len(rows), "memories": rows})
254
+ return
255
+ if not rows:
256
+ click.echo("(no memories)")
257
+ return
258
+ print_table(rows, ["name", "state", "affect", "area", "summary"])
259
+
260
+
261
+ @memory.command(name="consolidate")
262
+ @click.option("--kind", default="LessonLearned", type=click.Choice(_MEMORY_KINDS), show_default=True)
263
+ @click.option("--floor", "stale_floor", default=0.15, show_default=True,
264
+ help="Retention floor below which a memory is stale.")
265
+ @click.option("--apply", "do_apply", is_flag=True, help="Soft-forget stale memories (bi-temporal, never delete).")
266
+ @click.option("--scope", default=None)
267
+ @click.option("--tenant", default=None)
268
+ @click.option("--json", "as_json", is_flag=True)
269
+ def consolidate_cmd(
270
+ kind: str, stale_floor: float, do_apply: bool,
271
+ scope: str | None, tenant: str | None, as_json: bool,
272
+ ) -> None:
273
+ """Deterministic consolidation pass β€” recompute decay, report/soft-forget
274
+ stale memories. NO LLM (that scribe is external + optional)."""
275
+ from dna.memory import consolidate
276
+
277
+ with dna_session(scope) as s:
278
+ report = s.run(consolidate(
279
+ s.kernel, s.scope, kind=kind, tenant=tenant,
280
+ stale_retention_floor=stale_floor, apply=do_apply,
281
+ ))
282
+
283
+ if as_json:
284
+ print_json(report)
285
+ return
286
+ click.secho(
287
+ f"\nπŸŒ™ consolidate Β· evaluated {report['evaluated']} Β· "
288
+ f"{len(report['stale'])} stale Β· archived {report['archived']}",
289
+ bold=True,
290
+ )
291
+ for stl in report["stale"]:
292
+ click.echo(f" Β· {stl['name']} retention={stl['retention']:.3f} "
293
+ f"({stl['days_since']:.0f}d since recall)")
294
+ if report["stale"] and not do_apply:
295
+ click.secho(" (report-only β€” pass --apply to soft-forget)", fg="bright_black")
296
+
297
+
298
+ __all__ = ["memory"]