Skip to content
Draft
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 |
Original file line number Diff line number Diff line change
@@ -1,12 +1,8 @@
import { dirname, join } from "node:path";
import {
cursorProjectHooksScriptPath,
mergeCursorProjectHooksJson,
} from "../../../../../contexts/tools/domain/formats/cursor-hooks-project-merge.js";
import type { FileReader } from "../../../../../kernel/ports/file-reader.js";
import type { FileWriter } from "../../../../../kernel/ports/file-writer.js";
import type { AiToolId } from "../../../../../kernel/tool.js";
import { resolvePluginsCapability } from "../../../../tools/domain/registry.js";
import { projectHooksDeliveryOf } from "../../../../tools/domain/registry.js";
import {
type PluginComponentFile,
PluginDistribution,
Expand All @@ -24,6 +20,8 @@ import {

const HOOKS_MANIFEST_PATH = "hooks/hooks.json";

type ProjectHooksDelivery = NonNullable<ReturnType<typeof projectHooksDeliveryOf>>;

/**
* Delivers a plugin's hooks to the destination a `hooksDestination: "project"` capability names —
* merged into the project's own hooks file, scripts copied beside it — rather than into the
Expand All @@ -47,10 +45,8 @@ export class ProjectHooksMaterializer {
projectRoot: string,
previous?: ProjectHooksProvenance
): Promise<{ skipped: ReadonlySkipList; projectHooks?: ProjectHooksProvenance }> {
const pluginsCap = resolvePluginsCapability(toolId);
if (pluginsCap === null || pluginsCap.hooksDestination !== "project") return { skipped: [] };
const projectHooksRelativePath = pluginsCap.projectHooksRelativePath;
if (projectHooksRelativePath === null) return { skipped: [] };
const delivery = projectHooksDeliveryOf(toolId);
if (delivery === null) return { skipped: [] };
const manifestFile = dist.components.hooks.find((f) => f.relativePath === HOOKS_MANIFEST_PATH);
if (manifestFile === undefined) {
if (previous !== undefined) {
Expand All @@ -65,15 +61,15 @@ export class ProjectHooksMaterializer {
return { skipped: [] };
}
await assertProjectHooksUnchanged(this.fs, dist.manifest.name, previous, toolId, projectRoot);
await this.assertScriptsNotUserOwned(dist, projectRoot, previous);
await this.assertScriptsNotUserOwned(dist, projectRoot, delivery, previous);
const { warnings, entries } = await this.mergeProjectHooksJson(
dist,
manifestFile,
projectRoot,
projectHooksRelativePath
delivery
);
const scripts = await this.writeProjectHooksScripts(dist, projectRoot);
await this.removeObsoleteScripts(previous, scripts, projectRoot);
const scripts = await this.writeProjectHooksScripts(dist, projectRoot, delivery);
await this.removeObsoleteScripts(previous, scripts, projectRoot, delivery);
return {
skipped: warnings.map(
(reason): PluginTranslationSkip => ({
Expand All @@ -91,42 +87,49 @@ export class ProjectHooksMaterializer {
dist: PluginDistribution,
manifestFile: PluginComponentFile,
projectRoot: string,
projectHooksRelativePath: string
delivery: ProjectHooksDelivery
): Promise<{ warnings: readonly string[]; entries: ProjectHooksProvenance["entries"] }> {
const destPath = join(projectRoot, projectHooksRelativePath);
const destPath = join(projectRoot, delivery.relativePath);
const existing = await this.readExistingJson(destPath);
const { content, warnings } = mergeCursorProjectHooksJson(
const { content, warnings } = delivery.format.merge(
existing,
manifestFile.content,
dist.manifest.name
);
await this.fs.writeFile(destPath, content);
return { warnings, entries: recordedProjectHookEntries(content, dist.manifest.name) };
return {
warnings,
entries: recordedProjectHookEntries(delivery.format, content, dist.manifest.name),
};
}

private async assertScriptsNotUserOwned(
dist: PluginDistribution,
projectRoot: string,
delivery: ProjectHooksDelivery,
previous?: ProjectHooksProvenance
): Promise<void> {
for (const file of dist.components.hooks) {
if (file.relativePath === HOOKS_MANIFEST_PATH) continue;
const relativePath = cursorProjectHooksScriptPath(dist.manifest.name, file.relativePath);
const relativePath = delivery.format.scriptPath(dist.manifest.name, file.relativePath);
if (previous?.scripts.has(relativePath) === true) continue;
if (await this.fs.fileExists(join(projectRoot, relativePath))) {
throw new Error(`Cursor hook script '${relativePath}' is user-owned; install refused.`);
throw new Error(
`${delivery.toolName} hook script '${relativePath}' is user-owned; install refused.`
);
}
}
}

private async writeProjectHooksScripts(
dist: PluginDistribution,
projectRoot: string
projectRoot: string,
delivery: ProjectHooksDelivery
): Promise<ReadonlyMap<string, string>> {
const scripts = new Map<string, string>();
for (const file of dist.components.hooks) {
if (file.relativePath === HOOKS_MANIFEST_PATH) continue;
const dest = cursorProjectHooksScriptPath(dist.manifest.name, file.relativePath);
const dest = delivery.format.scriptPath(dist.manifest.name, file.relativePath);
await this.fs.writeFile(join(projectRoot, dest), file.content);
scripts.set(dest, (await this.fs.readFileHash(join(projectRoot, dest))).value);
}
Expand All @@ -136,7 +139,8 @@ export class ProjectHooksMaterializer {
private async removeObsoleteScripts(
previous: ProjectHooksProvenance | undefined,
current: ReadonlyMap<string, string>,
projectRoot: string
projectRoot: string,
delivery: ProjectHooksDelivery
): Promise<void> {
if (previous === undefined) return;
for (const [relativePath, digest] of previous.scripts) {
Expand All @@ -145,7 +149,7 @@ export class ProjectHooksMaterializer {
if (!(await this.fs.fileExists(path))) continue;
if ((await this.fs.readFileHash(path)).value !== digest) {
throw new Error(
`Cursor hook script '${relativePath}' was edited during reinstall; removal refused.`
`${delivery.toolName} hook script '${relativePath}' was edited during reinstall; removal refused.`
);
}
await this.fs.deleteFile(path);
Expand Down
Loading
Loading