feat(cursor): port subagent skill awareness to subagentStart

The third and last capability the ledger recorded as downgraded, replaced by a
static preamble in each agent file. Cursor's subagentStart response accepts
additional_context and its query carries subagent_type, which is the only field
the canonical hook reads, so the port is a field rename.

This is additive, not a replacement. The generated preamble carries an agent's
own declared skills; the hook carries the general skill-awareness text gated by
agent type. Both now ship.

Verified as far as a free Cursor plan allows: the hook registers, fires, and
delivers a payload carrying subagent_type, task, and subagent_model — confirmed
against a live Cursor 3.17.8 delegation, which fires subagentStart three times
before refusing to start the subagent with "Named models unavailable. Free
plans can only use Auto." What a free plan cannot show is whether the returned
context reaches a subagent that never starts. The docs and the submission
matrix say so rather than implying full verification. If delivery does not
work, the preamble still carries the declared skills, so the failure mode is
the status quo rather than a regression.

The context guard needed widening: skill awareness is legitimately
multi-paragraph, where the router and crash hints are single-line. Newline and
tab are now permitted, every other control character is still rejected, and the
bound is separate at 8 KiB.
This commit is contained in:
Charles Wiltgen
2026-08-23 13:18:08 -07:00
parent 3bf999dfc3
commit 8a7086351a
15 changed files with 333 additions and 14 deletions
+1
View File
@@ -116,6 +116,7 @@ Confirm Cursor shows the plugin as installed from the local marketplace and that
| No-argument command | Run one `/axiom-*` command without arguments | Native command appears and follows its translated workflow | _pending_ |
| Argument command | Run one argument-bearing command with benign and injection-oriented text | Arguments remain task input, not shell interpolation or authorization | _pending_ |
| Session hook | Start Apple-positive and non-Apple sessions | Compact context appears only where project detection allows it | _pending_ |
| Subagent start hook | Delegate to one named Axiom agent on a plan that permits subagents | Skill-awareness context reaches the subagent; this is the one hook a free plan cannot demonstrate, so record the plan tier | _pending_ |
| Crash read hook | Open an `.ips` path, then an ordinary source file | Crash path yields advisory context naming the `axiom_xcsym_crash` MCP tool and never a bare `xcsym` command; ordinary file yields nothing; neither read is gated | _pending_ |
| Prompt router hook | Send an Apple-platform prompt in an Apple project, then the same prompt in a non-Apple one | Router guidance is injected only in the Apple project; the model names the matched skill | _pending_ |
| Shell hook | Exercise a fixture that emits a known Axiom hint | Advisory `additional_context`; no permission or denial field | _pending_ |
+6
View File
@@ -13,6 +13,12 @@
"timeout": 5
}
],
"subagentStart": [
{
"command": "python3 ./scripts/cursor-hook-adapter.py subagent-start",
"timeout": 5
}
],
"preToolUse": [
{
"command": "python3 ./scripts/cursor-hook-adapter.py pretool-read",
+2 -2
View File
@@ -407,7 +407,7 @@
"PostToolUse(Write|Edit)": "postToolUse.additional_context",
"PreToolUse(Read)": "preToolUse.additional_context",
"SessionStart": "sessionStart.additional_context",
"SubagentStart": "prompt",
"SubagentStart": "subagentStart.additional_context",
"UserPromptSubmit": "beforeSubmitPrompt.additional_context",
"per-agent PreToolUse": "advisory"
},
@@ -1305,7 +1305,7 @@
"owner": null,
"event": "SubagentStart",
"matcher": null,
"disposition": "prompt",
"disposition": "subagentStart.additional_context",
"warning": null,
"advisory": null
},
+13 -8
View File
@@ -313,8 +313,8 @@
},
{
"path": "hooks/hooks.json",
"sha256": "e84521fd58405fdedef4632c493d5d1b4f8cca99cca171ceec53e86c07646083",
"bytes": 802
"sha256": "9bceb38ff9082316e23ef2f176a3b3d9cd7ed30569fc9119e8dc1b166ca2dff3",
"bytes": 947
},
{
"path": "mcp.json",
@@ -323,13 +323,13 @@
},
{
"path": "reports/capability-disposition.json",
"sha256": "51531c8f398bb98a5df45e63235315f1281edd676693e8381ae55127cc55409c",
"bytes": 35428
"sha256": "8a98c10c8e8cb98d94ad496aca5069225bfffb3d053601d4a86f1412436234bf",
"bytes": 35480
},
{
"path": "scripts/cursor-hook-adapter.py",
"sha256": "057e22bb2f4df0fc1cd143abb6250876238c1039182e70f097291532ebf53a14",
"bytes": 24459
"sha256": "d581f30fb83c2350f765d0d0acce4cc1497cd16f16a2be5360b0528f0d090565",
"bytes": 26497
},
{
"path": "scripts/posttool-bash-hints.py",
@@ -346,6 +346,11 @@
"sha256": "8fbd3975b329995d563c2f129c56c49c5056ea6a6f26541131c7c065c0c5be7c",
"bytes": 6103
},
{
"path": "scripts/subagent-start.py",
"sha256": "98fbef899fd412e0f54f6b98ac9de8db7639ae17726d98e25b082e107a29b2ea",
"bytes": 6008
},
{
"path": "scripts/swift-guardrails.py",
"sha256": "700681916e570fb6e31208241c1a071c7c80b4d2cfa937b119464e937e7aaaa6",
@@ -1858,8 +1863,8 @@
}
],
"totals": {
"files": 371,
"bytes": 7123102
"files": 372,
"bytes": 7131345
},
"excludedMirrors": 30,
"classes": {
+52
View File
@@ -29,6 +29,8 @@ MAX_POST_WRITE_FILE_BYTES = 1024 * 1024
MIN_ROUTED_PROMPT_CHARS = 5
MAX_ROUTED_PROMPT_CHARS = 2000
MAX_ROUTED_CONTEXT_CHARS = 2048
# Skill-awareness guidance is legitimately multi-paragraph.
MAX_SUBAGENT_CONTEXT_CHARS = 8192
# Cursor gives this hook 5 seconds. Leave 1.25 seconds for process-group teardown
# and JSON emission if the canonical child reaches its internal deadline.
CHILD_TIMEOUT_SECONDS = 3.75
@@ -268,6 +270,46 @@ def pretool_read(payload: Dict[str, Any]) -> Dict[str, str]:
return {"additional_context": _cursor_crash_hint(context)}
def subagent_start(payload: Dict[str, Any]) -> Dict[str, str]:
"""Port of the canonical SubagentStart skill awareness to Cursor's subagentStart.
Cursor names the field `subagent_type`; the canonical hook reads `agent_type`.
This is additive to the per-agent Required Skills preamble in the generated agent
files: the preamble carries that agent's declared skills, this carries the general
skill-usage awareness the canonical hook injects, gated by agent type.
"""
subagent_type = payload.get("subagent_type")
if not isinstance(subagent_type, str) or not subagent_type:
return {}
if _has_control_characters(subagent_type):
return {}
child_output = run_child(
"subagent-start.py",
{"agent_type": subagent_type},
cwd=_workspace_root(payload),
)
if not child_output.strip():
return {}
try:
response = json.loads(child_output)
except json.JSONDecodeError:
raise AdapterError("invalid child JSON")
if not isinstance(response, dict):
raise AdapterError("invalid child JSON")
specific = response.get("hookSpecificOutput")
if not isinstance(specific, dict):
return {}
context = specific.get("additionalContext")
if not isinstance(context, str):
return {}
context = context.strip()
if not context:
return {}
if len(context) > MAX_SUBAGENT_CONTEXT_CHARS or _has_unsafe_control_characters(context):
raise AdapterError("unexpected subagent context")
return {"additional_context": context}
def prompt_submit(payload: Dict[str, Any]) -> Dict[str, str]:
"""Port of the canonical UserPromptSubmit router to Cursor's beforeSubmitPrompt.
@@ -370,6 +412,14 @@ def post_shell(payload: Dict[str, Any]) -> Dict[str, str]:
return {"additional_context": "\n".join(lines)} if lines else {}
def _has_unsafe_control_characters(value: str) -> bool:
"""Control characters other than newline and tab.
Multi-line guidance is legitimate for injected context; raw escapes and NULs are not.
"""
return any((ord(ch) < 32 or ord(ch) == 127) and ch not in "\n\t" for ch in value)
def _has_control_characters(value: str) -> bool:
return any(ord(character) < 32 or ord(character) == 127 for character in value)
@@ -606,6 +656,8 @@ def dispatch(mode: str, payload: Dict[str, Any]) -> Dict[str, str]:
return prompt_submit(payload)
if mode == "pretool-read":
return pretool_read(payload)
if mode == "subagent-start":
return subagent_start(payload)
raise AdapterError("unknown mode")
+136
View File
@@ -0,0 +1,136 @@
#!/usr/bin/env python3
"""SubagentStart hook for Axiom plugin.
Injects compact Axiom skill awareness into subagents so they use skills.
Standalone Python (matching pretool-crash-route.py / posttool-bash-hints.py /
user-prompt-submit.py) — NOT embedded in a bash heredoc. The heredoc-in-bash
pattern breaks under macOS bash 3.2 whenever a prose apostrophe lands in the
body; plain .py avoids that and is directly lintable/testable.
Reads a JSON payload on stdin, writes a JSON response on stdout. Never exits
non-zero — a hook failure must not block subagent startup.
RESERVED AGENT-TYPE SUFFIX: an agent whose type ends in `-noskills` receives no
injection. It is reserved for agents that must measure un-assisted behavior —
A/B testing a skill's value needs a control arm that genuinely lacks the skill.
The SubagentStart payload exposes only `agent_type` (no prompt text, no
per-invocation env), so agent naming is the only channel available for this.
Name an agent `-noskills` only when you intend it; the suppression is silent
apart from a stderr note.
SCOPE: this suppresses THIS hook only. Axiom's PostToolUse hooks still fire for
such an agent — `posttool-bash-hints.py` appends skill hints to Bash output and
`swift-guardrails.py` can block a Write/Edit citing an Axiom rule — and their
payloads carry no `agent_type` to gate on. An agent that must stay clean should
also be denied Bash/Write/Edit in its own definition.
"""
from __future__ import annotations
import json
import os
import sys
try:
input_data = json.load(sys.stdin)
agent_type = input_data.get("agent_type", "")
except Exception:
print("{}")
sys.exit(0)
# Project-type gate (GH #48). Like session-start.py / user-prompt-submit.py, stay
# silent in non-Apple projects and honor AXIOM_SESSION_CONTEXT — otherwise a
# generic subagent (general-purpose, Explore, ...) spun up in a Python or docs
# repo gets Axiom iOS routing pressure injected into its context. Fail-open in
# BOTH directions: a missing module or detection error falls through to injection
# rather than silencing a real Apple project (resolve_context_decision is itself
# fail-open). stdin is already drained above, so this early exit leaves no unread
# pipe. CPython puts this script's dir on sys.path[0] regardless of cwd; the
# explicit insert only hardens the unusual -c / -m / symlink invocation.
_hook_dir = os.path.dirname(os.path.abspath(__file__))
if _hook_dir not in sys.path:
sys.path.insert(0, _hook_dir)
try:
from project_detect import resolve_context_decision
if not resolve_context_decision(os.getcwd(), os.environ.get("AXIOM_SESSION_CONTEXT")):
print("{}")
sys.exit(0)
except Exception:
pass # fail-open: detection unavailable → proceed with skill injection
# Skip agents that won't benefit from Axiom skills
skip_types = {
"statusline-setup",
"claude-code-guide",
"episodic-memory:search-conversations",
"beads:task-agent",
"plugin-dev:skill-reviewer",
"plugin-dev:plugin-validator",
"plugin-dev:agent-creator",
"plugin-dev:skill-development",
"plugin-dev:command-development",
"plugin-dev:hook-development",
"plugin-dev:plugin-structure",
"plugin-dev:agent-development",
"plugin-dev:plugin-settings",
"plugin-dev:mcp-integration",
"plugin-dev:create-plugin",
"code-simplifier:code-simplifier",
}
if agent_type in skip_types:
print("{}")
sys.exit(0)
# Also skip any agent type containing known non-iOS plugin prefixes
skip_prefixes = ("beads:", "plugin-dev:", "superpowers-lab:", "superpowers-developing-for-claude-code:")
if any(agent_type.startswith(p) for p in skip_prefixes):
print("{}")
sys.exit(0)
# Reserved "-noskills" suffix (see module docstring): the agent is deliberately
# measuring UNSKILLED behavior, so injecting the roster would corrupt it. The
# SubagentStart payload carries only agent_type — never the prompt — so a prompt
# saying "do not use Axiom skills" cannot suppress this hook. A naming convention
# is the only discriminator available. Announce on stderr so a surprise
# suppression is debuggable; stderr is advisory and does not affect the stdout
# contract at exit 0.
if agent_type.endswith("-noskills"):
sys.stderr.write(
f"axiom: no skill injection for '{agent_type}' (reserved -noskills suffix)\n"
)
print("{}")
sys.exit(0)
context = """You have access to Axiom iOS development skills via the Skill tool. If your task involves iOS, Swift, Xcode, or Apple frameworks, invoke the matching skill BEFORE doing the work:
- `axiom-build` — build failures, Xcode, simulator, SPM
- `axiom-swiftui` — SwiftUI views, navigation, layout, animation, architecture
- `axiom-data` — SwiftData, Core Data, CloudKit, migrations, Codable
- `axiom-concurrency` — async/await, actors, Sendable, data races
- `axiom-performance` — memory leaks, profiling, battery, Instruments
- `axiom-networking` — URLSession, Network.framework, HTTP
- `axiom-integration` — widgets, Siri, StoreKit, EventKit, push, background tasks
- `axiom-media` — camera, photos, audio, haptics, ShazamKit, Now Playing
- `axiom-accessibility` — VoiceOver, Dynamic Type, WCAG
- `axiom-ai` — Foundation Models, Apple Intelligence
- `axiom-games` — SpriteKit, SceneKit, RealityKit
- `axiom-shipping` — App Store submission, rejections, privacy manifests
- `axiom-macos` — macOS windows, menus, sandboxing, distribution, AppKit bridging
- `axiom-design` — HIG patterns, Liquid Glass, SF Symbols, typography, app structure
- `axiom-swift` — Swift idioms, noncopyable types, drag and drop, tvOS
- `axiom-uikit` — UIKit/SwiftUI bridging, Auto Layout, Combine, TextKit
- `axiom-location` — Core Location, MapKit, geofencing, directions
Invoke with: Skill tool, skill name (e.g., "axiom-swiftui")."""
output = {
"hookSpecificOutput": {
"hookEventName": "SubagentStart",
"additionalContext": context,
}
}
print(json.dumps(output))
+2 -1
View File
@@ -67,8 +67,9 @@ Cursor does not provide Axiom's canonical per-agent tool allowlists. Every gener
## Hooks
The plugin registers supported Cursor hooks for session start, prompt submission, file reads, and post-tool shell/write events. They add routing hints or diagnostics as `additional_context` where available.
The plugin registers supported Cursor hooks for session start, prompt submission, subagent start, file reads, and post-tool shell/write events. They add routing hints or diagnostics as `additional_context` where available.
- The subagent hook adds Axiom skill awareness when a subagent starts, alongside the per-agent Required Skills preamble in the agent file. The hook is confirmed to fire and receive the subagent type; whether its context reaches the subagent has not been verified, because a Cursor plan that refuses to start subagents cannot demonstrate it. If it does not, the preamble still carries each agent's declared skills.
- The read hook routes crash reports. Opening an `.ips`, legacy `.crash`, or `.xccrashpoint` path adds a note pointing at the `axiom_xcsym_crash` MCP tool instead of reading the raw file. It emits no `permission` field, so it never gates the read.
- The prompt hook is the per-prompt router. Cursor's `beforeSubmitPrompt` supplies the prompt text and accepts `additional_context`, so Axiom's canonical routing carries over: a prompt that matches a router is annotated with the skill to invoke before the model answers. It stays silent outside an Apple project and on prompts under five characters.
+1 -1
View File
@@ -208,7 +208,7 @@ test("hook counts and dispositions remain tied to canonical sources", () => {
) {
expected = "postToolUse.additional_context";
} else if (event === "SubagentStart" && entry.matcher === undefined) {
expected = "prompt";
expected = "subagentStart.additional_context";
} else {
throw new Error(`unreviewed canonical global hook: ${key}`);
}
+1 -1
View File
@@ -87,7 +87,7 @@ export const CURSOR_HOOK_DISPOSITIONS = Object.freeze({
"PreToolUse(Read)": "preToolUse.additional_context",
"PostToolUse(Bash)": "postToolUse.additional_context",
"PostToolUse(Write|Edit)": "postToolUse.additional_context",
SubagentStart: "prompt",
SubagentStart: "subagentStart.additional_context",
"per-agent PreToolUse": "advisory",
});
@@ -104,5 +104,25 @@
"workspace_roots": [
"/private/tmp/claude-501/-Users-someone-Projects-Example/00000000-1111-2222-3333-444444444444/scratchpad/cursor-hook-probe"
]
},
"subagent-start": {
"conversation_id": "<redacted-conversation_id>",
"cursor_version": "3.17.8",
"generation_id": "<redacted-generation_id>",
"hook_event_name": "subagentStart",
"is_parallel_worker": false,
"model": "Cursor Grok 4.6",
"parent_conversation_id": "<redacted-parent_conversation_id>",
"session_id": "<redacted-session_id>",
"subagent_id": "<redacted-subagent_id>",
"subagent_model": "cursor-grok-4.6-medium",
"subagent_type": "memory-auditor",
"task": "You are the memory-auditor. Run your audit and return your complete output.",
"tool_call_id": "<redacted-tool_call_id>",
"transcript_path": "<redacted-transcript_path>",
"user_email": "<redacted-user_email>",
"workspace_roots": [
"<workspace>"
]
}
}
+4
View File
@@ -94,6 +94,9 @@ test("renders the native Cursor hook manifest and non-executable runtime copies"
beforeSubmitPrompt: [
{ command: "python3 ./scripts/cursor-hook-adapter.py prompt-submit", timeout: 5 },
],
subagentStart: [
{ command: "python3 ./scripts/cursor-hook-adapter.py subagent-start", timeout: 5 },
],
preToolUse: [
{ command: "python3 ./scripts/cursor-hook-adapter.py pretool-read", matcher: "Read", timeout: 5 },
],
@@ -109,6 +112,7 @@ test("renders the native Cursor hook manifest and non-executable runtime copies"
"scripts/posttool-bash-hints.py",
"scripts/pretool-crash-route.py",
"scripts/project_detect.py",
"scripts/subagent-start.py",
"scripts/swift-guardrails.py",
"scripts/user-prompt-submit.py",
]);
+4 -1
View File
@@ -23,6 +23,9 @@ const HOOKS_DOCUMENT = {
beforeSubmitPrompt: [
{ command: "python3 ./scripts/cursor-hook-adapter.py prompt-submit", timeout: 5 },
],
subagentStart: [
{ command: "python3 ./scripts/cursor-hook-adapter.py subagent-start", timeout: 5 },
],
preToolUse: [
{ command: "python3 ./scripts/cursor-hook-adapter.py pretool-read", matcher: "Read", timeout: 5 },
],
@@ -38,7 +41,7 @@ function readFile(source: string): string {
}
export function renderCursorHooks(): VirtualFile[] {
const runtime = ["pretool-crash-route.py", "project_detect.py", "posttool-bash-hints.py", "swift-guardrails.py", "user-prompt-submit.py"].map((filename) => ({
const runtime = ["pretool-crash-route.py", "project_detect.py", "posttool-bash-hints.py", "subagent-start.py", "swift-guardrails.py", "user-prompt-submit.py"].map((filename) => ({
path: `scripts/${filename}`,
content: readFile(path.join(canonicalHooksDirectory, filename)),
mode: 0o644 as const,
+38
View File
@@ -187,3 +187,41 @@ test("preToolUse(Read) never returns a permission field", () => {
fs.rmSync(workspace, { recursive: true, force: true });
}
});
test("subagentStart maps Cursor's subagent_type onto the canonical agent_type", () => {
// Captured from a live Cursor 3.17.8 delegation. The hook fires and delivers this
// payload even on a plan that then refuses to start the subagent.
const workspace = applePackage();
try {
const payload = { ...FIXTURE["subagent-start"], workspace_roots: [workspace] };
assert.equal(payload.subagent_type, "memory-auditor");
assert.ok(!("agent_type" in payload), "Cursor names the field subagent_type");
const { response, stderr } = invoke("subagent-start", payload);
assert.equal(stderr, "");
assert.match(
(response as { additional_context?: string }).additional_context ?? "",
/skill/i,
"a named Axiom agent must receive skill awareness",
);
} finally {
fs.rmSync(workspace, { recursive: true, force: true });
}
});
test("subagentStart stays silent for subagent types the router excludes", () => {
// Generic types like general-purpose are deliberately NOT excluded: a generic
// subagent working in an Apple project should still learn the skills exist.
const workspace = applePackage();
try {
for (const subagent_type of ["statusline-setup", "beads:task-agent", "probe-noskills", ""]) {
const payload = { ...FIXTURE["subagent-start"], workspace_roots: [workspace], subagent_type };
assert.deepEqual(
invoke("subagent-start", payload).response,
{},
`subagent_type=${JSON.stringify(subagent_type)}`,
);
}
} finally {
fs.rmSync(workspace, { recursive: true, force: true });
}
});
@@ -29,6 +29,8 @@ MAX_POST_WRITE_FILE_BYTES = 1024 * 1024
MIN_ROUTED_PROMPT_CHARS = 5
MAX_ROUTED_PROMPT_CHARS = 2000
MAX_ROUTED_CONTEXT_CHARS = 2048
# Skill-awareness guidance is legitimately multi-paragraph.
MAX_SUBAGENT_CONTEXT_CHARS = 8192
# Cursor gives this hook 5 seconds. Leave 1.25 seconds for process-group teardown
# and JSON emission if the canonical child reaches its internal deadline.
CHILD_TIMEOUT_SECONDS = 3.75
@@ -268,6 +270,46 @@ def pretool_read(payload: Dict[str, Any]) -> Dict[str, str]:
return {"additional_context": _cursor_crash_hint(context)}
def subagent_start(payload: Dict[str, Any]) -> Dict[str, str]:
"""Port of the canonical SubagentStart skill awareness to Cursor's subagentStart.
Cursor names the field `subagent_type`; the canonical hook reads `agent_type`.
This is additive to the per-agent Required Skills preamble in the generated agent
files: the preamble carries that agent's declared skills, this carries the general
skill-usage awareness the canonical hook injects, gated by agent type.
"""
subagent_type = payload.get("subagent_type")
if not isinstance(subagent_type, str) or not subagent_type:
return {}
if _has_control_characters(subagent_type):
return {}
child_output = run_child(
"subagent-start.py",
{"agent_type": subagent_type},
cwd=_workspace_root(payload),
)
if not child_output.strip():
return {}
try:
response = json.loads(child_output)
except json.JSONDecodeError:
raise AdapterError("invalid child JSON")
if not isinstance(response, dict):
raise AdapterError("invalid child JSON")
specific = response.get("hookSpecificOutput")
if not isinstance(specific, dict):
return {}
context = specific.get("additionalContext")
if not isinstance(context, str):
return {}
context = context.strip()
if not context:
return {}
if len(context) > MAX_SUBAGENT_CONTEXT_CHARS or _has_unsafe_control_characters(context):
raise AdapterError("unexpected subagent context")
return {"additional_context": context}
def prompt_submit(payload: Dict[str, Any]) -> Dict[str, str]:
"""Port of the canonical UserPromptSubmit router to Cursor's beforeSubmitPrompt.
@@ -370,6 +412,14 @@ def post_shell(payload: Dict[str, Any]) -> Dict[str, str]:
return {"additional_context": "\n".join(lines)} if lines else {}
def _has_unsafe_control_characters(value: str) -> bool:
"""Control characters other than newline and tab.
Multi-line guidance is legitimate for injected context; raw escapes and NULs are not.
"""
return any((ord(ch) < 32 or ord(ch) == 127) and ch not in "\n\t" for ch in value)
def _has_control_characters(value: str) -> bool:
return any(ord(character) < 32 or ord(character) == 127 for character in value)
@@ -606,6 +656,8 @@ def dispatch(mode: str, payload: Dict[str, Any]) -> Dict[str, str]:
return prompt_submit(payload)
if mode == "pretool-read":
return pretool_read(payload)
if mode == "subagent-start":
return subagent_start(payload)
raise AdapterError("unknown mode")
+1
View File
@@ -32,6 +32,7 @@ const RUNTIME_FILES = [
"hooks/posttool-bash-hints.py",
"hooks/pretool-crash-route.py",
"hooks/project_detect.py",
"hooks/subagent-start.py",
"hooks/swift-guardrails.py",
"hooks/user-prompt-submit.py",
];