ringframe 0.0.1__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.
ringframe/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """RingFrame: explicit Ask, Eval, and Seal inside native AI harnesses."""
2
+
3
+ __version__ = "0.0.1"
ringframe/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from ringframe.cli import main
2
+
3
+ raise SystemExit(main())
ringframe/ask.py ADDED
@@ -0,0 +1,412 @@
1
+ """Ask: persist a compiled intent, then append graded observations (confirmation, cancellation, submission, delivery)."""
2
+
3
+ import shutil
4
+ from datetime import datetime, timedelta, timezone
5
+ from pathlib import Path
6
+
7
+ from ringframe import config, deltas, digest, ids, profiles, schema, sessions, store
8
+ from ringframe.store import LedgerError
9
+ from ringframe.workspace import Workspace
10
+
11
+ HOOK_WINDOW = timedelta(minutes=30)
12
+ HANDOFF = """Prompt prepared for {host} ({capability}):
13
+ {path}
14
+
15
+ Open the file, copy its complete contents, and submit them in the
16
+ active {host_title} TUI. RingFrame does not observe that submission.
17
+ """
18
+ HOST_TITLES = {"claude-code": "Claude Code", "codex": "Codex"}
19
+
20
+
21
+ class NeedsInput(Exception):
22
+ def __init__(self, reason: str, candidates: list[dict]):
23
+ super().__init__(reason)
24
+ self.reason = reason
25
+ self.candidates = candidates
26
+
27
+
28
+ def _event(type_, id_, actor, data, links=()):
29
+ return {"schema": store.SCHEMA, "event_id": ids.new_id("evt"), "type": type_, "time": sessions.now(),
30
+ "id": id_, "actor": actor, "links": list(links), "data": data}
31
+
32
+
33
+ def _actor(actor):
34
+ return actor or {"kind": "human", "id": "local-user", "authority": "interactive"}
35
+
36
+
37
+ def _authorized(ws, actor, capability, effects) -> dict:
38
+ """Interactive humans act by being present. Any other actor needs a pre-authorization record."""
39
+ import json
40
+ if actor["kind"] == "human" and actor.get("authority", "interactive") == "interactive":
41
+ return actor
42
+ path = ws.rf_dir / "authorizations" / f"{actor['id']}.json"
43
+ if not path.exists():
44
+ raise NeedsInput(f"authorization required: no record at authorizations/{actor['id']}.json for {actor['kind']}:{actor['id']}", [])
45
+ grant = json.loads(path.read_text())
46
+ allowed = grant.get("allowed", {})
47
+ from datetime import datetime, timezone
48
+ expired = grant.get("expires") and datetime.fromisoformat(str(grant["expires"]).replace("Z", "+00:00")) <= datetime.now(timezone.utc)
49
+ ok = grant.get("actor") == f"{actor['kind']}:{actor['id']}" and capability in allowed.get("capabilities", []) \
50
+ and set(effects) <= set(allowed.get("effects", [])) and not expired
51
+ if not ok:
52
+ raise NeedsInput(f"authorization does not cover {capability} with effects {sorted(effects)} for {actor['kind']}:{actor['id']}", [])
53
+ return {**actor, "authority": f"preauthorized:authorizations/{actor['id']}.json"}
54
+
55
+
56
+ def _normalize_classification(c) -> dict:
57
+ """Accept `approval-gated` for `approval_gated`; the vocabulary itself is unchanged."""
58
+ if not isinstance(c, dict):
59
+ return c
60
+ fix = lambda v: v.replace("-", "_") if isinstance(v, str) else v
61
+ return {k: [fix(x) for x in v] if isinstance(v, list) else fix(v) for k, v in c.items()}
62
+
63
+
64
+ STAGED_FORMS = {("prompt.txt", "source.txt"): "prompt", ("body.txt", "source.txt"): "body", ("composed.txt", "source.txt"): "composed"}
65
+
66
+
67
+ def _staged(staged: Path) -> tuple[bytes, str, bytes]:
68
+ """source.txt plus exactly one of prompt.txt (legacy: model wrote everything), body.txt (CLI renders the
69
+ directives after it) or composed.txt (model applied the CLI-selected directives; CLI adds the prefix only)."""
70
+ names = tuple(sorted(p.name for p in Path(staged).iterdir())) if Path(staged).is_dir() else ()
71
+ if names not in STAGED_FORMS:
72
+ raise LedgerError("ask.staged_dir", "must contain exactly source.txt and one of composed.txt, body.txt or prompt.txt")
73
+ out = {}
74
+ for name in names:
75
+ data = (Path(staged) / name).read_bytes()
76
+ if not data or data.startswith(b"\xef\xbb\xbf"):
77
+ raise LedgerError("ask.staged_file", f"{name} must be non-empty UTF-8 without BOM")
78
+ data.decode("utf-8")
79
+ out[name] = data
80
+ form = STAGED_FORMS[names]
81
+ return out["source.txt"], form, out[names[0]]
82
+
83
+
84
+ def _render_prompt(ws, profile, cap, capability, classification, text_in: bytes, form: str) -> tuple[bytes, dict]:
85
+ """prompt = capability prefix + text (+ rendered directives when form is body). The selection is always the CLI's;
86
+ a composed prompt records what was supplied, recomputed from the classification."""
87
+ try:
88
+ rendered = deltas.render(ws, profile, capability, classification)
89
+ except config.ConfigError as e:
90
+ raise LedgerError("ask.classification", str(e)) from None
91
+ text = (cap.get("prompt_prefix") or "") + text_in.decode("utf-8").rstrip("\n") + "\n"
92
+ if form == "body" and rendered["text"]:
93
+ text += rendered["text"].rstrip("\n") + "\n"
94
+ provenance = {"source": form, "host": {k: v for k, v in rendered["host"].items() if k not in ("text", "entries")},
95
+ "practice": {k: v for k, v in rendered["practice"].items() if k not in ("text", "entries")}}
96
+ if form == "composed":
97
+ supplied = rendered["host"]["entries"] + rendered["practice"]["entries"]
98
+ try:
99
+ applied, omitted = deltas.audit_composed(text_in.decode("utf-8"), supplied)
100
+ except config.ConfigError as e:
101
+ raise LedgerError("ask.composed_rules", str(e)) from None
102
+ provenance.update(applied=applied, omitted=omitted)
103
+ return text.encode("utf-8"), provenance
104
+
105
+
106
+ def compile(ws, *, staged, title, capability, classification, route, host, links=(), limitations=(), actor=None):
107
+ """The only Ask operation that writes artifacts. Appends `ask.compiled`."""
108
+ source, form, prompt = _staged(staged)
109
+ classification = _normalize_classification(classification)
110
+ host = dict(host)
111
+ provenance = {}
112
+ if not host.get("session_ref"):
113
+ found = sessions.resolve_session(ws, host["name"], source)
114
+ if found:
115
+ host["session_ref"] = found["session_ref"]
116
+ provenance["session_ref_source"] = "capture"
117
+ if not host.get("version") and found.get("host_version"):
118
+ host["version"] = found["host_version"]
119
+ provenance["version_source"] = "capture"
120
+ profile = profiles.for_host(host)
121
+ cap = profiles.capability(profile, capability)
122
+ if cap is None:
123
+ raise LedgerError("ask.capability", f"{capability!r} is not in profile {profile['profile_id']}")
124
+ gated = set(cap.get("requires_explicit_request_for_effects", [])) & set(classification.get("effects", []))
125
+ if gated and route.get("explicit_direct_request") is not True:
126
+ raise LedgerError("ask.route_policy", f"{capability} with effects {sorted(gated)} requires route.explicit_direct_request=true, "
127
+ "which is only true when the source intent itself asks to skip planning or act immediately; otherwise select native_plan")
128
+ try:
129
+ deltas.validate_concerns(classification.get("concerns", []))
130
+ except config.ConfigError as e:
131
+ raise LedgerError("ask.classification", str(e)) from None
132
+ compiler = {"source": "prompt"}
133
+ if form != "prompt":
134
+ prompt, compiler = _render_prompt(ws, profile, cap, capability, classification, prompt, form)
135
+ text = prompt.decode("utf-8")
136
+ prefix = cap.get("prompt_prefix")
137
+ if prefix and not text.startswith(prefix):
138
+ raise LedgerError("ask.prompt_prefix", f"{capability} on {host['name']} requires prompt.txt to begin with {prefix!r}")
139
+ if cap.get("max_prompt_chars") and len(text.rstrip("\n")) > cap["max_prompt_chars"]:
140
+ raise LedgerError("ask.prompt_too_long", f"{capability} on {host['name']} allows at most {cap['max_prompt_chars']} characters")
141
+ actor = _authorized(ws, _actor(actor), capability, classification.get("effects", []))
142
+ limitations = list(limitations or []) + list(cap.get("limitations", []))
143
+ if profile["profile_id"] == "unknown":
144
+ limitations.append("qualification gap: no profile for this host")
145
+ ask_id = ids.new_id("ask")
146
+ # Provisional references let the event be validated before anything is written.
147
+ source_ref = {"role": "source_intent", "path": f"asks/{ask_id}/source.txt", "bytes": len(source), "sha256": digest.sha256_bytes(source)}
148
+ prompt_ref = {"role": "generated_prompt", "path": f"asks/{ask_id}/prompt.txt", "bytes": len(prompt), "sha256": digest.sha256_bytes(prompt)}
149
+ verified, reason = sessions.source_verified(ws, host["name"], host.get("session_ref"), source)
150
+ if reason:
151
+ limitations.append(f"source_verified unverified: {reason}")
152
+ data = {"title": title, "classification": classification, "selected_capability": capability, "route_explanation": route,
153
+ "host": {"name": host["name"], "version": host.get("version"), "surface": host.get("surface"),
154
+ "session_ref": host.get("session_ref"), "workspace": ws.describe(), **provenance,
155
+ "profile_id": profile["profile_id"], "profile_sha256": profiles.sha256(profile["host"] or "unknown")},
156
+ "source": source_ref, "prompt": prompt_ref, "source_verified": verified, "limitations": limitations,
157
+ "delivery_mode": cap["delivery_mode"], "compiler": compiler, "base_commit": _head(ws)}
158
+ ev = _event("ask.compiled", ask_id, actor, data, links)
159
+ schema.validate_event(ev)
160
+ assert store.publish(ws, source_ref["path"], source, role="source_intent") == source_ref
161
+ assert store.publish(ws, prompt_ref["path"], prompt, role="generated_prompt") == prompt_ref
162
+ store.append(ws, ev)
163
+ shutil.rmtree(staged)
164
+ return {"ask_id": ask_id, "source": source_ref, "prompt": prompt_ref, "prompt_path": str(ws.rf_dir / prompt_ref["path"]),
165
+ "source_verified": verified, "delivery_mode": data["delivery_mode"]}
166
+
167
+
168
+ def _head(ws) -> str | None:
169
+ """The workspace commit an Ask starts from; Eval's anchor when no Seal precedes it. None outside Git."""
170
+ import subprocess
171
+ try:
172
+ return subprocess.run(["git", "-C", str(ws.root), "rev-parse", "--verify", "--quiet", "HEAD"],
173
+ capture_output=True, text=True, check=True).stdout.strip() or None
174
+ except (subprocess.CalledProcessError, FileNotFoundError):
175
+ return None
176
+
177
+
178
+ def _append(ws, type_, ask_id, data, actor=None):
179
+ ev = _event(type_, ask_id, _actor(actor), data)
180
+ schema.validate_event(ev)
181
+ store.append(ws, ev)
182
+ return {"ask_id": ask_id, **data}
183
+
184
+
185
+ def confirm(ws, ask_id: str, actor=None) -> dict:
186
+ """Record the skill-reported chooser answer. Evidence grade: observed by the skill, not by the host."""
187
+ rec = _record(ws, ask_id)
188
+ if rec["confirmed"]:
189
+ raise LedgerError("ask.already_confirmed", ask_id)
190
+ d = rec["compiled"]["data"]
191
+ actor = _authorized(ws, _actor(actor), d["selected_capability"], d["classification"].get("effects", []))
192
+ surface = profiles.for_host(d["host"]).get("confirmation", {}).get("tool") # None under the unknown profile: never invent a surface
193
+ return _append(ws, "ask.confirmed", ask_id, {"confirmation": {"observed_by": "skill", "surface": surface}}, actor)
194
+
195
+
196
+ def cancel(ws, ask_id: str, reason=None, actor=None, attributed=False) -> dict:
197
+ rec = _record(ws, ask_id)
198
+ if rec["cancelled"]:
199
+ raise LedgerError("ask.already_cancelled", ask_id)
200
+ a = _actor(actor)
201
+ grade = {"attributed_by": f"{a['kind']}:{a['id']}"} if attributed else {"observed_by": "skill"}
202
+ return _append(ws, "ask.cancelled", ask_id, {"cancellation": grade, **({"reason": reason} if reason else {})}, a)
203
+
204
+
205
+ def submitted(ws, ask_id: str, as_modified=False, actor=None) -> dict:
206
+ """The person attests that they submitted the prompt (possibly edited). Human attestation, not host evidence."""
207
+ rec = _record(ws, ask_id)
208
+ a = _actor(actor)
209
+ d = rec["compiled"]["data"]
210
+ return _append(ws, "ask.submission", ask_id, {"state": "attributed", "observed_by": None, "attributed_by": f"{a['kind']}:{a['id']}",
211
+ "as_modified": bool(as_modified), "host": {"name": d["host"]["name"], "session_ref": d["host"].get("session_ref")},
212
+ "prompt_sha256": d["prompt"]["sha256"]}, a)
213
+
214
+
215
+ def submission_from_capture(ws, host: str, session: str, sha256: str) -> dict | None:
216
+ """A later user prompt whose bytes equal one compiled prompt.txt: host-observed submission.
217
+
218
+ Three byte forms count as the same prompt, each recorded by name: the file itself (`exact`), the file
219
+ without its trailing newline (`trailing_newline_dropped`: composers drop it on paste), and the file
220
+ without the capability's slash-command prefix (`host_prefix_stripped`: Codex hands its hooks the text
221
+ after `/plan `). When several Asks match, only those already handed off are candidates; a remaining tie
222
+ records nothing rather than guessing. Once per Ask."""
223
+ hits = []
224
+ for ask_id, v in _by_id(ws).items():
225
+ if not v["compiled"] or any(s["data"]["state"] == "observed" for s in v["submissions"]):
226
+ continue
227
+ d = v["compiled"]["data"]
228
+ match = _prompt_match(ws, d, sha256)
229
+ if match:
230
+ hits.append((ask_id, match, v["delivery"] is not None))
231
+ if len(hits) > 1:
232
+ hits = [h for h in hits if h[2]]
233
+ if len(hits) != 1:
234
+ return None
235
+ ask_id, match, _ = hits[0]
236
+ return _append(ws, "ask.submission", ask_id, {"state": "observed", "observed_by": "hook:UserPromptSubmit", "attributed_by": None,
237
+ "as_modified": False, "host": {"name": host, "session_ref": session}, "prompt_sha256": sha256,
238
+ "match": match})
239
+
240
+
241
+ def _prompt_match(ws, compiled: dict, sha256: str) -> str | None:
242
+ ref = compiled["prompt"]
243
+ if ref["sha256"] == sha256:
244
+ return "exact"
245
+ data = (ws.rf_dir / ref["path"]).read_bytes()
246
+ if digest.sha256_bytes(data.rstrip(b"\n")) == sha256:
247
+ return "trailing_newline_dropped"
248
+ prefix = (profiles.capability(profiles.for_host(compiled["host"]), compiled["selected_capability"]) or {}).get("prompt_prefix")
249
+ if prefix and data.startswith(prefix.encode()):
250
+ stripped = data[len(prefix):]
251
+ if sha256 in (digest.sha256_bytes(stripped), digest.sha256_bytes(stripped.rstrip(b"\n"))):
252
+ return "host_prefix_stripped"
253
+ return None
254
+
255
+
256
+ def prompt_text(ws, ask_id: str) -> str:
257
+ return (ws.rf_dir / _record(ws, ask_id)["compiled"]["data"]["prompt"]["path"]).read_text(encoding="utf-8")
258
+
259
+
260
+ def _by_id(ws) -> dict:
261
+ """ask_id -> {"compiled", "confirmed", "cancelled", "delivery": event|None, "submissions": [events]}."""
262
+ out = {}
263
+ for ev in store.events(ws):
264
+ if ev["type"].startswith("ask."):
265
+ rec = out.setdefault(ev["id"], {"compiled": None, "confirmed": None, "cancelled": None, "delivery": None, "submissions": []})
266
+ kind = ev["type"].split(".")[1]
267
+ if kind == "submission":
268
+ rec["submissions"].append(ev)
269
+ else:
270
+ rec[kind] = ev
271
+ return out
272
+
273
+
274
+ def _record(ws, ask_id):
275
+ rec = _by_id(ws).get(ask_id)
276
+ if not rec or not rec["compiled"]:
277
+ raise LedgerError("ask.not_compiled", ask_id)
278
+ return rec
279
+
280
+
281
+ def _append_delivery(ws, ask_id, compiled, mode, mechanism, state, receipt, limitations, actor=None):
282
+ if _by_id(ws)[ask_id]["delivery"] is not None:
283
+ raise LedgerError("delivery.duplicate", ask_id)
284
+ cap = profiles.capability(profiles.for_host(compiled["data"]["host"]), compiled["data"]["selected_capability"]) or {}
285
+ data = {"mode": mode, "mechanism": mechanism, "state": state, "qualification": cap.get("qualification", {"id": None}),
286
+ "receipt": receipt, "submission": "unobserved" if mode == "human_handoff" else "not_applicable",
287
+ "limitations": ["native acceptance does not prove instruction following, execution, or completion"] + list(limitations)}
288
+ ev = _event("ask.delivery", ask_id, _actor(actor), data)
289
+ schema.validate_event(ev)
290
+ store.append(ws, ev)
291
+ return {"ask_id": ask_id, **data}
292
+
293
+
294
+ def delivery_from_hook(ws, payload: dict) -> dict | None:
295
+ """Record native_accepted (or delivery_failed) from a PostToolUse payload; never raise."""
296
+ session = payload.get("session_id")
297
+ host = "claude-code"
298
+ if payload.get("hook_event_name") != "PostToolUse" or not session:
299
+ return None
300
+ tool = payload.get("tool_name")
301
+ cutoff = datetime.now(timezone.utc) - HOOK_WINDOW
302
+ candidates = []
303
+ for ask_id, rec in _by_id(ws).items():
304
+ c = rec["compiled"]
305
+ if not c or rec["delivery"] or rec["cancelled"] or c["data"]["delivery_mode"] != "native_dispatch":
306
+ continue
307
+ if c["data"]["host"].get("session_ref") != session:
308
+ continue
309
+ cap = profiles.capability(profiles.for_host(c["data"]["host"]), c["data"]["selected_capability"]) or {}
310
+ if (cap.get("activation") or {}).get("tool") != tool:
311
+ continue
312
+ if datetime.fromisoformat(c["time"].replace("Z", "+00:00")) < cutoff:
313
+ continue
314
+ candidates.append((ask_id, c))
315
+ if len(candidates) != 1:
316
+ sessions.log(ws, host, session, "delivery-skipped.jsonl",
317
+ {"reason": "ambiguous" if candidates else "no_candidate", "tool": tool, "candidates": [a for a, _ in candidates]})
318
+ return None
319
+ ask_id, compiled = candidates[0]
320
+ response = payload.get("tool_response")
321
+ failed = isinstance(response, dict) and (response.get("error") or response.get("is_error"))
322
+ receipt = {"tool": tool, "tool_use_id": payload.get("tool_use_id"), "session_id": session,
323
+ "response_sha256": digest.sha256_bytes(store.canonical(response)),
324
+ "captured_by": "hook:PostToolUse"}
325
+ return _append_delivery(ws, ask_id, compiled, "native_dispatch", "capability_activate",
326
+ "delivery_failed" if failed else "native_accepted", receipt,
327
+ [f"tool error: {response.get('error')}"] if failed else [])
328
+
329
+
330
+ def delivery_handoff(ws, ask_id: str) -> tuple[str, dict]:
331
+ compiled = _record(ws, ask_id)["compiled"]
332
+ path = ws.rf_dir / compiled["data"]["prompt"]["path"]
333
+ host = compiled["data"]["host"]["name"]
334
+ text = HANDOFF.format(host=host, capability=compiled["data"]["selected_capability"], path=path,
335
+ host_title=HOST_TITLES.get(host, host))
336
+ rec = _append_delivery(ws, ask_id, compiled, "human_handoff", None, "handoff_ready",
337
+ {"path": compiled["data"]["prompt"]["path"], "emitted_by": "cli"}, ["submission unobserved"])
338
+ return text, rec
339
+
340
+
341
+ def delivery_state(ws, ask_id: str, state: str, reason: str) -> dict:
342
+ compiled = _record(ws, ask_id)["compiled"]
343
+ mode = compiled["data"]["delivery_mode"] if compiled["data"]["delivery_mode"] != "unsupported" else "human_handoff"
344
+ return _append_delivery(ws, ask_id, compiled, mode, None, state, None, [reason])
345
+
346
+
347
+ def submission_grade(rec) -> str:
348
+ states = [s["data"]["state"] for s in rec["submissions"]]
349
+ return "observed" if "observed" in states else "attributed" if "attributed" in states else "unobserved"
350
+
351
+
352
+ def _sealed(ws) -> set:
353
+ return {a for e in store.events(ws) if e["type"] == "seal.created" for a in e["data"].get("basis", {}).get("asks", [])}
354
+
355
+
356
+ def _state(ws, ask_id, rec, sealed=None) -> str:
357
+ """open from compile until a Seal names the Ask in its basis; cancelled Asks are never open."""
358
+ if rec["cancelled"]:
359
+ return "cancelled"
360
+ return "sealed" if ask_id in (_sealed(ws) if sealed is None else sealed) else "open"
361
+
362
+
363
+ def open_asks(ws) -> list[dict]:
364
+ """Every open Ask, oldest first: the basis of an Eval and of a Seal."""
365
+ sealed = _sealed(ws)
366
+ return [_summary(ws, k, v, sealed) for k, v in _by_id(ws).items() if v["compiled"] and _state(ws, k, v, sealed) == "open"]
367
+
368
+
369
+ def _summary(ws, ask_id, rec, sealed=None):
370
+ ev = rec["compiled"]
371
+ d = ev["data"]
372
+ outcome = "cancelled" if rec["cancelled"] else "confirmed" if rec["confirmed"] else "compiled"
373
+ return {"id": ask_id, "ask_id": ask_id, "title": d["title"], "time": ev["time"], "outcome": outcome, "state": _state(ws, ask_id, rec, sealed),
374
+ "confirmed_at": rec["confirmed"]["time"] if rec["confirmed"] else None, "base_commit": d.get("base_commit"),
375
+ "links": ev["links"],
376
+ "capability": d["selected_capability"], "session_ref": d["host"].get("session_ref"),
377
+ "source_verified": d["source_verified"], "source": d["source"], "prompt": d["prompt"],
378
+ "prompt_path": str(ws.rf_dir / d["prompt"]["path"]),
379
+ "delivery": rec["delivery"]["data"]["state"] if rec["delivery"] else None,
380
+ "submission": submission_grade(rec)}
381
+
382
+
383
+ def resolve(ws, session=None, kind="ask", reference=None) -> dict:
384
+ """Ordered resolution; never picks the newest for being newest."""
385
+ recs = {k: v for k, v in _by_id(ws).items() if v["compiled"]}
386
+ summaries = [_summary(ws, k, v) for k, v in recs.items()]
387
+ if reference:
388
+ exact = [s for s in summaries if s["ask_id"] == reference]
389
+ if exact:
390
+ return {"candidates": exact, "rule_applied": "explicit_id"}
391
+ hits = [s for s in summaries if reference.lower() in s["title"].lower()]
392
+ if len(hits) == 1:
393
+ return {"candidates": hits, "rule_applied": "explicit_title"}
394
+ return {"candidates": hits or summaries, "rule_applied": "chooser"}
395
+ if session:
396
+ same = [s for s in summaries if s["session_ref"] == session]
397
+ if same:
398
+ return {"candidates": same, "rule_applied": "same_session"}
399
+ return {"candidates": summaries, "rule_applied": "unique_in_workspace" if len(summaries) == 1 else "chooser"}
400
+
401
+
402
+ def list_asks(ws) -> list[dict]:
403
+ """Every compiled Ask in this workspace, oldest first: what Eval and a person choose from."""
404
+ sealed = _sealed(ws)
405
+ return [_summary(ws, ask_id, rec, sealed) for ask_id, rec in _by_id(ws).items() if rec["compiled"]]
406
+
407
+
408
+ def show(ws, ask_id=None, session=None) -> dict:
409
+ res = resolve(ws, session=session, reference=ask_id)
410
+ if len(res["candidates"]) != 1:
411
+ raise NeedsInput("no_record" if not res["candidates"] else "chooser", res["candidates"])
412
+ return res["candidates"][0]