Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .agents/plugins/marketplace.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
{
"name": "flow",
"description": "Unified toolkit for Context-Driven Development with spec-first planning, TDD workflow, and OKF knowledge bundles.",
"version": "0.23.0",
"version": "0.23.1",
"source": {
"source": "local",
"path": "./plugins/flow"
Expand Down
2 changes: 1 addition & 1 deletion .claude-plugin/marketplace.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
{
"name": "flow",
"description": "Unified toolkit for Context-Driven Development with spec-first planning, TDD workflow, and OKF knowledge bundles.",
"version": "0.23.0",
"version": "0.23.1",
"source": "./",
"author": {
"name": "cofin"
Expand Down
2 changes: 1 addition & 1 deletion .claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "flow",
"description": "Unified toolkit for Context-Driven Development with spec-first planning, TDD workflow, and OKF knowledge bundles.",
"version": "0.23.0",
"version": "0.23.1",
"author": {
"name": "cofin"
},
Expand Down
2 changes: 1 addition & 1 deletion .codex-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "flow",
"version": "0.23.0",
"version": "0.23.1",
"description": "Unified toolkit for Context-Driven Development with spec-first planning, TDD workflow, and OKF knowledge bundles.",
"author": {
"name": "cofin"
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ agy plugin install https://github.com/cofin/flow

This uses Antigravity's documented Plugins & Skills installer. Flow ships the
plugin manifest at `plugin.json`, the model-decision rule under `rules/`, and
the static PreInvocation routing manifest at `hooks/hooks-agy.json`.
the static PreInvocation routing manifest at `hooks.json` (`hooks/hooks-agy.json`).

After installing or updating the plugin, restart Antigravity so the plugin manifest, skills, agents, and hooks are reloaded.

Expand Down
2 changes: 1 addition & 1 deletion docs/antigravity.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ agy plugin install https://github.com/cofin/flow
|---|---|---|
| Plugin manifest | `plugin.json` | plugin identity and metadata |
| Operational rule | `rules/flow-antigravity.md` | `model_decision` activation and structured-choice view |
| Hook manifest | `hooks/hooks-agy.json` | static `PreInvocation` routing registration |
| Hook manifest | `hooks.json` (`hooks/hooks-agy.json`) | static `PreInvocation` routing registration |
| Hook emitter | `hooks/agy-pre-invocation.sh` | one bounded fixed JSON envelope |
| Subagents | `agents/*.md` | canonical lifecycle, state, correctness, and quality agents |
| Skills | `skills/**/SKILL.md` | Flow router and lifecycle procedures |
Expand Down
11 changes: 11 additions & 0 deletions hooks.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"flow-priming": {
"PreInvocation": [
{
"type": "command",
"command": "bash \"${PLUGIN_ROOT:-${ANTIGRAVITY_PLUGIN_ROOT:-.}}/hooks/agy-pre-invocation.sh\"",
"timeout": 25
}
]
}
}
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "flow",
"version": "0.23.0",
"version": "0.23.1",
"description": "Unified toolkit for Context-Driven Development",
"type": "module",
"main": ".opencode/plugins/flow.js",
Expand Down
2 changes: 1 addition & 1 deletion plugins/flow/.codex-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "flow",
"version": "0.23.0",
"version": "0.23.1",
"description": "Unified toolkit for Context-Driven Development with spec-first planning, TDD workflow, and OKF knowledge bundles.",
"author": {
"name": "cofin"
Expand Down
4 changes: 2 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "flow"
version = "0.23.0"
version = "0.23.1"
description = "Unified toolkit for Context-Driven Development"
authors = [
{ name = "cofin" },
Expand Down Expand Up @@ -35,7 +35,7 @@ build = [
allow_dirty = true
commit = false
commit_args = "--no-verify"
current_version = "0.23.0"
current_version = "0.23.1"
ignore_missing_files = false
ignore_missing_version = false
message = "chore(release): bump to v{new_version}"
Expand Down
23 changes: 18 additions & 5 deletions tests/test_antigravity_hooks.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,10 @@ def _load_agy_hooks() -> dict:
return json.loads((REPO_ROOT / "hooks" / "hooks-agy.json").read_text(encoding="utf-8"))


def _load_root_hooks() -> dict:
return json.loads((REPO_ROOT / "hooks.json").read_text(encoding="utf-8"))


def test_antigravity_root_plugin_manifest_exists() -> None:
manifest_path = REPO_ROOT / "plugin.json"

Expand All @@ -21,13 +25,22 @@ def test_antigravity_root_plugin_manifest_exists() -> None:
assert manifest["name"] == "flow"


def test_antigravity_root_hooks_manifest_matches_harness_source() -> None:
root_hooks_path = REPO_ROOT / "hooks.json"
agy_hooks_path = REPO_ROOT / "hooks" / "hooks-agy.json"

assert root_hooks_path.is_file()
assert agy_hooks_path.is_file()
assert _load_root_hooks() == _load_agy_hooks()


def test_antigravity_hooks_use_only_real_events() -> None:
# Antigravity has no SessionStart event; priming must ride PreInvocation.
hooks = _load_agy_hooks()
events = {event for spec in hooks.values() for event in spec}
assert events, "hooks-agy.json must define at least one hook event"
assert events <= ANTIGRAVITY_HOOK_EVENTS
assert "PreInvocation" in events
for hooks in (_load_root_hooks(), _load_agy_hooks()):
events = {event for spec in hooks.values() for event in spec}
assert events, "hooks manifest must define at least one hook event"
assert events <= ANTIGRAVITY_HOOK_EVENTS
assert "PreInvocation" in events


def test_antigravity_hook_commands_are_python_free_and_root_anchored() -> None:
Expand Down
40 changes: 19 additions & 21 deletions tests/test_antigravity_manifest.py
Original file line number Diff line number Diff line change
Expand Up @@ -73,16 +73,11 @@ def test_antigravity_hook_command_validation_rejects_legacy_extension_tokens(
(tmp_path / "hooks.json").write_text(
json.dumps(
{
"hooks": {
"SessionStart": [
"flow-priming": {
"PreInvocation": [
{
"matcher": "*",
"hooks": [
{
"type": "command",
"command": "bash ${extensionPath}${/}hooks${/}session-start.sh",
}
],
"type": "command",
"command": "bash ${extensionPath}${/}hooks${/}agy-pre-invocation.sh",
}
]
}
Expand All @@ -108,21 +103,24 @@ def test_antigravity_hook_command_validation_accepts_plugin_root_ladder(
),
encoding="utf-8",
)
hook_payload = {
"flow-priming": {
"PreInvocation": [
{
"type": "command",
"command": 'r="${ANTIGRAVITY_PLUGIN_ROOT:-${PLUGIN_ROOT:-${AGY_PLUGIN_ROOT:-}}}"; bash "$r/hooks/agy-pre-invocation.sh"',
}
]
}
}
(tmp_path / "hooks.json").write_text(
json.dumps(hook_payload),
encoding="utf-8",
)
hooks_dir = tmp_path / "hooks"
hooks_dir.mkdir(exist_ok=True)
(hooks_dir / "hooks-agy.json").write_text(
json.dumps(
{
"hooks": {
"SessionStart": [
{
"type": "command",
"command": 'r="${ANTIGRAVITY_PLUGIN_ROOT:-${PLUGIN_ROOT:-${AGY_PLUGIN_ROOT:-}}}"; bash "$r/hooks/session-start.sh"',
}
]
}
}
),
json.dumps(hook_payload),
encoding="utf-8",
)

Expand Down
4 changes: 3 additions & 1 deletion tests/test_hook_scripts.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,9 @@
HOOKS / "session-start.cmd",
)
AGY_ENTRYPOINTS = (HOOKS / "agy-pre-invocation.sh", HOOKS / "agy-pre-invocation.ps1")
MANIFESTS = tuple(sorted(HOOKS.glob("hooks-*.json")))
MANIFESTS = tuple(sorted(HOOKS.glob("hooks-*.json"))) + (
(REPO_ROOT / "hooks.json",) if (REPO_ROOT / "hooks.json").is_file() else ()
)
POWERSHELL = shutil.which("pwsh") or shutil.which("powershell")
NODE = shutil.which("node")
BASH = shutil.which("bash")
Expand Down
118 changes: 71 additions & 47 deletions tools/validate.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ def _toml_loads(text: str) -> dict[str, Any]:
CLAUDE_AGENTS_DIR = REPO_ROOT / ".claude-plugin" / "agents"
CODEX_AGENTS_DIR = REPO_ROOT / ".codex" / "agents"
VSCODE_AGENTS_DIR = REPO_ROOT / ".github" / "agents"
SHIPPED_ROOT_FILES = ("AGENTS.md", "CONTRIBUTING.md", "README.md")
SHIPPED_ROOT_FILES = ("AGENTS.md", "CONTRIBUTING.md", "README.md", "hooks.json")

PUBLIC_LOCK_SOURCE_HOSTS = frozenset({"pypi.org", "files.pythonhosted.org"})
_LOCK_URL_PATTERN = re.compile(r'https?://[^"\s]+')
Expand Down Expand Up @@ -1226,9 +1226,10 @@ def iter_claude_hook_configs() -> Iterator[Path]:


def iter_antigravity_hook_configs() -> Iterator[Path]:
candidate = REPO_ROOT / "hooks" / "hooks-agy.json"
if candidate.is_file():
yield candidate
for relative in ("hooks.json", "hooks/hooks-agy.json"):
candidate = REPO_ROOT / relative
if candidate.is_file():
yield candidate


def iter_all_shipped_files() -> Iterator[Path]:
Expand Down Expand Up @@ -1865,58 +1866,81 @@ def _iter_hook_commands(hooks_manifest: object) -> Iterator[str]:


def validate_antigravity_hook_commands(repo_root: Path) -> list[Violation]:
path = repo_root / "hooks" / "hooks-agy.json"
root_hooks = repo_root / "hooks.json"
agy_hooks = repo_root / "hooks" / "hooks-agy.json"
violations: list[Violation] = []
if not path.is_file():
return [Violation(path, None, "missing hooks/hooks-agy.json")]
try:
data = json.loads(path.read_text(encoding="utf-8"))
except (json.JSONDecodeError, OSError) as exc:
return [Violation(path, 1, f"JSON parse error: {exc}")]

if not isinstance(data, dict):
return [Violation(path, 1, "hooks manifest must be a JSON object")]

commands = [
handler.get("command")
for events in data.values()
if isinstance(events, dict)
for handlers in events.values()
if isinstance(handlers, list)
for handler in handlers
if isinstance(handler, dict) and isinstance(handler.get("command"), str)
]
if not commands:
return [Violation(path, 1, "no command hooks found in Antigravity manifest")]
for command in commands:
if "python" in command:
violations.append(
Violation(
path,
1,
f"Antigravity hook commands must not require Python at runtime: {command!r}",
targets = [p for p in (root_hooks, agy_hooks) if p.is_file()]
if not targets:
return [Violation(root_hooks, None, "missing root hooks.json and hooks/hooks-agy.json")]

if root_hooks.is_file() and agy_hooks.is_file():
try:
root_data = json.loads(root_hooks.read_text(encoding="utf-8"))
agy_data = json.loads(agy_hooks.read_text(encoding="utf-8"))
if root_data != agy_data:
violations.append(
Violation(
root_hooks,
1,
"root hooks.json and hooks/hooks-agy.json must have identical content",
)
)
)
except (json.JSONDecodeError, OSError) as exc:
violations.append(Violation(root_hooks, 1, f"JSON parse error comparing hooks: {exc}"))

for command in commands:
for token in ("${extensionPath}", "${/}"):
if token in command:
for path in targets:
try:
data = json.loads(path.read_text(encoding="utf-8"))
except (json.JSONDecodeError, OSError) as exc:
violations.append(Violation(path, 1, f"JSON parse error: {exc}"))
continue

if not isinstance(data, dict):
violations.append(Violation(path, 1, "hooks manifest must be a JSON object"))
continue

commands = [
handler.get("command")
for events in data.values()
if isinstance(events, dict)
for handlers in events.values()
if isinstance(handlers, list)
for handler in handlers
if isinstance(handler, dict) and isinstance(handler.get("command"), str)
]
if not commands:
violations.append(Violation(path, 1, "no command hooks found in Antigravity manifest"))
continue
for command in commands:
if "python" in command:
violations.append(
Violation(
path, 1, f"unsupported template token {token!r} in hook command"
path,
1,
f"Antigravity hook commands must not require Python at runtime: {command!r}",
)
)
if not any(
token in command
for token in ("ANTIGRAVITY_PLUGIN_ROOT", "AGY_PLUGIN_ROOT", "PLUGIN_ROOT")
):
violations.append(
Violation(
path,
1,
f"hook command must resolve an Antigravity plugin root: {command!r}",

for command in commands:
for token in ("${extensionPath}", "${/}"):
if token in command:
violations.append(
Violation(
path, 1, f"unsupported template token {token!r} in hook command"
)
)
if not any(
token in command
for token in ("ANTIGRAVITY_PLUGIN_ROOT", "AGY_PLUGIN_ROOT", "PLUGIN_ROOT")
):
violations.append(
Violation(
path,
1,
f"hook command must resolve an Antigravity plugin root: {command!r}",
)
)
)
return violations


Expand Down
Loading
Loading