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
|
@@ -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"]
|