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 +3 -0
- ringframe/__main__.py +3 -0
- ringframe/ask.py +412 -0
- ringframe/cli.py +296 -0
- ringframe/config.py +34 -0
- ringframe/deltas/claude-code.yaml +30 -0
- ringframe/deltas/codex.yaml +29 -0
- ringframe/deltas/practice/software-development.yaml +270 -0
- ringframe/deltas.py +166 -0
- ringframe/digest.py +18 -0
- ringframe/evaluate.py +415 -0
- ringframe/ids.py +15 -0
- ringframe/profiles/claude-code.yaml +72 -0
- ringframe/profiles/codex.yaml +74 -0
- ringframe/profiles/unknown.yaml +25 -0
- ringframe/profiles.py +32 -0
- ringframe/schema.py +130 -0
- ringframe/seal.py +125 -0
- ringframe/sessions.py +101 -0
- ringframe/store.py +158 -0
- ringframe/workspace.py +41 -0
- ringframe-0.0.1.dist-info/METADATA +102 -0
- ringframe-0.0.1.dist-info/RECORD +27 -0
- ringframe-0.0.1.dist-info/WHEEL +5 -0
- ringframe-0.0.1.dist-info/entry_points.txt +2 -0
- ringframe-0.0.1.dist-info/licenses/LICENSE +202 -0
- ringframe-0.0.1.dist-info/top_level.txt +1 -0
ringframe/__init__.py
ADDED
ringframe/__main__.py
ADDED
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]
|