Skip to content
Open
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
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
---
status: pending
status: done
---

<!-- Fill or omit these sections; never add, rename, or reorder one. -->
Expand All @@ -12,24 +12,37 @@ status: pending

```txt
cli/
β”œβ”€β”€ src/contexts/tools/domain/profiles/antigravity/
β”‚ β”œβ”€β”€ antigravity-hooks.ts βœ… named-hook shape: merge AIDD's `aidd-memory` key into `.agents/hooks.json`, strip it on uninstall, convert Claude-shaped flat hooks
β”‚ β”œβ”€β”€ profile.ts ✏️ HooksCapability on `.agents/hooks.json`
β”‚ └── build.ts ✏️ hooks artifact: scripts under `.agents/hooks/<plugin>/`, hooksMerge into `.agents/hooks.json`
β”œβ”€β”€ src/contexts/tools/domain/
β”‚ β”œβ”€β”€ formats/
β”‚ β”‚ β”œβ”€β”€ project-hooks-format.ts βœ… per-tool project hooks file strategy: merge, unmerge, contributed entries, empty check, script path and dir
β”‚ β”‚ └── cursor-hooks-project-merge.ts ✏️ Cursor's functions exposed as one `ProjectHooksFormat`, behaviour unchanged
β”‚ β”œβ”€β”€ capabilities/plugins-capability.ts ✏️ `hooksDestination: "project"` + `projectHooksRelativePath` + `projectHooksFormat` allowed on flat params too
β”‚ β”œβ”€β”€ build-contract.ts ✏️ `hooksMerge` receives the plugin name
β”‚ └── profiles/
β”‚ β”œβ”€β”€ cursor/profile.ts ✏️ declares Cursor's `ProjectHooksFormat`
β”‚ └── antigravity/
β”‚ β”œβ”€β”€ antigravity-hooks.ts βœ… named-hook shape: one `<plugin>` key, handlers under `SessionStart`, `cd .. && ` prefix, other events skipped with a warning
β”‚ β”œβ”€β”€ profile.ts ✏️ flat `acceptsHooks: true`, `hooksDestination: "project"` on `.agents/hooks.json`
β”‚ └── build.ts ✏️ hooks artifact: scripts under `.agents/hooks/<plugin>/`, hooksMerge into `.agents/hooks.json`
β”œβ”€β”€ src/contexts/framework/application/
β”‚ β”œβ”€β”€ framework/translator/project-hooks-materializer.ts ✏️ reads the tool's `ProjectHooksFormat`; invalid JSON names the file; messages name the tool
β”‚ └── shared/remove-project-hooks.ts ✏️ same, no Cursor import left
β”œβ”€β”€ src/contexts/translate/application/strategies/flat-build-strategy.ts ✏️ passes the plugin name to `hooksMerge`
β”œβ”€β”€ tests/contexts/tools/domain/profiles/antigravity/
β”‚ └── antigravity-hooks.unit.test.ts βœ… merge, idempotence, foreign keys kept, invalid JSON, strip
β”œβ”€β”€ tests/contexts/tools/domain/build-hooks-support-declaration.unit.test.ts ✏️ antigravity now supports hooks
β”‚ └── antigravity-hooks.unit.test.ts βœ… merge, idempotence, foreign keys kept, invalid JSON, unmerge, empty check, unmapped event warning
β”œβ”€β”€ tests/ (Cursor project-hooks suites) ✏️ only messages that named Cursor generically, if any; behaviour assertions untouched
β”œβ”€β”€ tests/ (antigravity profile, build, build-hooks-support-declaration, registry-conformance) ✏️ antigravity now delivers hooks to `.agents/hooks.json`
└── tests/golden/snapshots/framework-build/golden.json ✏️ `antigravity:flat` cell only
```

## User Journey

```mermaid
flowchart TD
A[aidd setup --ai antigravity] --> B[.agents/hooks.json gains key aidd-memory with SessionStart command]
A[aidd setup --ai antigravity] --> B[.agents/hooks.json gains key aidd-context with SessionStart command, script under .agents/hooks/aidd-context/]
B --> C[agy new session in trusted folder]
C --> D[log: loaded N named hooks; memory refresh runs at repo root]
E[aidd clean antigravity] --> F[aidd-memory key removed, other named hooks kept, file deleted only if empty]
E[aidd clean antigravity] --> F[aidd-context key and its scripts removed, other named hooks kept, file deleted only if empty]
```

## Test Scope
Expand All @@ -40,9 +53,9 @@ title: Test scope
---
journey
section Setup
agy 1.2.14 logged in, probe hook printing cwd and workspacePaths => measured cwd recorded: 5: system
agy 1.2.17 logged in, interactive session under a pty, probe hook printing cwd and workspacePaths => measured cwd recorded: 5: system
section Happy path
aidd setup --ai antigravity on empty project => .agents/hooks.json has aidd-memory SessionStart entry: 5: cli
aidd setup --ai antigravity on empty project => .agents/hooks.json has aidd-context SessionStart entry: 5: cli
second setup => file byte-identical: 5: cli
translate --to antigravity --as flat => same hooks.json shape and scripts under .agents/hooks/: 5: cli
section Edge case - user hooks present
Expand All @@ -55,25 +68,29 @@ journey

## Tasks to do

### `1)` Measure the hook `cwd` on 1.2.14
### `1)` Measure the hook `cwd` on 1.2.17

> #890 measured `cwd = <repo>/.agents` on 1.2.5; `update_memory.js` silently no-ops when `aidd_docs/` is not in `cwd`.

1. Local probe: a `SessionStart` hook writing `pwd` and its stdin payload to a marker; read the marker, not the turn.
2. Pick the command from the result: if `cwd` is `.agents`, the command must reach the repo root (e.g. `node ../.aidd/scripts/update_memory.cjs`, or a root resolved from `workspacePaths[0]`); record the evidence in the PR.
1. Local probe: a `SessionStart` hook writing `pwd` and its stdin payload to a marker; read the marker, not the turn. On 1.2.17 a print-mode turn never reaches the model and fires no hook at all, so the probe runs interactive under a pty: `script -q /dev/null agy -i "reply OK" --add-dir <ws>`, killed after 60s. Without `--add-dir` it loads 0 hooks.
2. Measured on 2026-10-05: `cwd` is `<repo>/.agents`, as on 1.2.5; stdin carries `workspacePaths[0]` = `<repo>`. A root-relative command needs a `cd .. && ` prefix, and `agy` runs it through a shell: a real session running `cd .. && node <root-relative script>` wrote `AIDD-MEMORY-MARKER cwd=<repo> docs=true`. Record the evidence in the PR.

### `2)` Named-hook merge, test first
### `2)` Named-hook format, test first

> Shape `{ "<name>": { "SessionStart": [ { "hooks": [ { "type": "command", "command": "…", "timeout": 30 } ] } ] } }`, no wrapper.
> Shape `{ "<name>": { "SessionStart": [ { "type": "command", "command": "…", "timeout": 30 } ] } }`: no top-level `hooks` wrapper, and handlers sit directly under the event. 1.2.17 rejects the Claude-style nested `[ { "hooks": [ … ] } ]` with `command hook must specify 'command'`; only `PreToolUse` and `PostToolUse` take a `matcher` entry.

1. Tests: empty file, existing foreign keys, re-run idempotent, invalid JSON fails loud (Codex's silent `catch { parsed = {} }` is not copied), strip leaves foreign keys.
2. Implement in `antigravity-hooks.ts`; mutate the foreign-key preservation and watch its test fail.
1. Tests: empty file, existing foreign keys, re-run idempotent, invalid JSON fails loud (Codex's silent `catch { parsed = {} }` is not copied), unmerge leaves foreign keys, empty check, an event other than `SessionStart` skipped with a warning (Claude and `agy` tool names differ; the telemetry host is out of scope).
2. Implement in `antigravity-hooks.ts` as a `ProjectHooksFormat`: the key is the plugin name, each command gets `cd .. && ` and runs `./.agents/hooks/<plugin>/<script>`. Mutate the foreign-key preservation and watch its test fail.

### `3)` Wire setup and translate
### `3)` Wire setup, clean and translate

1. `profile.ts`: `HooksCapability` targeting `.agents/hooks.json` with the merge and strip; `acceptsHooks` stays false for plugin flat install so the hook is not written twice.
2. `build.ts`: hooks `hooksBundle`, scripts to `.agents/hooks/<plugin>/`, `hooksMerge` + `hooksMergeDest` converting the rewritten Claude-shaped JSON to one named key per plugin.
3. Confirm the flat-rewritten script path resolves from the measured `cwd`.
> `HooksCapability` is not the route: no config ref `codex-hooks` exists, so Codex's own declaration never writes anything and nothing installs `.aidd/scripts/update_memory.cjs`. The memory hook reaches a project through the `aidd-context` plugin; Cursor already merges plugin hooks into a project hooks file with per-entry provenance, and both install routes call `ProjectHooksMaterializer` whenever a tool declares `hooksDestination: "project"`.

1. Generalise, Cursor behaviour unchanged first: extract `ProjectHooksFormat`, have the materializer and `remove-project-hooks.ts` read it from the capability instead of importing Cursor's functions. Cursor's suites stay green with no assertion change. If the PR nears 600 lines, this step ships as its own PR first.
2. Allow flat params to declare `hooksDestination: "project"`, `projectHooksRelativePath` and `projectHooksFormat`; the constructor guard keeps requiring the path.
3. `profile.ts`: flat `acceptsHooks: true`, `hooksDestination: "project"`, `projectHooksRelativePath: ".agents/hooks.json"`, the Antigravity format.
4. `build.ts`: hooks `hooksBundle`, scripts to `.agents/hooks/<plugin>/`, `hooksMerge` + `hooksMergeDest` with the same format function, the plugin name now passed by `flat-build-strategy.ts`.
5. Confirm with a real `aidd setup --ai antigravity` in a temp project, then `agy` under a pty: the memory refresh runs at repo root.

### `4)` Golden and suite

Expand All @@ -85,6 +102,6 @@ journey
| Task | Acceptance criteria |
| ---- | ------------------- |
| 1 | The PR quotes the measured `cwd` and the marker line from a real `agy` session |
| 2 | Foreign named hooks survive setup and clean; a second setup changes nothing; invalid JSON stops with the file named |
| 2 | Foreign named hooks survive setup and clean; a second setup changes nothing; invalid JSON stops with the file named; a non-`SessionStart` event is skipped with a warning |
| 3 | After setup, a new `agy` session logs `loaded … named hooks` and the memory refresh marker appears at repo root |
| 4 | Only the `antigravity:flat` golden cell changes; full suite passes |
Original file line number Diff line number Diff line change
Expand Up @@ -27,19 +27,20 @@ status: in-progress

| Source | Verified |
| ------ | -------- |
| https://antigravity.google/docs/hooks | `hooks.json` is keyed by hook name, no `hooks` wrapper; events `PreToolUse`, `PostToolUse`, `PreInvocation`, `PostInvocation`, `Stop`; `SessionStart` works though undocumented (spike, #890) |
| https://antigravity.google/docs/hooks | `hooks.json` is keyed by hook name, no `hooks` wrapper; handlers sit directly under an event, a `matcher` entry only for `PreToolUse`/`PostToolUse`; events `PreToolUse`, `PostToolUse`, `PreInvocation`, `PostInvocation`, `Stop`; `SessionStart` works though undocumented (spike, #890) |
| https://antigravity.google/docs/subagents?tab=cli | agents at `.agents/agents/<name>.md` or `<name>/agent.md`; `name`, `description` required; `model` in `inherit`/`flash`/`pro`; `tools` a YAML list |
| Issue #890 | hooks only from `hooks.json`; hook process `cwd` is `<repo>/.agents` on 1.2.5 |
| Issue #916 comments | `agy` silently drops an agent with `model: sonnet`/`opus` or a comma-separated `tools`, while `plugin validate` passes |
| Spike, 2026-10-02 | print-mode turns hang after answering: runtime evidence reads `cli-*.log` lines and markers, never the turn's end; a turn needs a login, so no CI runtime smoke |
| Probe, 2026-10-05, `agy` 1.2.17 | print mode never reaches the model and fires no hook; an interactive session under a pty with `--add-dir` does. Hook `cwd` is `<repo>/.agents`, stdin `workspacePaths[0]` is `<repo>` |

## Decisions

| Decision | Why |
| -------- | --- |
| The shared plugin-path uninstall bug stays out of #511; phases are numbered 2 to 5 | Codex and Antigravity both write `.agents/skills/`, and uninstalling one deletes plugin files the other still records (probe `uninstall-tools-shared-plugin-path.probe.unit.test.ts` fails). Product owner's call on 2026-10-05: track it separately, ship #511 with it as a known limitation |
| Setup writes the memory hook through a `HooksCapability` on `.agents/hooks.json`, like Codex, not through a JS bridge like Kilo | `agy` reads a JSON file natively; a bridge only exists where the host has no hooks file |
| The hook command is chosen from a measured `cwd` on `agy` 1.2.14, not assumed | `update_memory.js` exits 0 in silence when `aidd_docs/` is absent from `cwd`, so a wrong `cwd` looks like success |
| Setup and `plugin add` merge the `aidd-context` plugin's hooks into `.agents/hooks.json` through the project-hooks route Cursor uses, generalised to a per-tool format; not through `HooksCapability`, not through a JS bridge like Kilo | `agy` reads a JSON file natively; Codex's `HooksCapability` is never fed (no `codex-hooks` config ref exists), so copying it would write nothing; the project-hooks route already has idempotent merge, provenance and clean |
| The hook command is chosen from a measured `cwd` on `agy` 1.2.17 (`<repo>/.agents`, so `cd .. && node …`), not assumed | `update_memory.js` exits 0 in silence when `aidd_docs/` is absent from `cwd`, so a wrong `cwd` looks like success |
| The portable agent frontmatter builder lives in `tools/domain/formats/`, not in the profile | it is a cross-host shape (#916 reuses it for Codex flat and Kimi); only the Antigravity paths stay in `profiles/antigravity/` |
| No CI runtime smoke; CI gets the golden cell and the `build-per-tool` matrix row only | a turn needs a Google login and print mode hangs; the Kilo review's "real binary in the gate" lesson cannot hold here, so the PR says so instead of shipping a skipped test |
| Native plugin mode, telemetry host, duplicate-agent-name rejection stay out | #511 out of scope; duplicate `name` rejection belongs to #916 |
27 changes: 20 additions & 7 deletions cli/src/contexts/framework/application/clean-use-case.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,11 @@ import {
purgeAllNativeCaches,
type UndoneToolRegistrations,
} from "./shared/purge-native-marketplace-cache.js";
import { assertProjectHooksRemovable, removeProjectHooks } from "./shared/remove-project-hooks.js";
import {
assertProjectHooksRemovable,
type ProjectHooksGuard,
removeProjectHooks,
} from "./shared/remove-project-hooks.js";
import { resolveUninstallScopeOrder } from "./shared/resolve-uninstall-scope.js";
import {
describeGuardedPluginRefMessage,
Expand Down Expand Up @@ -167,7 +171,9 @@ export class CleanUseCase {
if (!isAiToolId(toolId)) continue;
for (const plugin of manifest.getPlugins(toolId)) {
if (projectHooksFileOf(toolId) !== undefined) {
await assertProjectHooksRemovable(this.fs, plugin, toolId, options.projectRoot);
await assertProjectHooksRemovable(this.fs, plugin, toolId, options.projectRoot, {
overwriteEdited: options.force,
});
}
await assertProjectMcpEntriesRemovable(this.fs, plugin, toolId, options.projectRoot);
}
Expand Down Expand Up @@ -196,7 +202,9 @@ export class CleanUseCase {
// name (see `purgeNativeCaches`'s own post-conditions).
await this.purgeNativeCaches(home, undone);
let deleted = await this.deleteAllToolFiles(manifest, options.projectRoot);
deleted += await this.deleteMachineLocalFiles(manifest, options.projectRoot);
deleted += await this.deleteMachineLocalFiles(manifest, options.projectRoot, {
overwriteEdited: options.force,
});
await this.removeAiddState(options.projectRoot);
// Exactly what the pipeline added on install, never a subset of it.
await this.gitignoreUseCase.remove(options.projectRoot, aiddGitignoreEntries(manifest));
Expand Down Expand Up @@ -676,11 +684,15 @@ export class CleanUseCase {
/** The files a tool writes that `plugins[].files` never tracks: a machine-local settings file
* (`.claude/settings.local.json`) and, for a tool merging a plugin's hooks into its own project
* file (`.cursor/hooks.json`), the same unmerge `plugin remove` drives one plugin at a time. */
private async deleteMachineLocalFiles(manifest: Manifest, projectRoot: string): Promise<number> {
private async deleteMachineLocalFiles(
manifest: Manifest,
projectRoot: string,
hooksGuard: ProjectHooksGuard
): Promise<number> {
let count = 0;
for (const toolId of manifest.getInstalledToolIds()) {
count += await this.deleteMachineLocalSettingsFiles(toolId, projectRoot);
count += await this.removeProjectHooksForTool(manifest, toolId, projectRoot);
count += await this.removeProjectHooksForTool(manifest, toolId, projectRoot, hooksGuard);
}
return count;
}
Expand All @@ -703,12 +715,13 @@ export class CleanUseCase {
private async removeProjectHooksForTool(
manifest: Manifest,
toolId: ToolId,
projectRoot: string
projectRoot: string,
hooksGuard: ProjectHooksGuard
): Promise<number> {
if (projectHooksFileOf(toolId) === undefined || !isAiToolId(toolId)) return 0;
let count = 0;
for (const plugin of manifest.getPlugins(toolId)) {
if (await removeProjectHooks(this.fs, plugin, toolId, projectRoot)) count++;
if (await removeProjectHooks(this.fs, plugin, toolId, projectRoot, hooksGuard)) count++;
}
return count;
}
Expand Down
Loading
Loading