Skip to content

Commit 6838d69

Browse files
Merge pull request #99 from open-coder-ai/w42/d5-singleton-folds
2 parents 38023f4 + 857f558 commit 6838d69

24 files changed

Lines changed: 732 additions & 807 deletions

CHANGELOG.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,25 @@ versioning: [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
3030
chains (demonstrated, not merely asserted).
3131

3232
### Changed
33+
- **The three singleton adapters are folded onto the engine**
34+
(`docs/design/dialect-families.md` D5). `cursor`, `windsurf` and `antigravity` are no
35+
longer hand-written modules: each is a small family module (shape-inferred claims and
36+
event naming, which §3.3 keeps as code, plus cursor's G4 permission-object renderer and
37+
windsurf's G5 exit-code renderer) over the shared accessor and its
38+
`data/vendors/<agent>.json` entry, which now carries the dialect's words and note strings
39+
verbatim. The golden wire fixtures pass **byte-for-byte unchanged**. The engine devices
40+
this needed are opt-in schema keys absent from every F1/F2 entry: a generic dotted-path
41+
field accessor (`toolCall.args.X`, `workspacePaths[0]`, top-level `edits[].new_string`),
42+
`stringify`, `empty_object_events`, per-gate `words_at` overrides, `flag_note` templates,
43+
`hook_entry.also_wires` (windsurf's second pre_tool wire name, moved out of
44+
`entry_extra`, which would have copied it into entries verbatim), the `cursor` and
45+
`flat_entries` hook wrappers with `group` nesting, and a G1 block reroute for an escalate
46+
degraded from a transform where the entry names that degradation. `_hook_json.py` split
47+
by activity into `_payload.py` (claims/parse) and the renderers to stay in the review
48+
budget. windsurf's `pre_mcp_tool_use` gate — blocking in the adapter but invisible to
49+
D1's one-wire-per-canonical-event fixture — joins `_EXTRA_GATE_NAMES` and is recounted by
50+
executing the dispatcher. vscode_copilot remains the one dialect module (W38's recorded
51+
design feedback; folding it is an owner decision).
3352
- **The four `flat_decision` adapters are folded onto the same engine**
3453
(`docs/design/dialect-families.md` D4). `gemini_cli`, `tabnine`, `junie` and `grok` are
3554
no longer hand-written modules: the D3 engine executes their `data/vendors/<agent>.json`

docs/design/dialect-families.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -332,6 +332,23 @@ first, D3/D4 swap cleanly — they share only D1+D2.
332332
(escalate word path, two transform-body variants, template notes, per-gate reason
333333
defaults, allow-body context/silence, a reverse-map wire fallback, a tool-input
334334
envelope chain, write-gated content, literal hook-entry extras).
335+
- **[v]** D5's singleton numbers, measured at its base (main `38023f4`, where the pre-fold
336+
bundles stood at cursor 524 / windsurf 424 / antigravity 442): cursor 883, windsurf 814,
337+
antigravity 796 — +69% to +92%, the steepest growth of the three folds and far above §4's
338+
"singletons roughly unchanged" estimate. The composition explains it: a singleton's legacy
339+
bundle spliced only its own small module, while an engine bundle inlines the whole shared
340+
engine (accessor, G1/G2 renderers, hook-entry wrappers) plus the family module plus the
341+
`VENDOR` literal — the same trade D3/D4 made, and D6 owns any trimming. The source-tree
342+
payoff held a third time: the three deleted modules were 472 lines, replaced by three
343+
family modules of 219 (cursor's G4 renderer and shape claims, windsurf's G5 exit-code
344+
renderer, antigravity's 3-line shape functions over the engine's G1) — everything
345+
word-shaped moved into the entries as opt-in schema keys (`words_at` per-gate overrides,
346+
`empty_object_events`, `flag_note`/`flag_note_default`, `stringify`, `also_wires`, a
347+
generic dotted-path accessor with `[0]`/top-level `[].` forms, and a block reroute for an
348+
escalate degraded from a transform where the entry names that degradation). The
349+
config/code line held without a new named probe: shape inference stays the three family
350+
modules' code, exactly as §3.3 drew it, and windsurf's two-key pre_tool fan-out folded as
351+
`hook_entry.also_wires` (wiring data) rather than an `entry_extra` entry field.
335352
- **[v]** (was [h]) The `reject_probes: ["looks_like_claude_code"]` device (named engine
336353
predicates referenced from config) is the narrowest crack in the code/config line;
337354
if D2 finds more than ~3 named probes are needed, that is evidence the line is drawn

src/agentseam/adapters/__init__.py

Lines changed: 17 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -3,25 +3,27 @@
33
from __future__ import annotations
44

55
from .._data import load
6-
from . import (
7-
antigravity,
8-
cursor,
9-
vscode_copilot,
10-
windsurf,
11-
)
6+
from . import vscode_copilot
127
from ._family import bind
138

14-
#: hook_json (D3) and flat_decision (D4) vendors driven by engine + data/vendors entry;
9+
#: Every vendor but one is a family engine (D3-D5) bound to its data/vendors entry;
1510
#: vscode_copilot stays a dialect module -- its three-path claims() and memory-tool
1611
#: branching are beyond what the flat config may carry (§3.1).
17-
_CONFIG_DRIVEN = ("claude_code", "codex_cli", "devin", "gemini_cli", "grok", "junie", "kimi_code", "tabnine")
18-
19-
ADAPTERS = {
20-
antigravity.AGENT: antigravity,
21-
cursor.AGENT: cursor,
22-
vscode_copilot.AGENT: vscode_copilot,
23-
windsurf.AGENT: windsurf,
24-
}
12+
_CONFIG_DRIVEN = (
13+
"antigravity",
14+
"claude_code",
15+
"codex_cli",
16+
"cursor",
17+
"devin",
18+
"gemini_cli",
19+
"grok",
20+
"junie",
21+
"kimi_code",
22+
"tabnine",
23+
"windsurf",
24+
)
25+
26+
ADAPTERS = {vscode_copilot.AGENT: vscode_copilot}
2527
ADAPTERS.update({agent: bind(load("vendors/%s.json" % agent)) for agent in _CONFIG_DRIVEN})
2628

2729

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
"""The F5 `antigravity` family: event-less payloads classified by shape.
2+
3+
Only the shape inference is code (dialect-families.md §3.3) -- the verdicts are the
4+
engine's G1 renderer over `data/vendors/antigravity.json`'s word tables.
5+
"""
6+
7+
from __future__ import annotations
8+
9+
from ._hook_json import hj_respond
10+
from ._payload import hj_parse
11+
12+
13+
def antigravity_wire(raw):
14+
"""Name the event from shape; ties go to PreToolUse so the gate stays a gate."""
15+
if "terminationReason" in raw or "fullyIdle" in raw:
16+
return "Stop"
17+
if isinstance(raw.get("toolCall"), dict):
18+
return "PostToolUse" if "error" in raw else "PreToolUse"
19+
return None
20+
21+
22+
def antigravity_claims(cfg, raw):
23+
"""Structural: `conversationId` with `workspacePaths` is Antigravity's own envelope."""
24+
if not isinstance(raw, dict):
25+
return False
26+
return "conversationId" in raw and isinstance(raw.get("workspacePaths"), list)
27+
28+
29+
def antigravity_parse(cfg, raw):
30+
return hj_parse(cfg, raw, wire=antigravity_wire(raw))
31+
32+
33+
def antigravity_respond(cfg, decision, event):
34+
return hj_respond(cfg, decision, event, wire=antigravity_wire(event.raw or {}))

src/agentseam/adapters/_cursor.py

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
"""The F3 `cursor` family: shape-inferred claims and the G4 permission-object dialect.
2+
3+
Shape inference stays code (dialect-families.md §3.3); everything word- or chain-shaped
4+
comes from the vendor's `data/vendors/cursor.json` entry.
5+
"""
6+
7+
from __future__ import annotations
8+
9+
import json as _json
10+
11+
from ..contract import (
12+
DENY,
13+
ESCALATE,
14+
FILE_CHANGED,
15+
POST_TOOL,
16+
PRE_TOOL,
17+
PROMPT_SUBMIT,
18+
TOOL_FAILURE,
19+
TRANSFORM,
20+
degraded_from,
21+
)
22+
from ._payload import hj_parse
23+
24+
#: Wire names other vendors also spell this way; a payload naming one is claimed only on
25+
#: Cursor's own base-schema envelope markers.
26+
_AMBIGUOUS_NAMES = (
27+
"preToolUse",
28+
"postToolUse",
29+
"sessionStart",
30+
"sessionEnd",
31+
"preCompact",
32+
"stop",
33+
"subagentStart",
34+
"subagentStop",
35+
)
36+
37+
_MARKERS = ("conversation_id", "generation_id", "cursor_version", "workspace_roots")
38+
39+
40+
def cursor_wire(raw):
41+
"""The wire event name, inferred from shape when the payload names none."""
42+
name = raw.get("hook_event_name")
43+
if name is None:
44+
return "afterFileEdit" if isinstance(raw.get("edits"), list) else "beforeShellExecution"
45+
return name
46+
47+
48+
def cursor_claims(cfg, raw):
49+
"""True when this payload looks like Cursor's shape."""
50+
if not isinstance(raw, dict):
51+
return False
52+
name = raw.get("hook_event_name")
53+
if name in cfg["events"]:
54+
if name in _AMBIGUOUS_NAMES:
55+
return any(k in raw for k in _MARKERS)
56+
return True
57+
if isinstance(raw.get("command"), str) and ("sandbox" in raw or "cwd" in raw) and "tool_input" not in raw:
58+
return True
59+
return "file_path" in raw and isinstance(raw.get("edits"), list) and "tool_name" not in raw
60+
61+
62+
def cursor_parse(cfg, raw):
63+
name = cursor_wire(raw)
64+
event = hj_parse(cfg, raw, wire=name)
65+
event.tool = event.tool or name
66+
return event
67+
68+
69+
def _because(reason, note):
70+
"""Keep the handler's own reason and add why the outcome changed shape."""
71+
return "%s (%s)" % (reason, note) if reason else note
72+
73+
74+
def _wire_of(cfg, event):
75+
"""The wire name to answer at: the payload's own, `tool` where `parse` kept it there,
76+
else the entry's default gate."""
77+
name = (event.raw or {}).get("hook_event_name")
78+
if name in cfg["events"]:
79+
return name
80+
return event.tool if event.tool in cfg["events"] else cfg["verdicts"].get("default_wire_event")
81+
82+
83+
def cursor_respond(cfg, decision, event):
84+
v = cfg["verdicts"]
85+
name = _wire_of(cfg, event)
86+
canonical = cfg["events"].get(name)
87+
88+
if canonical == FILE_CHANGED:
89+
return "", 0
90+
91+
if canonical in (POST_TOOL, TOOL_FAILURE):
92+
if decision.outcome in (DENY, ESCALATE):
93+
note = v["flag_note"] % (name, decision.reason or v["flag_note_default"])
94+
return _json.dumps({"additional_context": note}), 0
95+
return "", 0
96+
97+
if canonical == PROMPT_SUBMIT:
98+
payload = {"continue": decision.outcome not in (DENY, ESCALATE, TRANSFORM)}
99+
if decision.reason:
100+
payload["user_message"] = decision.reason
101+
return _json.dumps(payload), 0
102+
103+
gate = v["gates"].get(name)
104+
if gate is None or canonical != PRE_TOOL:
105+
return "", 0
106+
107+
words = v["words"]
108+
notes = v["degrade_notes"]
109+
reason = decision.reason
110+
111+
if decision.outcome == TRANSFORM:
112+
if gate["honours_transform"] and decision.updated_input is not None:
113+
payload = {"permission": words["allow"], "updated_input": decision.updated_input}
114+
else:
115+
payload = {"permission": words["block"]}
116+
reason = _because(reason, notes["transform"])
117+
elif decision.outcome == DENY:
118+
payload = {"permission": words["block"]}
119+
elif decision.outcome == ESCALATE:
120+
if gate["honours_escalate"]:
121+
payload = {"permission": words["escalate"]}
122+
else:
123+
note = notes["escalate_from_transform"] if degraded_from(decision) == TRANSFORM else notes["escalate"]
124+
payload = {"permission": words["block"]}
125+
reason = _because(reason, note % name)
126+
else:
127+
payload = {"permission": words["allow"]}
128+
129+
if reason and payload["permission"] != words["allow"]:
130+
payload["user_message"] = reason
131+
payload["agent_message"] = reason
132+
return _json.dumps(payload), 0

src/agentseam/adapters/_family.py

Lines changed: 51 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,13 @@
33
from __future__ import annotations
44

55
from . import _hook_json
6+
from ._antigravity import antigravity_claims, antigravity_parse, antigravity_respond
7+
from ._cursor import cursor_claims, cursor_parse, cursor_respond
68
from ._hook_entry import hook_entry_config, render_config
7-
from ._hook_json import hj_claims, hj_parse, hj_respond, hj_reverse
9+
from ._hook_json import hj_respond, hj_reverse
10+
from ._payload import hj_claims, hj_parse
811
from ._windows import powershell_command
12+
from ._windsurf import windsurf_claims, windsurf_parse, windsurf_respond
913

1014

1115
class ConfigAdapter:
@@ -48,10 +52,55 @@ def hook_config(self, canonical_events, command, matcher=None):
4852
return hook_entry_config(self.CONFIG, canonical_events, command, matcher)
4953

5054

55+
class _CursorAdapter(ConfigAdapter):
56+
"""F3: the G4 dialect, and the only hook_config that takes fail_closed."""
57+
58+
def claims(self, raw):
59+
return cursor_claims(self.CONFIG, raw)
60+
61+
def parse(self, raw):
62+
return cursor_parse(self.CONFIG, raw)
63+
64+
def respond(self, decision, event):
65+
return cursor_respond(self.CONFIG, decision, event)
66+
67+
def hook_config(self, canonical_events, command, matcher=None, fail_closed=True):
68+
return hook_entry_config(self.CONFIG, canonical_events, command, matcher, fail_closed)
69+
70+
71+
class _WindsurfAdapter(ConfigAdapter):
72+
"""F4: the G5 exit-code dialect."""
73+
74+
def claims(self, raw):
75+
return windsurf_claims(self.CONFIG, raw)
76+
77+
def parse(self, raw):
78+
return windsurf_parse(self.CONFIG, raw)
79+
80+
def respond(self, decision, event):
81+
return windsurf_respond(self.CONFIG, decision, event)
82+
83+
84+
class _AntigravityAdapter(ConfigAdapter):
85+
"""F5: shape-inferred events over the engine's G1 renderer."""
86+
87+
def claims(self, raw):
88+
return antigravity_claims(self.CONFIG, raw)
89+
90+
def parse(self, raw):
91+
return antigravity_parse(self.CONFIG, raw)
92+
93+
def respond(self, decision, event):
94+
return antigravity_respond(self.CONFIG, decision, event)
95+
96+
97+
_FAMILY_ADAPTERS = {"cursor": _CursorAdapter, "windsurf": _WindsurfAdapter, "antigravity": _AntigravityAdapter}
98+
99+
51100
def _hook_entry_windows(cfg):
52101
return tuple(cfg["hook_entry"].get("entry_extra", {}))
53102

54103

55104
def bind(cfg):
56105
"""The adapter object for one vendor config entry."""
57-
return ConfigAdapter(cfg)
106+
return _FAMILY_ADAPTERS.get(cfg["family"], ConfigAdapter)(cfg)

src/agentseam/adapters/_hook_entry.py

Lines changed: 33 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,11 +21,16 @@ def _hook_dict(cfg, command):
2121
return entry
2222

2323

24-
def hook_entry_config(cfg, canonical_events, command, matcher=None):
25-
"""The vendor's hooks-config fragment wiring `command` for these canonical events."""
24+
def hook_entry_config(cfg, canonical_events, command, matcher=None, fail_closed=True):
25+
"""The vendor's hooks-config fragment wiring `command` for these canonical events.
26+
27+
`fail_closed` is read only by the `cursor` wrapper, whose gates fail open unless the
28+
entry says otherwise; a False installs an observer, not a gate.
29+
"""
2630
hook_entry = cfg["hook_entry"]
2731
reverse = hj_reverse(cfg)
28-
if hook_entry["wrapper"] == "flat_list":
32+
wrapper = hook_entry["wrapper"]
33+
if wrapper == "flat_list":
2934
rules = []
3035
for ev in canonical_events:
3136
name = reverse.get(ev)
@@ -36,6 +41,29 @@ def hook_entry_config(cfg, canonical_events, command, matcher=None):
3641
rule["matcher"] = matcher
3742
rules.append(rule)
3843
return rules
44+
if wrapper == "cursor":
45+
gates = cfg["verdicts"]["answer_events"]
46+
hooks = {}
47+
for ev in canonical_events:
48+
name = reverse.get(ev)
49+
if not name:
50+
continue
51+
entry = {"command": command}
52+
if fail_closed and name in gates:
53+
entry["failClosed"] = True
54+
hooks.setdefault(name, []).append(entry)
55+
return {"version": 1, "hooks": hooks}
56+
if wrapper == "flat_entries":
57+
hooks = {}
58+
for ev in canonical_events:
59+
name = reverse.get(ev)
60+
if not name:
61+
continue
62+
hooks.setdefault(name, []).append({"command": command})
63+
extra = hook_entry.get("also_wires", {}).get(ev)
64+
if extra:
65+
hooks.setdefault(extra, []).append({"command": command})
66+
return {"hooks": hooks}
3967
hooks = {}
4068
for ev in canonical_events:
4169
name = reverse.get(ev)
@@ -45,6 +73,8 @@ def hook_entry_config(cfg, canonical_events, command, matcher=None):
4573
if matcher and hook_entry["matcher"]:
4674
entry["matcher"] = matcher
4775
hooks.setdefault(name, []).append(entry)
76+
if hook_entry.get("group"):
77+
return {hook_entry["group"]: hooks}
4878
return hooks if hook_entry.get("bare") else {"hooks": hooks}
4979

5080

0 commit comments

Comments
 (0)