From afac7c4c2296b475d6c2de3af098eea021061800 Mon Sep 17 00:00:00 2001 From: Thierry Kwiatkowski Date: Wed, 29 Jul 2026 16:37:31 +0200 Subject: [PATCH 01/23] feat(cli): register gemini as a known AI tool id MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extends AiToolId/AI_TOOL_IDS/FrameworkBuildTarget, adds the gemini CONFIG_ASSETS entry and settings.json seed, and writes domain/tools/ai/gemini.ts (agents, skills, mcp, plugins:unsupported) so the id lands atomically with a registered tool β€” AI_TOOL_IDS is the runtime source of truth for "install all tools", so registering the id without the tool would have crashed real installs. Phase 1 of part-1 (feat/511-gemini-flat-build-target), pulling forward what was originally Phase 4 task 1; see the plan's Amendments for the reasoning and the resulting Phase 4 trim. Co-Authored-By: Claude Sonnet 5 --- .../2026_07_27-511-gemini-cli-tool-master.md | 117 ++++++++ .../2026_07_27-511-gemini-cli-tool-part-1.md | 259 ++++++++++++++++++ .../2026_07_27-511-gemini-cli-tool-part-2.md | 198 +++++++++++++ .../2026_07_27-511-gemini-cli-tool-part-3.md | 213 ++++++++++++++ .../2026_07_27-511-gemini-cli-tool-part-4.md | 216 +++++++++++++++ .../brainstorm.md | 59 ++++ cli/assets/configs/gemini/settings.json | 5 + cli/src/domain/models/framework-build.ts | 9 +- cli/src/domain/models/tool-ids.ts | 3 +- cli/src/domain/tools/ai/gemini.ts | 89 ++++++ cli/src/infrastructure/assets/asset-loader.ts | 2 + cli/src/infrastructure/deps.ts | 1 + cli/tests/application/use-cases/helpers.ts | 1 + .../domain/models/tool-config.unit.test.ts | 21 +- cli/tests/domain/models/tool-ids.unit.test.ts | 1 + .../tools/registry-conformance.unit.test.ts | 12 +- cli/tests/helpers/ports/build-unit-deps.ts | 1 + .../assets/asset-loader.unit.test.ts | 9 + 18 files changed, 1207 insertions(+), 9 deletions(-) create mode 100644 aidd_docs/tasks/2026_07/2026_07_27_gemini-cli-build-target/2026_07_27-511-gemini-cli-tool-master.md create mode 100644 aidd_docs/tasks/2026_07/2026_07_27_gemini-cli-build-target/2026_07_27-511-gemini-cli-tool-part-1.md create mode 100644 aidd_docs/tasks/2026_07/2026_07_27_gemini-cli-build-target/2026_07_27-511-gemini-cli-tool-part-2.md create mode 100644 aidd_docs/tasks/2026_07/2026_07_27_gemini-cli-build-target/2026_07_27-511-gemini-cli-tool-part-3.md create mode 100644 aidd_docs/tasks/2026_07/2026_07_27_gemini-cli-build-target/2026_07_27-511-gemini-cli-tool-part-4.md create mode 100644 aidd_docs/tasks/2026_07/2026_07_27_gemini-cli-build-target/brainstorm.md create mode 100644 cli/assets/configs/gemini/settings.json create mode 100644 cli/src/domain/tools/ai/gemini.ts diff --git a/aidd_docs/tasks/2026_07/2026_07_27_gemini-cli-build-target/2026_07_27-511-gemini-cli-tool-master.md b/aidd_docs/tasks/2026_07/2026_07_27_gemini-cli-build-target/2026_07_27-511-gemini-cli-tool-master.md new file mode 100644 index 000000000..18ad39709 --- /dev/null +++ b/aidd_docs/tasks/2026_07/2026_07_27_gemini-cli-build-target/2026_07_27-511-gemini-cli-tool-master.md @@ -0,0 +1,117 @@ +--- +name: master_plan +description: Parent plan orchestrating the four child plans that make Gemini CLI a first-class AIDD tool +argument-hint: N/A +--- + +# Master Plan: Gemini CLI as a first-class AIDD tool + +## Overview + +- **Goal**: Gemini CLI becomes a full tool-registry citizen (build target + install/uninstall/update/restore/doctor/status), with skills, agents, MCP, hooks and `AGENTS.md` natively wired. +- **Risk Score**: 8/10 (published archive content changes +3, 5+ modules affected +3, generalization of two opencode hardcodes +2) +- **Branch**: `feat/511-gemini-cli-tool/` +- **Source**: [issue #511](https://github.com/ai-driven-dev/framework/issues/511), refined in `./brainstorm.md` +- **Marketplace mode**: out of scope. Gemini CLI has no plugin-manager equivalent. + +## Surface mapping (verified, not assumed) + +Every row below was checked against Gemini CLI 0.52.0 installed locally (`gemini --version`), against strings extracted from the shipped bundle, and against the official docs at `main`. The issue's original mapping is wrong on three rows and must be rewritten (part 4). + +| AIDD surface | Gemini CLI target | Evidence | +| --- | --- | --- | +| Skills | `.agents/skills/aidd-/SKILL.md` | `gemini skills list --all` discovers a probe skill placed there. Alias documented, and it takes precedence over `.gemini/skills/` | +| Agents | `.gemini/agents/.md` | `getProjectAgentsDir() = geminiDir/agents`. No `.agents/agents` alias exists | +| MCP | `mcpServers` in `.gemini/settings.json` | Same key as the AIDD source `.mcp.json`, no shape transform needed | +| Hooks | `hooks` in `.gemini/settings.json` | Official Claude to Gemini event table shipped in `gemini hooks migrate` | +| Rules / context | `AGENTS.md`, made readable via `context.fileName` | `AGENTS.md` appears nowhere in the shipped bundle, so it is not read by default. `settings.context.fileName` feeds `setGeminiMdFilename` | +| Commands | none | Out of scope for every AIDD target. A framework-build limitation, not a Gemini one | + +## Corrections to the brainstorm's risk list + +Four of its nine risks were wrong or mislocated. Recorded here so no child plan re-litigates them. + +| Brainstorm risk | Verdict | +| --- | --- | +| 1. Uninstalling codex deletes the shared skills tree | **Mislocated.** `uninstall-tools-use-case.ts:201-213` already guards shared paths. The unguarded path is `uninstall-plugin-use-case.ts:62-74`. Owned by part 2 | +| 3. `context.fileName` merge strategy undefined | **Downgraded.** `setGeminiMdFilename` unions rather than replaces, so `GEMINI.md` is never lost. The residual problem is our own writer needing array union under user-prime. Owned by part 1 | +| 4. Hook mapping unverified event by event | **Resolved.** AIDD ships exactly two hooks, both mapping cleanly. Kept in scope, no longer best effort | +| 5. Detection ambiguity via `detectUserFileSectionKey` | **Not a live mechanism.** Five implementations, zero callers in `src/`. There is no sync command. Documented, not fixed | +| 6. CI cost 5x5 to 6x6 | **Wrong shape.** No such automated matrix exists. Real cost is 9 to 10 build cells and a 4x4 to 5x5 unit suite | + +## Constraints discovered during planning + +Neither appears in the issue or the brainstorm, and both gate the stated success criterion. + +- **Minimum Gemini CLI 0.28.0.** The `.agents/skills/` alias does not exist before it. Skills need 0.24.0, markdown agents 0.25.0, the full hook event set 0.21.0. +- **Folder trust.** In an untrusted folder Gemini prints `Skipping project agents due to untrusted folder` and `Project hooks disabled because the folder is not trusted`, and lists zero project skills. Unzipping the archive is not sufficient. The user must trust the folder, or `security.folderTrust.enabled` must be false. + +## Child Plans + +| # | Plan | File | Status | Validated | +| --- | ----------------------- | ------------------------------------------------ | ------- | --------- | +| 1 | Build target | `./2026_07_27-511-gemini-cli-tool-part-1.md` | pending | [ ] | +| 2 | Shared tree safety | `./2026_07_27-511-gemini-cli-tool-part-2.md` | blocked | [ ] | +| 3 | Registry citizen | `./2026_07_27-511-gemini-cli-tool-part-3.md` | blocked | [ ] | +| 4 | Content and docs | `./2026_07_27-511-gemini-cli-tool-part-4.md` | blocked | [ ] | + + + + +Each part is independently shippable: + +- Part 1 alone closes the literal ask of issue #511 (a `gemini` flat archive). +- Part 2 is a pre-existing bug fix, valuable with or without gemini. +- Part 3 turns the build target into a registry citizen. +- Part 4 carries the deliberate golden re-baseline and the documentation debt. + +## Cross-cutting decisions + +Taken during planning, binding on every child plan. + +| Question | Decision | Reason | +| --- | --- | --- | +| `aidd-orchestrator` under Gemini | Excluded from the gemini target | Structurally Claude-coupled: `enabledPlugins` in `.claude/settings.json` and the Claude Code GitHub Action have no Gemini equivalent. Neutralizing the wording would leave a skill that cannot run | +| Who writes `.gemini/settings.json` | Three logical writers (settings seed, MCP, hooks), all in merge semantics, all delegating to one authority module. No `configOutputPaths` | `buildConfigFiles` skips an existing untracked file with only a warning (`install-runtime-config-use-case.ts:84,130-139`). Every real Gemini user already has that file, so `context.fileName` would silently never land | +| Existing golden cells | Byte-identical in parts 1 to 3. Re-baseline isolated in part 4 | `actions/05-build-contract.md` mandates byte-identical existing-target output against a pre-change baseline | +| Skills rendering for gemini | Identical to codex, producing a byte-identical subset of codex's `.agents/skills/**` | Removes the hash-divergence failure mode on co-owned paths instead of managing it | +| Plugin exclusion mechanism | `shouldBuildPlugin(name)` on `BuildOutputStrategy`, fed by the contract | Keeps zero per-tool branches in the orchestrators, as the `tool` skill requires | +| Standalone operation without codex | Required, and validated | Verified in part 1 phase 5 and part 3 phase 4, both in a project where codex is absent. The `.agents/` tree is an official Gemini alias, not a codex artifact | + +This supersedes one brainstorm decision. The brainstorm chose tool-neutral skill content (its option A), accepting that codex output changes. Excluding `aidd-orchestrator` removes the need for the hard part of that work: all nineteen real Claude-specific runtime paths live in that one plugin. What remains is additive, adding a gemini row to ten multi-tool tables, which still changes codex output and still needs the re-baseline. The intent of option A holds; its cost drops. + +## Validation Protocol + +1. Complete Part 1, run its `success_condition` +2. [ ] Checkpoint 1: gemini archive builds, 9 existing golden cells byte-identical +3. Unblock Part 2, run its `success_condition` +4. [ ] Checkpoint 2: shared-path deletion guarded, subset invariant green +5. Unblock Part 3, run its `success_condition` +6. [ ] Checkpoint 3: full command matrix green for gemini, smoke coverage gate met +7. Unblock Part 4, run its `success_condition` +8. [ ] Final: a Gemini-only project consumes the archive end to end, verified against the real `gemini` binary + +## Confidence assessment + +**9/10.** + +Reasons for confidence: + +- Every surface claim was verified against Gemini CLI 0.52.0 running locally, not inferred from documentation. Skill discovery under `.agents/skills/` was reproduced; the hook event mapping and the strict agent frontmatter schema were extracted from the shipped binary. The project's testing memory records that doc-plus-code inference was wrong twice before on exactly this kind of question. +- The lifecycle is overwhelmingly registry-driven. The set of files that genuinely need editing is enumerated with line references, and the compile-hard ones (the exhaustive config-asset record, the id union) fail loudly rather than silently. +- Two existing tools bracket gemini's shape: codex supplies the shared skills tree, opencode supplies the flat-only, no-marketplace citizen. Little is unprecedented. +- The riskiest failure mode, two tools writing different bytes to one shared path, is eliminated by construction rather than managed, and the invariant is asserted in the golden suite. +- Every part has a runnable success condition and each is independently shippable. + +Remaining risks: + +- The three-writer arrangement on `.gemini/settings.json` has no precedent in the codebase. Codex splits MCP and hooks across two files; opencode has no hooks. If the merge authority proves insufficient, part 1 phase 2 may need a fourth writer removed rather than added. +- Minimum-version numbers are source-derived. The vendor publishes none, so a stated minimum could be off by a release. +- Flat mode skips plugin hooks on the install path while the archive path merges them. That asymmetry predates this work, applies to opencode too, and is documented rather than fixed here. +- Folder trust cannot be satisfied by the archive. It is a documentation-only mitigation, so a user who skips the step sees an empty skill list with no explanation from AIDD. +- Part 3 refactors a path opencode depends on. The existing opencode suites are the only regression net; if their coverage is thinner than it looks, a regression could ship. + +## Estimations + +- **Confidence**: 9/10 +- **Duration**: 4 sessions, one per part diff --git a/aidd_docs/tasks/2026_07/2026_07_27_gemini-cli-build-target/2026_07_27-511-gemini-cli-tool-part-1.md b/aidd_docs/tasks/2026_07/2026_07_27_gemini-cli-build-target/2026_07_27-511-gemini-cli-tool-part-1.md new file mode 100644 index 000000000..25112224b --- /dev/null +++ b/aidd_docs/tasks/2026_07/2026_07_27_gemini-cli-build-target/2026_07_27-511-gemini-cli-tool-part-1.md @@ -0,0 +1,259 @@ +--- +name: plan +description: Living implementation plan - frozen objective, phases, and append-only execution Log. Used as input artifact AND as the autonomous-loop tracking file. +argument-hint: N/A +objective: "aidd framework build --target gemini --flat produces an archive a Gemini-only project can consume, without changing any existing target's output." +success_condition: "cd cli && pnpm typecheck && pnpm lint && pnpm test" +iteration: 0 +created_at: "2026-07-27T22:44:41+02:00" +status: in-progress +--- + +# Instruction: Gemini CLI flat build target + +## Feature + +- **Summary**: Register `gemini` as the sixth AI tool and give it a flat framework-build contract, so a single archive materializes skills, agents, MCP servers, hooks and the `AGENTS.md` context wiring into a Gemini CLI project. +- **Stack**: `Node.js >= 22.12`, `TypeScript (ESM, relative .js imports)`, `vitest`, `biome`, `tsup`, `pnpm`, `Gemini CLI >= 0.28.0 (verified against 0.52.0)` +- **Branch name**: `feat/511-gemini-flat-build-target` +- **Parent Plan**: `./2026_07_27-511-gemini-cli-tool-master.md` +- **Sequence**: `1 of 4` +- Confidence: 9/10 +- Time to implement: one session + +## Architecture projection + +### Files to modify + +- `cli/src/domain/models/tool-ids.ts` - add `"gemini"` to the `AiToolId` union and `AI_TOOL_IDS`; this also appends `.gemini.md` to the derived `ALL_TOOL_SUFFIXES` of three capabilities +- `cli/src/domain/models/framework-build.ts` - add `"gemini"` to `FrameworkBuildTarget` +- `cli/src/domain/models/framework.ts` - add the config-name constant for the gemini settings source, if a framework-sourced config is used +- `cli/src/domain/tools/build-contract.ts` - add the optional plugin-exclusion field consumed by the build strategies +- `cli/src/application/use-cases/framework/strategies/build-output-strategy.ts` - add `shouldBuildPlugin(name)` to the strategy interface +- `cli/src/application/use-cases/framework/strategies/flat-build-strategy.ts` - implement `shouldBuildPlugin` from the contract +- `cli/src/application/use-cases/framework/strategies/marketplace-build-strategy.ts` - implement `shouldBuildPlugin` as always true +- `cli/src/application/use-cases/framework/framework-build-use-case.ts` - skip plugins the strategy rejects, in the plugin loop +- `cli/src/application/use-cases/framework/strategies/tool-contracts.ts` - add `buildGeminiFlatContract()` declaring all six artifact kinds +- `cli/src/infrastructure/deps.ts` - side-effect import of the gemini tool module, import of the contract builder, and the `gemini:flat` registry row +- `cli/src/infrastructure/assets/asset-loader.ts` - `CONFIG_ASSETS` is an exhaustive `Record`; the gemini entry is mandatory or the build fails to compile +- `cli/src/application/commands/framework.ts` - `SUPPORTED_TARGETS` and the `--target` help text +- `cli/src/application/commands/ai.ts` - the `ai` command description +- `cli/src/application/use-cases/menu-use-case.ts` - the interactive tool prompt hint +- `cli/src/domain/formats/flat-hooks-merge.ts` - generalize the existing settings-hooks merge and add the Claude to Gemini event mapping +- `cli/src/domain/formats/flat-paths.ts` - the header comment enumerating five tools +- `cli/biome.json` - add `!.gemini` to the ignore list +- `cli/package.json` - add the `gemini` keyword +- `cli/tests/golden/framework-build-golden.e2e.test.ts` - add `"gemini"` to `FLAT_TARGETS`, retitle the matrix from 9 to 10 cells +- `cli/tests/golden/snapshots/framework-build/golden.json` - additive regeneration: exactly one new `gemini:flat` key, the nine existing keys byte-identical +- `cli/tests/domain/models/tool-config.unit.test.ts` - the two exact-array assertions +- `cli/tests/domain/models/tool-ids.unit.test.ts` - `isAiToolId("gemini")` +- `cli/tests/application/use-cases/helpers.ts` - side-effect import so `getToolConfig("gemini")` resolves +- `cli/tests/helpers/ports/build-unit-deps.ts` - same side-effect import +- `cli/tests/infrastructure/assets/asset-loader.unit.test.ts` - a gemini `loadConfigAsset` block +- `cli/tests/application/use-cases/framework/flat-build-strategy.integration.test.ts` - a `.gemini/settings.json` emission block and the hooks-then-mcp write-order assertion +- `cli/tests/e2e/framework-build.e2e.test.ts` - `--target gemini --flat` succeeds, `--target gemini` without `--flat` exits 1 +- `.github/workflows/ci.yml` - add the `{ tool: gemini, mode: flat, flag: "--flat" }` matrix row and update the 9-cell comment + +### Files to create + +- `cli/src/domain/tools/ai/gemini.ts` - the `AiTool` definition and its `registerTool` call +- `cli/src/domain/formats/gemini-settings-merge.ts` - the single authority on `.gemini/settings.json`: additive `mcpServers`, additive `hooks`, and an idempotent array union guaranteeing `AGENTS.md` in `context.fileName` +- `cli/assets/configs/gemini/settings.json` - the greenfield settings seed carrying the nested `context.fileName` +- `cli/tests/domain/tools/ai/gemini.unit.test.ts` - mirror of the codex tool test +- `cli/tests/domain/formats/gemini-settings-merge.unit.test.ts` - merge, idempotence, user-prime, array-union and triple-writer coverage +- `cli/tests/application/use-cases/framework/gemini-plugin-exclusion.integration.test.ts` - the excluded plugin is absent from the gemini output and present in every other target + +### Files to delete + +None. Two dead surfaces were identified and are deliberately left alone as out of scope: `detectUserFileSectionKey` (five implementations, zero callers in `src/`) and the `contentSections` branch (always empty in production). + +## Applicable rules + +| Tool | Name | Path | Why it applies | +| ------ | ------------------------ | ----------------------------------------------------------------------- | -------------- | +| claude | 0-hexagonal | `cli/.claude/rules/00-architecture/0-hexagonal.md` | The tool definition belongs in `domain/tools/ai/`, the merge in `domain/formats/` | +| claude | 0-layer-responsibilities | `cli/.claude/rules/00-architecture/0-layer-responsibilities.md` | The contract stays domain; no tool logic enters the use-cases | +| claude | 0-error-handling | `cli/.claude/rules/00-architecture/0-error-handling.md` | The three-writer collision on one settings file must raise a typed error, never be swallowed | +| claude | 0-deps-wiring | `cli/.claude/rules/00-architecture/0-deps-wiring.md` | `deps.ts` and `commands/framework.ts` are both modified | +| claude | 1-exports | `cli/.claude/rules/01-standards/1-exports.md` | Named exports only, no barrel file | +| claude | 1-naming | `cli/.claude/rules/01-standards/1-naming.md` | `gemini.ts`, `*.unit.test.ts`, `*.integration.test.ts`, `*.e2e.test.ts` | +| claude | 2-typescript | `cli/.claude/rules/02-programming-languages/2-typescript.md` | Relative `.js` imports, `import type`, no `any` | +| claude | 3-cli-lifecycle | `cli/.claude/rules/03-frameworks-and-libraries/3-cli-lifecycle.md` | `SUPPORTED_TARGETS` and the `--target` flag surface change | +| claude | 3-cli-output | `cli/.claude/rules/03-frameworks-and-libraries/3-cli-output.md` | An unmapped hook event and an excluded plugin are `warn`, never `error` | +| claude | 4-biome | `cli/.claude/rules/04-tooling/4-biome.md` | `biome.json` is modified and all new TypeScript must pass `biome check` | +| claude | 6-method-size | `cli/.claude/rules/06-design-patterns/6-method-size.md` | The contract builder and the merge functions must stay at or under 20 lines per method | +| claude | 7-clean-code | `cli/.claude/rules/07-quality/7-clean-code.md` | No placeholder artifact kinds, no stub reserved for a later part | + +Not selected: `4-git-hooks` (`lefthook.yml` untouched) and `7-auth` (no auth path touched). + +The project's own `tool` skill (`cli/.claude/skills/tool/`) is the sanctioned entry point: run actions 01 through 05. Use `format` for the merge module, `capability` only if a capability constructor changes, and `test` for every new suite. + +## User Journey + +```mermaid +--- +title: Gemini CLI user consuming the flat archive +--- +flowchart TD + Maintainer["Maintainer runs framework build"] + Contract["Gemini flat contract"] + Exclusion{"Plugin allowed for gemini?"} + Skip["Warn and skip plugin"] + Skills["Write .agents/skills/aidd-*/SKILL.md"] + Agents["Write .gemini/agents/*.md"] + Hooks["Merge hooks into .gemini/settings.json"] + Mcp["Merge mcpServers into .gemini/settings.json"] + Context["Ensure AGENTS.md in context.fileName"] + Archive["Zip archive published on the release"] + User["Gemini-only user unzips into the project"] + Trust{"Folder trusted?"} + Blocked["Skills and hooks disabled"] + Working["Skills listed, agents answer to @name, MCP wired, AGENTS.md loaded"] + + Maintainer -- reads --> Contract + Contract --> Exclusion + Exclusion -.-> Skip + Exclusion --> Skills + Skills --> Agents + Agents --> Hooks + Hooks --> Mcp + Mcp --> Context + Context --> Archive + Archive --> User + User --> Trust + Trust -.-> Blocked + Trust --> Working +``` + +## Risk register + +| Risk | Impact | Mitigation | +| --- | --- | --- | +| `context.fileName` is an array and user-prime merge keeps the user's value | `AGENTS.md` never loaded, success criterion silently fails | The merge module performs an idempotent, order-preserving array union and never removes an entry; a unit test covers a pre-existing user list | +| Three writers on `.gemini/settings.json` | One writer clobbers another's key | All three delegate to the one authority module; write order is hooks then mcp per plugin, then the settings seed; an integration test asserts all three keys survive | +| Adding `gemini` to `AI_TOOL_IDS` appends `.gemini.md` to every other tool's suffix exclusion list | Silent behaviour change for the five existing tools | The golden test is the guard: the nine existing cells must stay byte-identical, so any leakage fails the build | +| Skills rendering diverges from codex on the shared tree | Two tools write different bytes to one path, permanent drift downstream | Gemini reuses codex's skills artifact contract verbatim; a test asserts gemini's `.agents/skills/**` is a byte-identical subset of codex's | +| Adding a `gemini:marketplace` row by reflex | An unsupported mode appears to work | The row is deliberately absent; an e2e test asserts `--target gemini` without `--flat` exits 1 | +| Gemini CLI below 0.28.0 | The archive lands but no skill is ever discovered | Documented as a prerequisite in part 4; nothing in the build can detect it | + +## Implementation phases + +### Phase 1: Register the tool identity + +> Make `gemini` a known tool id everywhere the type system demands it, with nothing behavioural yet. + +#### Tasks + +1. Extend the `AiToolId` union and `AI_TOOL_IDS`. +2. Extend `FrameworkBuildTarget`. +3. Add the mandatory `CONFIG_ASSETS` entry and the settings seed asset it loads. +4. Fix the two exact-array test assertions and the `isAiToolId` test. +5. Add the side-effect imports to both test helper modules (plus `deps.ts`, so production registration lands atomically too). +6. πŸ€– Write `domain/tools/ai/gemini.ts` (`AiTool`, `registerTool` at module bottom) β€” pulled forward from Phase 4. See Amendments. + +#### Acceptance criteria + +- [x] `pnpm typecheck` exits 0 with no `Record` exhaustiveness error +- [x] `pnpm test:unit` exits 0 +- [x] The nine existing golden cells are unchanged (`pnpm test:e2e` on the golden suite exits 0 without regeneration) + +### Phase 2: Own the settings file + +> One module becomes the single authority on `.gemini/settings.json`, before any writer uses it. + +#### Tasks + +1. Generalize the existing settings-hooks merge helper rather than adding a parallel implementation. +2. Add the Claude to Gemini hook event mapping, mirroring how the existing per-tool event map is declared. +3. Implement the additive `mcpServers` merge on the same file. +4. Implement the idempotent `context.fileName` array union. +5. Emit a `warn` for any source hook event with no Gemini equivalent, and drop it rather than write an invalid event name. +6. Write the unit suite: empty file, user keys preserved, idempotence, pre-existing user `context.fileName` list, unmapped event. + +#### Acceptance criteria + +- [ ] Merging twice produces identical bytes +- [ ] A pre-existing user `context.fileName` array retains its entries and gains `AGENTS.md` +- [ ] A user-authored unrelated key in the settings file survives every merge +- [ ] An unmapped hook event produces a warning and no output entry + +### Phase 3: Add the plugin-exclusion mechanism + +> Let a build contract exclude a plugin, with zero per-tool branching in the orchestrators. + +#### Tasks + +1. Add the optional exclusion field to the tool build contract. +2. Add the predicate to the build-output strategy interface. +3. Implement it in both strategies: contract-driven for flat, always true for marketplace. +4. Skip rejected plugins in the build use-case plugin loop, emitting one `warn` per skip. +5. Write the integration test proving the excluded plugin is absent from gemini and present elsewhere. + +#### Acceptance criteria + +- [ ] The build use-case contains no tool-name literal +- [ ] A grep for `if (tool === ` and `if (kind === "agents")` in both orchestrators returns nothing +- [ ] Skipping a plugin is reported on stderr, and no skip is silent + +### Phase 4: Declare the gemini flat contract + +> The build contract, reusing codex's skills rendering verbatim. The tool definition itself +> (`domain/tools/ai/gemini.ts`) landed in Phase 1 β€” see its Amendments entry. + +#### Tasks + +1. ~~Write the tool definition~~ β€” done in Phase 1 (`agents`, `skills`, `mcp`, `plugins: unsupported`; no `configOutputPaths`). `hooks` and `settings` capabilities (install-mode fidelity) are deliberately not on gemini's `AiTool` yet β€” they need real per-tool merge logic (Claudeβ†’Gemini hook event translation, `context.fileName` array union) that today's generic install pipeline can't express without a capability-class change. Out of this part's objective (`aidd framework build`, not `aidd install`); tracked for Part 3 (registry citizen). +2. Write the flat contract declaring all six artifact kinds, with rules and commands explicitly unsupported. +3. Reuse codex's skills path and transform without copying them; extract a shared helper if needed. +4. Wire the contract and the module import into the dependency graph, and add the target to the command surface and its help text. +5. Add the CI matrix row. + +#### Acceptance criteria + +- [ ] All six artifact kinds are declared; none is omitted +- [ ] No `gemini:marketplace` row exists, and `--target gemini` without `--flat` exits 1 +- [ ] Skills, agents, MCP and hooks all land at the mapped paths in a real build +- [ ] `.gemini/settings.json` contains `mcpServers`, `hooks` and `context.fileName` simultaneously + +### Phase 5: Prove it against the real binary + +> Green tests prove the output shape, not that Gemini consumes it. Verify empirically, as the project's testing memory requires. + +#### Tasks + +1. Build the archive into a fresh `/tmp` directory, never the repo root. +2. `git init` the directory and trust it, using a sandboxed home so the real user configuration is untouched. +3. Run `gemini skills list --all` and confirm the AIDD skills are discovered and enabled. +4. Confirm the agents directory parses: no `AgentLoadError`, given the strict frontmatter schema. +5. Confirm `AGENTS.md` is picked up as context. +6. Regenerate the golden snapshot additively and diff the nine existing keys. + +#### Acceptance criteria + +- [ ] `gemini skills list --all` lists every published AIDD skill from `.agents/skills/` +- [ ] No agent file is rejected by the strict frontmatter schema +- [ ] The nine pre-existing golden keys are byte-identical to the pre-change baseline +- [ ] `cd cli && pnpm typecheck && pnpm lint && pnpm test` exits 0 + +## Amendments + + + +πŸ€– Phase 1/Phase 4 boundary was unsound as originally scoped: adding `"gemini"` to `AI_TOOL_IDS` (Phase 1) without also calling `registerTool(gemini)` (originally Phase 4 task 1) broke every existing test that spreads `AI_TOOL_IDS` as "install all tools" (`tests/application/use-cases/setup-use-case.unit.test.ts`, 3 failures β€” `UnregisteredToolError: Tool 'gemini' is not registered.`), because `AI_TOOL_IDS` is the real runtime source of truth for "which AI tools does `all` install," not just a type-level list. This would have shipped a crash in the real CLI (`aidd setup --tools all`) had Phase 1 landed alone. Flagged to the user; resolved by pulling the tool-definition write (`domain/tools/ai/gemini.ts`) into Phase 1, atomic with the id registration. Phase 4 shrinks accordingly (its task 1 is struck). + +πŸ€– gemini's `AiTool` capability intersection is `HasAgents & HasSkills & HasMcp & HasPlugins` β€” narrower than codex/opencode. Deliberately omitted: `hooks` (Claudeβ†’Gemini event-name translation has no expression point in the current `HooksCapability`/generic install pipeline β€” content passes through untransformed) and `settings` (the idempotent `context.fileName` array union needs custom merge logic; `SettingsCapability` only supports generic `MergeStrategy` enums or static content, not a custom merge function). `plugins` is `{ mode: "unsupported" }` (no marketplace, no native activation, per the master plan). None of this blocks this part's objective β€” `aidd framework build` never reads `AiTool.capabilities` (`FlatBuildStrategy`/`ToolBuildContract` are fully standalone) β€” so the gap is real install-mode functionality deferred to Part 3, not a stub masking Phase 1/4 work. + +## Log + + + +- Phase 1: `AiToolId`/`AI_TOOL_IDS`/`FrameworkBuildTarget` extended; `CONFIG_ASSETS["gemini"]` + `assets/configs/gemini/settings.json` seed (`context.fileName: ["AGENTS.md"]`) added; `domain/tools/ai/gemini.ts` written and registered (see Amendments for scope); side-effect imports added to `deps.ts` + both test helpers; two exact-array assertions and `isAiToolId` test fixed; new `gemini` block added to `asset-loader.unit.test.ts`. Verified: `pnpm typecheck` (0 errors), `pnpm test:unit` (1413/1413), `pnpm test:e2e` golden framework-build suite (9-cell matrix byte-identical, claude cell frozen), `biome check` (clean after one formatting auto-fix). Two pre-existing, environment-coupled e2e failures observed and confirmed unrelated (`auth status` / `self-update --check`, depend on local `gh auth login` state, no gemini involvement). + +## Validation flow demonstration + +1. Build the gemini archive from the repo into a fresh temporary directory. +2. Create a separate empty project directory, `git init` it, unzip the archive into it. +3. Trust the folder in a sandboxed Gemini home so the real configuration is untouched. +4. Run `gemini skills list --all` and see the AIDD skills listed as enabled, sourced from `.agents/skills/`. +5. Open `.gemini/settings.json` and confirm `mcpServers`, `hooks` and `context.fileName` coexist. +6. Confirm `AGENTS.md` sits at the project root and is named in `context.fileName`. diff --git a/aidd_docs/tasks/2026_07/2026_07_27_gemini-cli-build-target/2026_07_27-511-gemini-cli-tool-part-2.md b/aidd_docs/tasks/2026_07/2026_07_27_gemini-cli-build-target/2026_07_27-511-gemini-cli-tool-part-2.md new file mode 100644 index 000000000..1382cd492 --- /dev/null +++ b/aidd_docs/tasks/2026_07/2026_07_27_gemini-cli-build-target/2026_07_27-511-gemini-cli-tool-part-2.md @@ -0,0 +1,198 @@ +--- +name: plan +description: Living implementation plan - frozen objective, phases, and append-only execution Log. Used as input artifact AND as the autonomous-loop tracking file. +argument-hint: N/A +objective: "Removing one owner of the shared .agents/skills tree never destroys another owner's skills, and the tree is visible to doctor and status." +success_condition: "cd cli && pnpm typecheck && pnpm lint && pnpm test" +iteration: 0 +created_at: "2026-07-27T22:44:41+02:00" +--- + +# Instruction: Shared skills tree safety + +## Feature + +- **Summary**: Close the real multi-owner hole. Plugin uninstall has no shared-path guard, unlike tool uninstall, so removing one owner of `.agents/skills/` deletes files another owner still needs. Also make the shared tree visible to `doctor` and `status`, which today only ever look under a tool's own directory. +- **Stack**: `Node.js >= 22.12`, `TypeScript (ESM, relative .js imports)`, `vitest`, `biome`, `pnpm` +- **Branch name**: `fix/511-shared-skills-tree-safety` +- **Parent Plan**: `./2026_07_27-511-gemini-cli-tool-master.md` +- **Sequence**: `2 of 4` +- Confidence: 9/10 +- Time to implement: one session + +## Why this is not what the brainstorm described + +The brainstorm asserts that `aidd ai uninstall codex` deletes `.agents/skills/aidd-*` and gemini silently loses its skills. That specific path is already guarded: `uninstall-tools-use-case.ts:201-213` computes the tracked paths of every remaining installed tool and the deletion loop skips them, with a passing test for the analogous claude-plus-vscode case. + +Three things are genuinely broken, and none was named: + +- `uninstall-plugin-use-case.ts:62-74` deletes every tracked plugin file unconditionally, with no shared-path guard. Since skills reach a project through the plugin path and not the tool path (`contentSections` is empty in production, so the skills install use-case never runs), this is the path that actually destroys the shared tree. +- `uninstall-ide-use-case.ts:37-50` has the same unguarded shape. +- `doctor-layout-use-case.ts:26-44` compares only a tool's own `directory`, and `manifest.ts:364-372` keys installed directories on the first path segment only. A shared tree under `.agents/` is therefore invisible to the orphan check, and `status-use-case.ts` never scans it either. + +## Architecture projection + +### Files to modify + +- `cli/src/application/use-cases/uninstall/uninstall-plugin-use-case.ts` - apply the shared-path guard before deleting a tracked plugin file +- `cli/src/application/use-cases/uninstall/uninstall-ide-use-case.ts` - same guard, same reason +- `cli/src/application/use-cases/uninstall/uninstall-tools-use-case.ts` - extract the existing shared-path computation so the three call sites share one implementation instead of three copies +- `cli/src/domain/models/manifest.ts` - expose a path-to-owners view derived on read, so consumers can iterate paths rather than tool-and-path pairs; no schema version bump +- `cli/src/application/use-cases/doctor/doctor-tracked-files-use-case.ts` - deduplicate a co-owned path so one missing file yields one issue, not one per owner +- `cli/src/application/use-cases/doctor/doctor-merge-files-use-case.ts` - same deduplication +- `cli/src/application/use-cases/doctor/doctor-layout-use-case.ts` - detect an orphaned shared tree, not only an orphaned tool directory +- `cli/src/application/use-cases/status-use-case.ts` - include the shared tree in the scanned surface +- `cli/tests/application/use-cases/uninstall-use-case.unit.test.ts` - extend beyond the current disjoint-path coverage +- `cli/tests/golden/framework-build-golden.e2e.test.ts` - add the subset invariant assertion between the codex and gemini flat cells + +### Files to create + +- `cli/tests/application/use-cases/uninstall/shared-path-guard.integration.test.ts` - two owners of one path, remove one, the file survives and the survivor reports healthy +- `cli/tests/domain/models/manifest-path-owners.unit.test.ts` - the derived owners view: single owner, two owners, zero owners, and hash divergence between owners + +### Files to delete + +None. + +## Applicable rules + +| Tool | Name | Path | Why it applies | +| ------ | ------------------------ | --------------------------------------------------------------- | -------------- | +| claude | 0-hexagonal | `cli/.claude/rules/00-architecture/0-hexagonal.md` | The owners view is a domain-model concern, not a use-case one | +| claude | 0-layer-responsibilities | `cli/.claude/rules/00-architecture/0-layer-responsibilities.md` | Domain model validates invariants; use-cases orchestrate | +| claude | 0-error-handling | `cli/.claude/rules/00-architecture/0-error-handling.md` | A skipped deletion must surface, never be silent | +| claude | 1-exports | `cli/.claude/rules/01-standards/1-exports.md` | Named exports only | +| claude | 1-naming | `cli/.claude/rules/01-standards/1-naming.md` | New test tiers must match the suffix convention | +| claude | 2-typescript | `cli/.claude/rules/02-programming-languages/2-typescript.md` | `readonly` on the returned map, no `any` | +| claude | 3-cli-output | `cli/.claude/rules/03-frameworks-and-libraries/3-cli-output.md` | A retained file is a `warn`, not an `error` | +| claude | 4-biome | `cli/.claude/rules/04-tooling/4-biome.md` | All touched TypeScript must pass `biome check` | +| claude | 6-method-size | `cli/.claude/rules/06-design-patterns/6-method-size.md` | The guard extraction must not produce a method over 20 lines | +| claude | 7-clean-code | `cli/.claude/rules/07-quality/7-clean-code.md` | Three copies of the guard is the DRY violation this part removes | + +The project's `use-case`, `domain-model` and `test` skills apply per layer touched. + +## User Journey + +```mermaid +--- +title: Removing one owner of a co-owned skills tree +--- +flowchart TD + User["User removes one AIDD plugin or tool"] + Collect["Collect that owner's tracked paths"] + Owners{"Path still claimed by another owner?"} + Retain["Retain file and warn"] + Delete["Delete file"] + Prune["Prune empty directories"] + Release["Release this owner's claim in the manifest"] + Doctor["User runs doctor"] + Healthy["One issue per path, survivor reports healthy"] + + User -- triggers --> Collect + Collect --> Owners + Owners -.-> Retain + Owners --> Delete + Delete --> Prune + Retain --> Release + Prune --> Release + Release --> Doctor + Doctor --> Healthy +``` + +## Risk register + +| Risk | Impact | Mitigation | +| --- | --- | --- | +| Guarding plugin deletion leaves orphan files when the last owner goes | Files accumulate and are never cleaned | The guard checks remaining owners, not mere co-ownership; when the last claim is released the file is deleted | +| The manifest owners view is computed per call | Repeated scans on large manifests | Derived once on read and returned as a readonly map, following the existing tracked-paths-in-directory pattern | +| Deduplicating doctor issues hides a genuine per-owner divergence | A real hash divergence between owners is swallowed | Divergence is reported explicitly as its own issue rather than collapsed into the deduplicated missing-file issue | +| Changing doctor output changes exit codes | The golden baseline stdout snapshot fails | The golden baseline is re-run and any stdout change is reviewed as intentional, not force-updated | +| No manifest schema change means co-ownership stays implicit | A future maintainer reintroduces the assumption of one owner per path | The owners view is the single accessor, and the invariant is covered by unit tests rather than by convention | + +## Implementation phases + +### Phase 1: One shared-path guard, three call sites + +> Remove the duplication and extend the existing protection to the two unguarded uninstall paths. + +#### Tasks + +1. Extract the shared-path computation currently living inside tool uninstall. +2. Apply it in plugin uninstall before every tracked-file deletion. +3. Apply it in IDE uninstall. +4. Emit one warning per retained file, naming the remaining owner. +5. Extend the uninstall test suite past its current disjoint-path coverage. + +#### Acceptance criteria + +- [ ] Two owners of one path, removing one leaves the file on disk +- [ ] Removing the last owner deletes the file +- [ ] Every retained file is reported on stderr; no retention is silent +- [ ] The guard exists in exactly one place + +### Phase 2: Make co-ownership a first-class read + +> Give consumers a path-to-owners view so they iterate paths, not tool-and-path pairs. No schema change. + +#### Tasks + +1. Add the derived owners accessor to the manifest model. +2. Cover the single-owner, two-owner, zero-owner and divergent-hash cases. +3. Rewire the uninstall guard to consume it. + +#### Acceptance criteria + +- [ ] The manifest version is unchanged and no migration is added +- [ ] The accessor returns a readonly structure +- [ ] Divergent hashes between owners are observable through the accessor rather than hidden + +### Phase 3: Make the shared tree visible + +> Stop doctor and status from reporting on a surface they cannot see. + +#### Tasks + +1. Deduplicate co-owned paths in the tracked-files and merge-files doctor checks. +2. Report a genuine inter-owner hash divergence as its own distinct issue. +3. Extend the orphan check beyond a tool's own directory to cover an abandoned shared tree. +4. Include the shared tree in the status scan surface. +5. Re-run the golden baseline and review every stdout change as intentional. + +#### Acceptance criteria + +- [ ] One missing co-owned file produces exactly one issue +- [ ] An abandoned shared tree is reported as orphaned +- [ ] A hash divergence between two owners is reported, distinctly from a missing file +- [ ] Golden baseline stdout changes are reviewed and justified, never blind-updated + +### Phase 4: Lock the rendering invariant + +> The cheapest way to keep co-ownership safe is to make the co-owned bytes identical. + +#### Tasks + +1. Assert in the golden suite that the gemini flat cell's shared skills tree is a byte-identical subset of the codex flat cell's. +2. Make the assertion fail loudly if the two ever diverge, naming the first differing path. + +#### Acceptance criteria + +- [ ] The invariant is asserted in the golden suite, not merely documented +- [ ] A deliberate one-byte divergence makes the suite fail with the offending path named +- [ ] `cd cli && pnpm typecheck && pnpm lint && pnpm test` exits 0 + +## Amendments + + + +## Log + + + +## Validation flow demonstration + +1. In a fresh temporary project, install AIDD for two tools that both claim the shared skills tree. +2. Confirm both manifest entries list the same shared paths. +3. Remove one of the two. +4. Confirm the shared skill files are still on disk, and that the retention was reported on stderr. +5. Run `doctor` and confirm the surviving tool is healthy, with no duplicated issue for the shared paths. +6. Remove the second one and confirm the shared tree is now gone and no empty directory is left behind. diff --git a/aidd_docs/tasks/2026_07/2026_07_27_gemini-cli-build-target/2026_07_27-511-gemini-cli-tool-part-3.md b/aidd_docs/tasks/2026_07/2026_07_27_gemini-cli-build-target/2026_07_27-511-gemini-cli-tool-part-3.md new file mode 100644 index 000000000..0d3599d75 --- /dev/null +++ b/aidd_docs/tasks/2026_07/2026_07_27_gemini-cli-build-target/2026_07_27-511-gemini-cli-tool-part-3.md @@ -0,0 +1,213 @@ +--- +name: plan +description: Living implementation plan - frozen objective, phases, and append-only execution Log. Used as input artifact AND as the autonomous-loop tracking file. +argument-hint: N/A +objective: "aidd ai install/uninstall/update/restore/doctor/status all work for gemini, with no tool-name literal left in the plugin translation path." +success_condition: "cd cli && pnpm typecheck && pnpm lint && pnpm test && pnpm smoke" +iteration: 0 +created_at: "2026-07-27T22:44:41+02:00" +--- + +# Instruction: Gemini as a full registry citizen + +## Feature + +- **Summary**: Give gemini the whole lifecycle the other tools have. Two hardcoded opencode literals in the plugin translation path block this and must be derived from capabilities instead. Marketplace mode stays out of scope, so gemini follows the opencode precedent of a flat-only citizen. +- **Stack**: `Node.js >= 22.12`, `TypeScript (ESM, relative .js imports)`, `vitest`, `biome`, `bash`, `pnpm`, `Gemini CLI >= 0.28.0` +- **Branch name**: `feat/511-gemini-registry-citizen` +- **Parent Plan**: `./2026_07_27-511-gemini-cli-tool-master.md` +- **Sequence**: `3 of 4` +- Confidence: 9/10 +- Time to implement: one session + +## What actually blocks this + +The lifecycle is overwhelmingly registry-driven: install, update, restore, doctor, status, setup, clean and the manifest all iterate the registry and need no edit. Three concrete things do not: + +- `built-tree-materialization-translator.ts:64` picks the translation mode with `toolId === "opencode" ? "flat" : "marketplace"`. Gemini is flat-only, so this literal makes gemini take the marketplace branch. +- The same file, at `:120-125`, decides plugin ownership with `segments[0] === ".opencode"`. +- `plugin-remove-use-case.ts:72-80` routes to the opencode MCP unmerge on a capability shape gemini will share, which would run the opencode unmerge against `.gemini/settings.json`. + +Both literals must become capability-derived. That is the refactoring cost of this part, and it improves opencode too. + +## Architecture projection + +### Files to modify + +- `cli/src/application/use-cases/plugin/translator/built-tree-materialization-translator.ts` - derive the translation mode from the plugins capability instead of a tool-name literal, and derive plugin ownership from the tool's directory plus the shared tree instead of a hardcoded segment +- `cli/src/application/use-cases/plugin/plugin-remove-use-case.ts` - route the MCP unmerge by capability rather than assuming the opencode shape +- `cli/src/domain/models/plugin-translation-skip.ts` - parameterize the hooks-skip reason so the message names the actual tool +- `cli/src/application/use-cases/restore/restore-use-case.ts` - add the gemini config reference only if gemini restores a framework-sourced config file +- `cli/scripts/smoke-tools.sh` - add gemini to the tool list and update the two stale comments +- `cli/tests/e2e/command-matrix-ai.e2e.test.ts` - add the gemini install and uninstall pair +- `cli/tests/domain/models/plugin-distribution-translate.unit.test.ts` - add gemini as a translation target in the cross-format matrix +- `cli/tests/application/use-cases/plugin/translator/*` - extend the existing opencode translator suites to cover the now-generalized branches + +### Files to create + +- `cli/tests/application/use-cases/plugin/translator/install-plugin-gemini-flat.integration.test.ts` - flat materialization for gemini +- `cli/tests/application/use-cases/plugin/translator/install-plugin-gemini-mcp.integration.test.ts` - MCP merge into the shared settings file +- `cli/tests/application/use-cases/plugin/translator/remove-plugin-gemini-mcp.integration.test.ts` - MCP unmerge leaves user keys intact +- `cli/tests/application/use-cases/plugin/translator/built-tree-gemini-materialization.integration.test.ts` - built-tree path takes the flat branch + +### Files to delete + +None. + +## Applicable rules + +| Tool | Name | Path | Why it applies | +| ------ | ------------------------ | --------------------------------------------------------------- | -------------- | +| claude | 0-hexagonal | `cli/.claude/rules/00-architecture/0-hexagonal.md` | Capability-derived routing keeps tool knowledge in the domain | +| claude | 0-layer-responsibilities | `cli/.claude/rules/00-architecture/0-layer-responsibilities.md` | Explicitly forbids tool-specific logic in use-cases, which is exactly what is being removed | +| claude | 0-error-handling | `cli/.claude/rules/00-architecture/0-error-handling.md` | The unmerge routing must throw a typed error on an unexpected capability shape | +| claude | 1-exports | `cli/.claude/rules/01-standards/1-exports.md` | Named exports only | +| claude | 1-naming | `cli/.claude/rules/01-standards/1-naming.md` | New integration and e2e suites must match the suffix convention | +| claude | 2-typescript | `cli/.claude/rules/02-programming-languages/2-typescript.md` | Relative `.js` imports, no `any` | +| claude | 3-cli-lifecycle | `cli/.claude/rules/03-frameworks-and-libraries/3-cli-lifecycle.md` | The `ai` subcommand surface now accepts gemini | +| claude | 3-cli-output | `cli/.claude/rules/03-frameworks-and-libraries/3-cli-output.md` | Skipped hooks in flat mode are a `warn` | +| claude | 4-biome | `cli/.claude/rules/04-tooling/4-biome.md` | All touched TypeScript must pass `biome check` | +| claude | 6-method-size | `cli/.claude/rules/06-design-patterns/6-method-size.md` | The extracted routing helpers must stay at or under 20 lines | +| claude | 7-clean-code | `cli/.claude/rules/07-quality/7-clean-code.md` | Removing the two literals is the fail-fast and DRY work this part exists for | + +The project's `use-case`, `command` and `test` skills apply. `audit-remediate` is the right macro if the translator turns out to need a full layer pass rather than two surgical edits. + +## User Journey + +```mermaid +--- +title: Gemini through the full aidd lifecycle +--- +flowchart TD + Install["aidd ai install gemini"] + Route{"Translation mode from capability"} + Flat["Flat materialization"] + Marketplace["Marketplace materialization"] + Files["Skills, agents and merged settings written and tracked"] + Status["aidd status shows gemini"] + Update["aidd ai update gemini"] + Conflict{"User edited a tracked file?"} + Prompt["Prompt or skip, never clobber"] + Restore["aidd ai restore gemini"] + Doctor["aidd ai doctor gemini reports healthy"] + Uninstall["aidd ai uninstall gemini"] + Shared{"Path co-owned?"} + Keep["Retain and warn"] + Remove["Remove"] + + Install --> Route + Route --> Flat + Route -.-> Marketplace + Flat --> Files + Files --> Status + Status --> Update + Update --> Conflict + Conflict -.-> Prompt + Conflict --> Restore + Prompt --> Restore + Restore --> Doctor + Doctor --> Uninstall + Uninstall --> Shared + Shared -.-> Keep + Shared --> Remove +``` + +## Risk register + +| Risk | Impact | Mitigation | +| --- | --- | --- | +| Deriving the translation mode changes opencode's behaviour | A working tool regresses | The existing opencode translator suites must pass unchanged; they are the regression net for this refactor | +| The MCP unmerge routing misfires on the shared settings file | User keys destroyed on plugin removal | A dedicated removal test asserts unrelated user keys survive; the routing throws on an unrecognized capability shape rather than guessing | +| Flat mode skips hooks, so an installed gemini has no hooks | Silent capability gap versus the archive | The skip is warned, and the difference between the install path and the archive path is documented in part 4 | +| Smoke tests pollute the repository or the user's global tool state | Tracked residue committed by accident, or the real user configuration mutated | Smoke runs in a fresh temporary directory with `git init`, and the tool home is sandboxed per run, as the project's testing memory requires | +| A smoke case looks covered but never executes | False confidence | Guards pick a tracked file from the manifest, which is the source of truth, never by walking the filesystem | +| The smoke coverage gate fails once gemini is added | CI blocks | Gemini leaf commands are added to the matrix in the same change as the tool entry, not after | + +## Implementation phases + +### Phase 1: Remove the two opencode literals + +> Derive routing from capabilities so a flat-only tool is not a special case. + +#### Tasks + +1. Replace the tool-name comparison that picks the translation mode with a capability read. +2. Replace the hardcoded directory-segment ownership check with one derived from the tool's directory and the shared tree. +3. Parameterize the hooks-skip reason so the message names the tool it applies to. +4. Run the existing opencode translator suites unchanged as the regression net. + +#### Acceptance criteria + +- [ ] No tool-name literal remains in the plugin translation path +- [ ] Every pre-existing opencode translator test passes without modification +- [ ] The hooks-skip message names the actual tool + +### Phase 2: Route the MCP unmerge safely + +> Plugin removal must not run one tool's unmerge against another tool's file. + +#### Tasks + +1. Make the unmerge selection depend on the capability shape rather than an assumed tool. +2. Throw a typed error on an unrecognized shape instead of falling through. +3. Cover removal on the shared settings file, asserting unrelated user keys survive. + +#### Acceptance criteria + +- [ ] Removing a plugin leaves every user-authored key in the settings file intact +- [ ] An unrecognized capability shape raises a typed error, not a silent fallthrough +- [ ] Removal is idempotent: running it twice changes nothing the second time + +### Phase 3: Wire the command matrix + +> Every leaf command must work for gemini, proven against the real binary. + +#### Tasks + +1. Add gemini to the smoke tool list and refresh the two stale comments. +2. Add the install and uninstall pair to the command-matrix e2e suite. +3. Add gemini as a translation target in the cross-format unit matrix. +4. Write the four new integration suites: flat materialization, MCP merge, MCP unmerge, built-tree materialization. +5. Add the gemini config reference to restore only if a framework-sourced config is genuinely used; otherwise record why not. + +#### Acceptance criteria + +- [ ] Every `ai` leaf command runs for gemini against the built binary +- [ ] The smoke coverage gate is met +- [ ] Smoke ran in a fresh temporary directory with a sandboxed tool home, and the repository working tree is clean afterwards +- [ ] `cd cli && pnpm typecheck && pnpm lint && pnpm test && pnpm smoke` exits 0 + +### Phase 4: Verify install parity against the real tool + +> Confirm that what install produces is what Gemini actually consumes, and record the gaps. + +#### Tasks + +1. Install AIDD for gemini into a fresh temporary project. +2. Probe the real binary for skills, agents and settings. +3. Record every difference between the install output and the archive output, hooks in particular. +4. Decide per difference: fix now, or document as a known flat-mode limitation. + +#### Acceptance criteria + +- [ ] Skills installed by the install path are discovered by the real binary +- [ ] Agents installed by the install path are accepted by the strict frontmatter schema +- [ ] Every install-versus-archive difference is either fixed or written down, none left implicit + +## Amendments + + + +## Log + + + +## Validation flow demonstration + +1. In a fresh temporary directory with `git init` and a sandboxed tool home, install AIDD for gemini. +2. Run `status` and see gemini listed as installed. +3. Probe the real binary: skills are discovered, agents parse, the settings file carries the expected keys. +4. Edit one tracked file by hand, run `update`, and confirm the edit is not clobbered. +5. Run `restore` and confirm the file returns to its expected content. +6. Run `doctor` and get a healthy report with exit code 0. +7. Uninstall gemini and confirm no residue outside co-owned paths still claimed by another tool. diff --git a/aidd_docs/tasks/2026_07/2026_07_27_gemini-cli-build-target/2026_07_27-511-gemini-cli-tool-part-4.md b/aidd_docs/tasks/2026_07/2026_07_27_gemini-cli-build-target/2026_07_27-511-gemini-cli-tool-part-4.md new file mode 100644 index 000000000..6582a72b5 --- /dev/null +++ b/aidd_docs/tasks/2026_07/2026_07_27_gemini-cli-build-target/2026_07_27-511-gemini-cli-tool-part-4.md @@ -0,0 +1,216 @@ +--- +name: plan +description: Living implementation plan - frozen objective, phases, and append-only execution Log. Used as input artifact AND as the autonomous-loop tracking file. +argument-hint: N/A +objective: "Gemini appears wherever AIDD documents its supported tools, the memory hook knows its context file, and issue #511 no longer states false facts." +success_condition: "cd cli && pnpm typecheck && pnpm lint && pnpm test" +iteration: 0 +created_at: "2026-07-27T22:44:41+02:00" +--- + +# Instruction: Gemini content and documentation + +## Feature + +- **Summary**: Add gemini wherever AIDD enumerates its supported tools, teach the memory hook that gemini's context file is `AGENTS.md`, publish the two prerequisites that gate the user's success, and rewrite the issue body whose surface mapping is factually wrong. This part carries the deliberate golden re-baseline that parts 1 to 3 avoided. +- **Stack**: `Markdown`, `Node.js >= 22.12`, `vitest`, `pnpm`, `gh` +- **Branch name**: `docs/511-gemini-content-and-docs` +- **Parent Plan**: `./2026_07_27-511-gemini-cli-tool-master.md` +- **Sequence**: `4 of 4` +- Confidence: 9/10 +- Time to implement: one session + +## Measured scope, not estimated + +A full scan of `plugins/**` for Claude-specific literals returns 80 hits across 36 files. Under `plugins/*/skills/**` specifically, the classification is: + +| Class | Hits | Files | Action | +| --- | --- | --- | --- | +| Multi-tool table row | 18 | 10 | Do not rewrite the claude rows. Add a gemini row to each table | +| Real runtime path | 19 | 12 | All in `aidd-orchestrator/skills/00-async-dev`. Out of scope: that plugin is excluded from the gemini target | +| Prose mention of Claude Code | 14 | 3 | Leave alone. Harmless | +| Plugin-root token | 2 | 2 | Already rewritten by the build | + +This corrects the brainstorm's estimate. Its claim of four `.claude/` occurrences in one `SKILL.md` is accurate. Its "roughly 24 reference files" undercounts the total but, more importantly, mislabels the shape: only 19 hits are real paths, they are all in the one plugin now excluded from gemini, and one of them (`enabledPlugins` in `.claude/settings.json`) has no Gemini equivalent at all. + +Confirmed by reading the build: `claude-root-path-rewrite.ts` rewrites only the plugin-root token inside JSON. It never rewrites a `.claude/` literal in markdown. So source edits are genuinely required for anything classified as a real path, and build-time rewriting is not an escape hatch. + +## Architecture projection + +### Files to modify + +- `plugins/aidd-context/hooks/update_memory.js` - add gemini to the tool-to-context-file map so the project memory block is written into `AGENTS.md` +- `plugins/aidd-context/skills/00-onboard/references/state/detection.md` - add the gemini detection row +- `plugins/aidd-context/skills/02-project-memory/references/tools.md` - add the gemini row; this file and the hook must stay in sync +- `plugins/aidd-context/skills/04-skill-generate/references/tool-detect.md` - add the gemini detection row +- `plugins/aidd-context/skills/04-skill-generate/references/tool-write.md` - add the gemini skills path row +- `plugins/aidd-context/skills/05-rule-generate/references/tool-paths.md` - add the gemini rules row and its detection row +- `plugins/aidd-context/skills/06-agent-generate/references/tool-paths.md` - add the gemini agents row and its detection row +- `plugins/aidd-context/skills/07-command-generate/references/tool-paths.md` - add the gemini row, marking commands unsupported +- `plugins/aidd-context/skills/08-hook-generate/references/tool-paths.md` - add the gemini hooks column with its real event names and scopes +- `plugins/aidd-context/skills/10-learn/references/sync-arguments.md` - add the gemini context-file row +- `plugins/aidd-context/skills/11-explore/references/ai-mapping.md` - add gemini to the surfaces, hooks and plugin-location tables +- `plugins/aidd-context/skills/11-explore/scripts/list-rules.mjs` - add the gemini rules entry and its doc comment +- `README.md` - prerequisites line, compatibility table moves gemini from in progress to supported flat, and a gemini install block +- `cli/README.md` - MCP output-path table, config table, `--target` value list, flat-only note, per-tool layout matrix, flat materialization examples +- `cli/ARCHITECTURE.md` - five targets becomes six, nine build cells becomes ten +- `docs/MAINTAINERS.md` - the archive count +- `cli/.claude/rules/00-architecture/0-hexagonal.md` - the list of tool definitions under `domain/tools/ai/` +- `cli/aidd_docs/memory/architecture.md`, `codebase-map.md`, `project-brief.md`, `testing.md` - the project-memory files that enumerate five tools +- `aidd_docs/memory/architecture.md`, `codebase-map.md` - the framework-level memory files +- `cli/tests/golden/snapshots/framework-build/golden.json` - the deliberate re-baseline: every flat cell that publishes the edited reference files changes +- `cli/tests/golden/framework-build-golden.e2e.test.ts` - document this re-baseline pass in the header, as previous passes were documented +- `aidd_docs/tasks/2026_05/2026_05_06-cli-v5-cleanup-sync-matrix.md` - the manual pair matrix grows from twenty to thirty pairs +- issue #511 body - rewrite the surface mapping; its agents, hooks and `AGENTS.md` claims are wrong + +### Files to create + +None. + +### Files to delete + +None. + +## Applicable rules + +| Tool | Name | Path | Why it applies | +| ------ | ------------ | ------------------------------------------------------------ | -------------- | +| claude | 4-biome | `cli/.claude/rules/04-tooling/4-biome.md` | The hook script and the rules script are linted | +| claude | 2-typescript | `cli/.claude/rules/02-programming-languages/2-typescript.md` | Applies to the scripts touched under `plugins/` and `cli/` | +| claude | 7-clean-code | `cli/.claude/rules/07-quality/7-clean-code.md` | Named constants for the repeated tool-to-context-file mapping instead of inline literals | + +Most rules are scoped to `cli/src/**` and do not apply to a documentation part. The framework-level instruction that does apply, from `CLAUDE.md`: before adding any instruction or rule, check whether an existing one already covers or contradicts it, and merge rather than adding a parallel. + +## User Journey + +```mermaid +--- +title: A Gemini user discovering and installing AIDD +--- +flowchart TD + Reader["User reads the README"] + Table["Compatibility table lists Gemini as supported flat"] + Prereq{"Gemini CLI 0.28.0 or newer?"} + Upgrade["Upgrade prompted by the prerequisites"] + Get["User obtains the gemini archive"] + Unzip["Unzip into the project"] + Trust{"Folder trusted?"} + TrustStep["Documented trust step"] + Session["Start a Gemini session"] + Memory["Memory hook writes the project memory block into AGENTS.md"] + Working["Skills activate, agents answer to @name, context loaded"] + + Reader --> Table + Table --> Prereq + Prereq -.-> Upgrade + Prereq --> Get + Upgrade --> Get + Get --> Unzip + Unzip --> Trust + Trust -.-> TrustStep + TrustStep --> Session + Trust --> Session + Session --> Memory + Memory --> Working +``` + +## Risk register + +| Risk | Impact | Mitigation | +| --- | --- | --- | +| A claude row is rewritten instead of a gemini row being added | Claude Code silently breaks for every user of that skill | The classification is per line and recorded above; every table edit is additive, and a diff review confirms no claude row changed | +| The memory hook and its mirror reference file drift | The hook writes to a file the documentation does not name | Both are edited in the same change, and the hook's own comment already points at the reference file | +| The golden re-baseline hides an unintended change | A real regression ships inside an approved re-baseline | The re-baseline diff is reviewed path by path, and only the edited reference files may appear in it | +| Rewriting the issue body loses the original context | Discussion history becomes unreadable | The corrected mapping is added with the original preserved, not silently overwritten | +| The two prerequisites are documented but easy to miss | Users unzip, see nothing, and conclude AIDD is broken | Both appear in the gemini install block itself, not only in a general prerequisites section | +| Minimum-version numbers were derived from source, not documented by the vendor | A stated minimum turns out wrong | Each stated version is labelled as source-derived, and the locally verified version is stated alongside it | + +## Implementation phases + +### Phase 1: Teach the framework about gemini + +> The memory hook and the ten multi-tool tables, additively. + +#### Tasks + +1. Add gemini to the memory hook's tool-to-context-file map, pointing at `AGENTS.md`. +2. Add the gemini row to each of the ten multi-tool tables, without touching any existing row. +3. Add the gemini entry to the rules-listing script and its doc comment. +4. Diff-review every table edit to confirm additive-only. + +#### Acceptance criteria + +- [ ] The memory hook writes the project memory block into `AGENTS.md` for gemini +- [ ] The hook and its mirror reference file agree +- [ ] The diff shows only added lines in the multi-tool tables, no modified claude row +- [ ] The rules-listing script reports gemini rules when a gemini rules directory exists + +### Phase 2: Publish the prerequisites and the mapping + +> Document what actually gates the user's success, including the two constraints absent from the issue. + +#### Tasks + +1. Move gemini from in progress to supported flat in the compatibility table. +2. Add the gemini install block, stating the minimum version and the folder-trust step inside the block. +3. Update the CLI documentation tables: MCP output path, config, target values, flat-only note, layout matrix. +4. Update the architecture and maintainer counts from five targets and nine cells to six and ten. +5. Update the project-memory files at both levels. +6. Label every stated minimum version as source-derived, and name the version actually verified locally. + +#### Acceptance criteria + +- [ ] No document still claims five supported tools or nine build cells +- [ ] The minimum version and the trust step appear in the gemini install block itself +- [ ] Every version claim states how it was established +- [ ] The documented surface mapping matches what the code emits, table by table + +### Phase 3: Re-baseline the golden snapshot deliberately + +> The one place in this work where existing output is allowed to change. + +#### Tasks + +1. Regenerate the golden snapshot. +2. Review the diff path by path; only the edited reference files may appear. +3. Document this re-baseline pass in the golden suite header, matching how previous passes were recorded. +4. Confirm the shared-tree subset invariant from part 2 still holds after the re-baseline. + +#### Acceptance criteria + +- [ ] Every path in the re-baseline diff traces to a reference file edited in phase 1 +- [ ] The re-baseline is documented in the golden suite header with its reason +- [ ] The shared-tree subset invariant still passes +- [ ] `cd cli && pnpm typecheck && pnpm lint && pnpm test` exits 0 + +### Phase 4: Correct the record + +> The issue and the manual matrix still carry the wrong facts. + +#### Tasks + +1. Rewrite the issue's surface mapping from the verified table, correcting the agents, hooks and `AGENTS.md` claims, and preserving the original. +2. State in the issue that the ticket is no longer purely additive and why. +3. Extend the manual pair matrix from twenty pairs to thirty. + +#### Acceptance criteria + +- [ ] The issue no longer says agents have no known equivalent, that hooks need investigation, or that Gemini reads `AGENTS.md` by default +- [ ] The issue records the two prerequisites and the excluded plugin +- [ ] The manual matrix covers all thirty pairs + +## Amendments + + + +## Log + + + +## Validation flow demonstration + +1. Read the README as a new Gemini user: the compatibility table lists Gemini as supported, and the install block states both the minimum version and the trust step. +2. Follow the block: obtain the archive, unzip it, trust the folder. +3. Start a Gemini session and confirm the project memory block was written into `AGENTS.md`. +4. Open any multi-tool table in the shipped skills and find the gemini row next to an unchanged claude row. +5. Read issue #511 and find a surface mapping that matches what the build actually produces. diff --git a/aidd_docs/tasks/2026_07/2026_07_27_gemini-cli-build-target/brainstorm.md b/aidd_docs/tasks/2026_07/2026_07_27_gemini-cli-build-target/brainstorm.md new file mode 100644 index 000000000..f255b94d2 --- /dev/null +++ b/aidd_docs/tasks/2026_07/2026_07_27_gemini-cli-build-target/brainstorm.md @@ -0,0 +1,59 @@ +# Brainstorm: Gemini CLI as a first-class AIDD tool + +> Source: [issue #511](https://github.com/ai-driven-dev/framework/issues/511) β€” the issue's surface mapping is factually wrong and must be rewritten from this document. + +## Refined idea + +Gemini CLI becomes a full citizen of the tool registry, not just a release archive. It gets the whole lifecycle the other five tools have: flat build, install, sync, restore, doctor, update, and a manifest entry. Marketplace mode stays out of scope β€” Gemini CLI has no plugin-manager equivalent. + +The mapping is native, surface by surface. Nothing is degraded into prose, which was the original assumption and is no longer needed. + +| AIDD surface | Gemini CLI target | Notes | +|---|---|---| +| Skills | `.agents/skills/aidd-/SKILL.md` | Shared tree, already codex's target, official Gemini alias. Discovered at session start, activated via the `activate_skill` tool | +| Agents | `.gemini/agents/*.md` | Frontmatter `name` + `description`, body is the system prompt, invoked with `@name` | +| MCP | `mcpServers` in `.gemini/settings.json` | | +| Hooks | `hooks` object in `.gemini/settings.json` | Gemini lifecycle events: `SessionStart`, `BeforeTool`, `AfterTool`, `BeforeModel`, … | +| Rules / context | `AGENTS.md` | Gemini does not read it by default; the build writes `context.fileName` into `.gemini/settings.json` | +| Commands | β€” | Out of scope, as for every target. A framework-build limitation, not a Gemini one | + +Skill content published under `.agents/skills/` becomes tool-neutral: no reference to any tool directory, so a single tree is valid for codex, for Gemini, and for future consumers of the same location (Antigravity, a later ticket). Accepted consequence: **#511 also changes the codex output** β€” it is no longer a purely additive ticket. + +### Success criterion + +A user who has only Gemini CLI unzips the archive into their project. Skills show up at session start and activate. Agents answer to `@`. MCP servers are wired. `AGENTS.md` is loaded as context. + +## Verified facts + +Checked against the Gemini CLI documentation, against the issue's claims: + +- **Skills** β€” native. `.gemini/skills/` or the `.agents/skills/` alias, `SKILL.md` unchanged. ([docs](https://github.com/google-gemini/gemini-cli/blob/main/docs/cli/skills.md)) +- **Agents** β€” native, contrary to the issue's "no known equivalent". `.gemini/agents/*.md`. ([docs](https://github.com/google-gemini/gemini-cli/blob/main/docs/core/subagents.md)) +- **Hooks** β€” native, contrary to the issue's "needs investigation". Declared in `settings.json`. ([docs](https://github.com/google-gemini/gemini-cli/blob/main/docs/hooks/index.md)) +- **Commands** β€” native (`.gemini/commands/*.toml` β†’ `/git:commit`), but the AIDD build emits commands for no target today. +- **AGENTS.md** β€” *not* read by default, contrary to the issue. Requires `context.fileName` (string or array). ([docs](https://github.com/google-gemini/gemini-cli/blob/main/docs/reference/configuration.md)) + +Repo-side, the shared skills tree already exists: `cli/src/domain/tools/ai/codex.ts` sets `prefix: "aidd-"`, producing `.agents/skills/aidd-/SKILL.md` (`cli/src/domain/capabilities/skills-capability.ts:5`). + +## Open assumptions and risks + +1. **Shared ownership is not solved by neutralizing content.** Neutral or not, `aidd ai uninstall codex` deletes `.agents/skills/aidd-*` and Gemini silently loses its skills. The manifest, `restore` and `doctor` need to understand multiple owners for one path. To settle at design time. +2. **Neutralization is bounded but not trivial.** One `SKILL.md` references `.claude/` (`plugins/aidd-orchestrator/skills/00-async-dev/SKILL.md`, 4 occurrences), and roughly 24 reference files under `skills/` mention it too β€” mostly inside multi-tool tables that must **not** be rewritten. Telling a described path from a real path is sorting work, not a `sed`. +3. **`context.fileName` is a user setting.** Merge strategy to define: never overwrite an existing value, handle a user who already set their own list. +4. **Hook mapping is unverified event by event.** Claude and Gemini do not expose the same hook points. Kept in scope on an explicit "let's try"; to be dropped from the ticket if the mapping does not hold. +5. **Detection ambiguity.** Codex's `detectUserFileSectionKey` already claims the `.agents/skills/aidd-` prefix. Gemini's `signalDir` and the resolution of that ambiguity are undefined. +6. **CI cost is unmeasured.** Sync matrix grows 5Γ—5 β†’ 6Γ—6, plus the golden snapshot matrix. +7. **Minimum Gemini CLI version unverified** for skills, agents and hooks. The archive assumes a recent release. +8. **Assumed, not confirmed:** Gemini must work standalone, without codex installed. Reasonable, since `.agents/` is an official Gemini alias, but it was never explicitly confirmed. +9. **Issue #511 must be rewritten**, not merely completed β€” its agents, hooks and AGENTS.md claims are wrong. + +## Decisions taken during the brainstorm + +| Question | Decision | +|---|---| +| Build target only, or full registry citizen? | Full citizen | +| Surfaces without a native equivalent | Moot β€” every needed surface turned out native | +| Where skills land | `.agents/` shared tree | +| Which form the sharing takes | Tool-neutral skill content (option A), accepting the codex output changes too | +| Context file | Everything in `AGENTS.md`, made readable via `context.fileName` written by the build | +| Hooks | In scope, best effort | diff --git a/cli/assets/configs/gemini/settings.json b/cli/assets/configs/gemini/settings.json new file mode 100644 index 000000000..c3b152659 --- /dev/null +++ b/cli/assets/configs/gemini/settings.json @@ -0,0 +1,5 @@ +{ + "context": { + "fileName": ["AGENTS.md"] + } +} diff --git a/cli/src/domain/models/framework-build.ts b/cli/src/domain/models/framework-build.ts index c1d527bcf..b9029534a 100644 --- a/cli/src/domain/models/framework-build.ts +++ b/cli/src/domain/models/framework-build.ts @@ -1,7 +1,13 @@ import { COPILOT_VSCODE_MCP_PATH, COPILOT_WORKSPACE_DIR } from "../tools/ai/copilot-paths.js"; /** Build target: supported tool identifiers for framework build. */ -export type FrameworkBuildTarget = "claude" | "cursor" | "copilot" | "codex" | "opencode"; +export type FrameworkBuildTarget = + | "claude" + | "cursor" + | "copilot" + | "codex" + | "opencode" + | "gemini"; /** Output layout discriminant: marketplace dist (Mode A) vs direct workspace inject (Mode B flat). */ export type FrameworkBuildMode = "marketplace" | "flat"; @@ -26,6 +32,7 @@ export const FRAMEWORK_BUILD_TARGET_MODES: readonly FrameworkBuildTargetMode[] = { target: "codex", mode: "marketplace" }, { target: "codex", mode: "flat" }, { target: "opencode", mode: "flat" }, + { target: "gemini", mode: "flat" }, ]; /** Every target with at least one supported build mode, derived from FRAMEWORK_BUILD_TARGET_MODES. */ diff --git a/cli/src/domain/models/tool-ids.ts b/cli/src/domain/models/tool-ids.ts index 050afa33c..ee4c14450 100644 --- a/cli/src/domain/models/tool-ids.ts +++ b/cli/src/domain/models/tool-ids.ts @@ -1,6 +1,6 @@ import { UnknownAiToolIdError } from "../errors.js"; -export type AiToolId = "claude" | "cursor" | "copilot" | "opencode" | "codex"; +export type AiToolId = "claude" | "cursor" | "copilot" | "opencode" | "codex" | "gemini"; export type IdeToolId = "vscode"; export type ToolId = AiToolId | IdeToolId; export type ToolCategory = "ai" | "ide"; @@ -11,6 +11,7 @@ export const AI_TOOL_IDS: readonly AiToolId[] = [ "copilot", "opencode", "codex", + "gemini", ]; export const IDE_TOOL_IDS: readonly IdeToolId[] = ["vscode"]; export const VALID_TOOL_IDS: readonly ToolId[] = [...AI_TOOL_IDS, ...IDE_TOOL_IDS]; diff --git a/cli/src/domain/tools/ai/gemini.ts b/cli/src/domain/tools/ai/gemini.ts new file mode 100644 index 000000000..183ba5eab --- /dev/null +++ b/cli/src/domain/tools/ai/gemini.ts @@ -0,0 +1,89 @@ +import { AgentsCapability } from "../../capabilities/agents-capability.js"; +import { McpCapability } from "../../capabilities/mcp-capability.js"; +import { PluginsCapability } from "../../capabilities/plugins-capability.js"; +import { SkillsCapability } from "../../capabilities/skills-capability.js"; +import { detectSectionKeyFromPrefixes } from "../../formats/command.js"; +import { baseReverseRewriteContent, baseRewriteContent } from "../../formats/placeholders.js"; +import { CONFIG_MCP } from "../../models/framework.js"; +import type { + AiTool, + HasAgents, + HasMcp, + HasPlugins, + HasSkills, + UserFileSectionKey, +} from "../contracts.js"; +import { registerTool } from "../registry.js"; + +const DIRECTORY = ".gemini/"; +const TOOL_SUFFIX = ".gemini.md"; +const AGENTS_SKILLS_PREFIX = ".agents/skills/"; + +function skillNameFromPath(fileName: string): string { + const parts = fileName.split("/"); + if (parts.length > 1) return parts[0]; + const base = parts[0]; + if (base.endsWith(TOOL_SUFFIX)) return base.slice(0, -TOOL_SUFFIX.length); + if (base.endsWith(".md")) return base.slice(0, -3); + return base; +} + +function buildGeminiSkillFilePath(fileName: string): string { + return `${AGENTS_SKILLS_PREFIX}aidd-${skillNameFromPath(fileName)}/SKILL.md`; +} + +function stripGeminiSkillFrontmatter(fm: Record): Record { + const result: Record = {}; + if (fm.name !== undefined) result.name = fm.name; + if (fm.description !== undefined) result.description = fm.description; + return result; +} + +export const gemini: AiTool = { + kind: "ai", + toolId: "gemini", + directory: DIRECTORY, + toolSuffix: TOOL_SUFFIX, + signalDir: `${DIRECTORY}agents`, + + capabilities: { + agents: new AgentsCapability({ + directory: DIRECTORY, + toolSuffix: TOOL_SUFFIX, + format: "markdown", + }), + skills: new SkillsCapability({ + prefix: "aidd-", + buildInstallPath: buildGeminiSkillFilePath, + convertFrontmatter: stripGeminiSkillFrontmatter, + reverseConvertFrontmatter: (fm) => fm, + }), + mcp: new McpCapability({ + outputPath: ".gemini/settings.json", + format: "json", + entrySection: "mcpServers", + mergeStrategy: "framework-prime", + consumes: [CONFIG_MCP], + }), + // Gemini CLI has no plugin-manager equivalent (no marketplace, no native activation). + // Plugin install/propagation for gemini is out of scope for this build target. + plugins: new PluginsCapability({ mode: "unsupported" }), + }, + + rewriteContent(content: string, docsDir: string): string { + return baseRewriteContent(content, DIRECTORY, docsDir); + }, + + reverseRewriteContent(content: string, docsDir: string): string { + return baseReverseRewriteContent(content, DIRECTORY, docsDir); + }, + + detectUserFileSectionKey(relativePath: string): UserFileSectionKey | null { + return detectSectionKeyFromPrefixes(relativePath, [ + [`${AGENTS_SKILLS_PREFIX}aidd-`, "skills"], + [`${DIRECTORY}agents/`, "agents"], + ]); + }, +}; + +registerTool(gemini); diff --git a/cli/src/infrastructure/assets/asset-loader.ts b/cli/src/infrastructure/assets/asset-loader.ts index d3eb1d0ae..e77305309 100644 --- a/cli/src/infrastructure/assets/asset-loader.ts +++ b/cli/src/infrastructure/assets/asset-loader.ts @@ -6,6 +6,7 @@ import copilotVscodeSettings from "../../../assets/configs/copilot/vscode-settin type: "json", }; import cursorSettings from "../../../assets/configs/cursor/settings.json" with { type: "json" }; +import geminiSettings from "../../../assets/configs/gemini/settings.json" with { type: "json" }; import opencodeJson from "../../../assets/configs/opencode/opencode.json" with { type: "json" }; import vscodeExtensions from "../../../assets/configs/vscode/extensions.json" with { type: "json" }; import vscodeKeybindings from "../../../assets/configs/vscode/keybindings.json" with { @@ -36,6 +37,7 @@ const CONFIG_ASSETS: Readonly = }); describe("VALID_TOOL_IDS", () => { - it("contains exactly claude, cursor, copilot, opencode, codex, vscode", () => { - expect(VALID_TOOL_IDS).toEqual(["claude", "cursor", "copilot", "opencode", "codex", "vscode"]); + it("contains exactly claude, cursor, copilot, opencode, codex, gemini, vscode", () => { + expect(VALID_TOOL_IDS).toEqual([ + "claude", + "cursor", + "copilot", + "opencode", + "codex", + "gemini", + "vscode", + ]); }); }); @@ -52,7 +60,14 @@ describe("stripToolSuffix()", () => { describe("toolIdsForCategory()", () => { it("returns AI tool IDs for 'ai'", () => { - expect(toolIdsForCategory("ai")).toEqual(["claude", "cursor", "copilot", "opencode", "codex"]); + expect(toolIdsForCategory("ai")).toEqual([ + "claude", + "cursor", + "copilot", + "opencode", + "codex", + "gemini", + ]); }); it("returns IDE tool IDs for 'ide'", () => { diff --git a/cli/tests/domain/models/tool-ids.unit.test.ts b/cli/tests/domain/models/tool-ids.unit.test.ts index d1a8e8f87..c7e5eed49 100644 --- a/cli/tests/domain/models/tool-ids.unit.test.ts +++ b/cli/tests/domain/models/tool-ids.unit.test.ts @@ -13,6 +13,7 @@ describe("isAiToolId", () => { expect(isAiToolId("copilot")).toBe(true); expect(isAiToolId("opencode")).toBe(true); expect(isAiToolId("codex")).toBe(true); + expect(isAiToolId("gemini")).toBe(true); }); it("returns false for unknown strings", () => { diff --git a/cli/tests/domain/tools/registry-conformance.unit.test.ts b/cli/tests/domain/tools/registry-conformance.unit.test.ts index 4eab75ba7..77a0475ea 100644 --- a/cli/tests/domain/tools/registry-conformance.unit.test.ts +++ b/cli/tests/domain/tools/registry-conformance.unit.test.ts @@ -5,7 +5,9 @@ import "../../../src/domain/tools/ai/claude.js"; import "../../../src/domain/tools/ai/codex.js"; import "../../../src/domain/tools/ai/copilot.js"; import "../../../src/domain/tools/ai/cursor.js"; +import "../../../src/domain/tools/ai/gemini.js"; import "../../../src/domain/tools/ai/opencode.js"; +import type { PluginsCapability } from "../../../src/domain/capabilities/plugins-capability.js"; import { FRAMEWORK_BUILD_TARGET_MODES } from "../../../src/domain/models/framework-build.js"; import { MARKETPLACE_PROBES, @@ -90,12 +92,14 @@ describe("AiTool contract conformance", () => { ).toBe(true); }); - it("is ingestible when it declares a plugins capability", () => { - const declaresPlugins = "plugins" in (tool.capabilities as object); - if (!declaresPlugins) return; + it("is ingestible when its plugins capability has a marketplace to detect", () => { + // "unsupported" declares the absence of a plugin system, so no probe could describe it. + // Requiring one would make aidd claim it detects a marketplace format that does not exist. + const { plugins } = tool.capabilities as { plugins?: PluginsCapability }; + if (!plugins || plugins.mode === "unsupported") return; expect( MARKETPLACE_PROBES.some((probe) => probe.format === toolId), - `${toolId} declares a plugins capability but has no MARKETPLACE_PROBES entry (domain/models/plugin-format.ts) β€” its native marketplace would never be detected` + `${toolId} declares a "${plugins.mode}" plugins capability but has no MARKETPLACE_PROBES entry (domain/models/plugin-format.ts) β€” its marketplace would never be detected` ).toBe(true); }); }); diff --git a/cli/tests/helpers/ports/build-unit-deps.ts b/cli/tests/helpers/ports/build-unit-deps.ts index 0f3b7f045..9976440cd 100644 --- a/cli/tests/helpers/ports/build-unit-deps.ts +++ b/cli/tests/helpers/ports/build-unit-deps.ts @@ -4,6 +4,7 @@ import "../../../src/domain/tools/ai/claude.js"; import "../../../src/domain/tools/ai/codex.js"; import "../../../src/domain/tools/ai/copilot.js"; import "../../../src/domain/tools/ai/cursor.js"; +import "../../../src/domain/tools/ai/gemini.js"; import "../../../src/domain/tools/ai/opencode.js"; import "../../../src/domain/tools/ide/vscode.js"; import { CLIOutput } from "../../../src/application/output.js"; diff --git a/cli/tests/infrastructure/assets/asset-loader.unit.test.ts b/cli/tests/infrastructure/assets/asset-loader.unit.test.ts index a4806078b..4e3677b37 100644 --- a/cli/tests/infrastructure/assets/asset-loader.unit.test.ts +++ b/cli/tests/infrastructure/assets/asset-loader.unit.test.ts @@ -21,6 +21,15 @@ describe("BundledAssetProviderAdapter.loadConfigAsset", () => { }); }); + describe("gemini", () => { + it("returns parsed settings.json with context.fileName", () => { + const asset = provider.loadConfigAsset("gemini", "settings.json") as { + context: { fileName: string[] }; + }; + expect(asset.context.fileName).toEqual(["AGENTS.md"]); + }); + }); + describe("codex", () => { it("returns config.toml as raw string", () => { const asset = provider.loadConfigAsset("codex", "config.toml"); From 4ef926c74f6956272685ab57fafbf7abb19d19d8 Mon Sep 17 00:00:00 2001 From: Thierry Kwiatkowski Date: Wed, 29 Jul 2026 16:41:47 +0200 Subject: [PATCH 02/23] feat(cli): add gemini settings.json merge authority domain/formats/gemini-settings-merge.ts becomes the single authority on .gemini/settings.json: mergeGeminiSettingsHooks translates Claude hook event names to Gemini's own vocabulary (verified against the shipped gemini-cli 0.52.0 bundle's hooks-migrate table) and warns on anything unmapped rather than writing an invalid event name; mergeGeminiSettingsSeed does an idempotent, order-preserving context.fileName array union so a pre-existing user value is never lost. Both preserve every key they don't own, so hooks, mcpServers (reusing mergeVscodeMcp, no new code needed there) and the seed can write into the same file in sequence without clobbering each other. Phase 2 of part-1 (feat/511-gemini-flat-build-target). Co-Authored-By: Claude Sonnet 5 --- .../2026_07_27-511-gemini-cli-tool-part-1.md | 21 +-- .../domain/formats/gemini-settings-merge.ts | 159 ++++++++++++++++++ .../gemini-settings-merge.unit.test.ts | 150 +++++++++++++++++ 3 files changed, 320 insertions(+), 10 deletions(-) create mode 100644 cli/src/domain/formats/gemini-settings-merge.ts create mode 100644 cli/tests/domain/formats/gemini-settings-merge.unit.test.ts diff --git a/aidd_docs/tasks/2026_07/2026_07_27_gemini-cli-build-target/2026_07_27-511-gemini-cli-tool-part-1.md b/aidd_docs/tasks/2026_07/2026_07_27_gemini-cli-build-target/2026_07_27-511-gemini-cli-tool-part-1.md index 25112224b..8db656c56 100644 --- a/aidd_docs/tasks/2026_07/2026_07_27_gemini-cli-build-target/2026_07_27-511-gemini-cli-tool-part-1.md +++ b/aidd_docs/tasks/2026_07/2026_07_27_gemini-cli-build-target/2026_07_27-511-gemini-cli-tool-part-1.md @@ -163,19 +163,19 @@ flowchart TD #### Tasks -1. Generalize the existing settings-hooks merge helper rather than adding a parallel implementation. -2. Add the Claude to Gemini hook event mapping, mirroring how the existing per-tool event map is declared. -3. Implement the additive `mcpServers` merge on the same file. -4. Implement the idempotent `context.fileName` array union. +1. ~~Generalize the existing settings-hooks merge helper~~ β€” new module `domain/formats/gemini-settings-merge.ts` (Gemini's event vocabulary and settings shape are distinct enough from Claude/Cursor/Codex's that a parallel `mergeGeminiSettingsHooks` was clearer than threading a fourth branch through `flat-hooks-merge.ts`; still a pure function following the exact same `(existing, incoming) => {content, warnings}` contract). +2. Add the Claude to Gemini hook event mapping (`GEMINI_HOOK_EVENT_MAP`), verified against the shipped `gemini-cli` 0.52.0 bundle's own `gemini hooks migrate --from-claude` table. +3. πŸ€– The additive `mcpServers` merge needs no new code: gemini's shape is byte-identical to `.mcp.json`'s (verified), so Phase 4 reuses `mergeVscodeMcp(existing, incoming, force, "mcpServers")` directly β€” the tool skill's own rule ("generalize a helper rather than reimplement it"). +4. Implement the idempotent `context.fileName` array union (`mergeGeminiSettingsSeed`). 5. Emit a `warn` for any source hook event with no Gemini equivalent, and drop it rather than write an invalid event name. -6. Write the unit suite: empty file, user keys preserved, idempotence, pre-existing user `context.fileName` list, unmapped event. +6. Write the unit suite: empty file, user keys preserved, idempotence, pre-existing user `context.fileName` list (array and string forms), unmapped event. #### Acceptance criteria -- [ ] Merging twice produces identical bytes -- [ ] A pre-existing user `context.fileName` array retains its entries and gains `AGENTS.md` -- [ ] A user-authored unrelated key in the settings file survives every merge -- [ ] An unmapped hook event produces a warning and no output entry +- [x] Merging twice produces identical bytes +- [x] A pre-existing user `context.fileName` array retains its entries and gains `AGENTS.md` +- [x] A user-authored unrelated key in the settings file survives every merge +- [x] An unmapped hook event produces a warning and no output entry ### Phase 3: Add the plugin-exclusion mechanism @@ -247,7 +247,8 @@ flowchart TD -- Phase 1: `AiToolId`/`AI_TOOL_IDS`/`FrameworkBuildTarget` extended; `CONFIG_ASSETS["gemini"]` + `assets/configs/gemini/settings.json` seed (`context.fileName: ["AGENTS.md"]`) added; `domain/tools/ai/gemini.ts` written and registered (see Amendments for scope); side-effect imports added to `deps.ts` + both test helpers; two exact-array assertions and `isAiToolId` test fixed; new `gemini` block added to `asset-loader.unit.test.ts`. Verified: `pnpm typecheck` (0 errors), `pnpm test:unit` (1413/1413), `pnpm test:e2e` golden framework-build suite (9-cell matrix byte-identical, claude cell frozen), `biome check` (clean after one formatting auto-fix). Two pre-existing, environment-coupled e2e failures observed and confirmed unrelated (`auth status` / `self-update --check`, depend on local `gh auth login` state, no gemini involvement). +- Phase 1: `AiToolId`/`AI_TOOL_IDS`/`FrameworkBuildTarget` extended; `CONFIG_ASSETS["gemini"]` + `assets/configs/gemini/settings.json` seed (`context.fileName: ["AGENTS.md"]`) added; `domain/tools/ai/gemini.ts` written and registered (see Amendments for scope); side-effect imports added to `deps.ts` + both test helpers; two exact-array assertions and `isAiToolId` test fixed; new `gemini` block added to `asset-loader.unit.test.ts`. Verified: `pnpm typecheck` (0 errors), `pnpm test:unit` (1413/1413), `pnpm test:e2e` golden framework-build suite (9-cell matrix byte-identical, claude cell frozen), `biome check` (clean after one formatting auto-fix). Two pre-existing, environment-coupled e2e failures observed and confirmed unrelated (`auth status` / `self-update --check`, depend on local `gh auth login` state, no gemini involvement). Committed 8540e4e9. +- Phase 2: `domain/formats/gemini-settings-merge.ts` written β€” `GEMINI_HOOK_EVENT_MAP` (verified against the shipped `@google/gemini-cli@0.52.0` bundle's `EVENT_MAPPING` in `gemini-6K6USV55.js`'s hooks-migrate command, and its settings/hooks JSON-schema in `chunk-SZMWXEEI.js`), `mergeGeminiSettingsHooks` (event-translated additive hooks merge, preserves other keys, warns+drops unmapped events), `mergeGeminiSettingsSeed` (idempotent `context.fileName` array union, string-or-array normalization, preserves all other keys including ones written by prior mcp/hooks merges). 14 new unit tests in `gemini-settings-merge.unit.test.ts`. Verified: `pnpm typecheck` (0 errors), full `pnpm test:unit` (1427/1427, up from 1413), `biome check` (clean after one formatting auto-fix). ## Validation flow demonstration diff --git a/cli/src/domain/formats/gemini-settings-merge.ts b/cli/src/domain/formats/gemini-settings-merge.ts new file mode 100644 index 000000000..6b8553dfc --- /dev/null +++ b/cli/src/domain/formats/gemini-settings-merge.ts @@ -0,0 +1,159 @@ +/** + * Pure merge helpers for the single-authority `.gemini/settings.json` file. + * + * Gemini CLI has three logical writers landing in the same file at framework-build time: + * hooks, mcpServers, and the context.fileName seed. Each function here is pure (no I/O) + * and preserves every top-level key it does not own, so the three writers can run in + * sequence (hooks, then mcp via mergeVscodeMcp, then the seed) without clobbering + * each other or user-authored keys. + * + * Claude event names (source): PreToolUse, PostToolUse, UserPromptSubmit, Stop, + * SubAgentStop, SessionStart, SessionEnd, PreCompact, Notification (PascalCase). + * Gemini CLI's own event names differ; see GEMINI_HOOK_EVENT_MAP. + */ + +// ── Types ───────────────────────────────────────────────────────────────────── + +type ClaudeHookItem = { + type?: string; + command?: string; + timeout?: number; + description?: string; + [key: string]: unknown; +}; +type ClaudeMatcherGroup = { matcher?: string; hooks: ClaudeHookItem[] }; +type ClaudeHooksShape = { hooks?: Record }; + +type GeminiHookEntry = { type: string; command: string; timeout?: number; description?: string }; +type GeminiMatcherGroup = { matcher?: string; hooks: GeminiHookEntry[] }; +type GeminiSettingsShape = { + hooks?: Record; + context?: { fileName?: string | string[] }; + [key: string]: unknown; +}; + +// ── Event mapping ───────────────────────────────────────────────────────────── + +/** Official Claude β†’ Gemini hook event table, per `gemini hooks migrate --from-claude`. */ +export const GEMINI_HOOK_EVENT_MAP: Readonly> = { + PreToolUse: "BeforeTool", + PostToolUse: "AfterTool", + UserPromptSubmit: "BeforeAgent", + Stop: "AfterAgent", + SubAgentStop: "AfterAgent", + SessionStart: "SessionStart", + SessionEnd: "SessionEnd", + PreCompact: "PreCompress", + Notification: "Notification", +}; + +// ── Hooks: merge into .gemini/settings.json `hooks` key ─────────────────────── + +/** + * Merges a plugin's hooks (Claude nested shape) additively into the top-level `hooks` + * key of `.gemini/settings.json`, translating event names via GEMINI_HOOK_EVENT_MAP. + * Preserves every other top-level key (mcpServers, context, user-authored keys). + * + * An unmapped Claude event is dropped with a warning rather than written as an + * invalid Gemini event name. + * + * @param existingSettings - Current file content, or null if absent. + * @param pluginHooksJson - Path-rewritten plugin hooks.json content (Claude nested shape). + */ +export function mergeGeminiSettingsHooks( + existingSettings: string | null, + pluginHooksJson: string +): { content: string; warnings: readonly string[] } { + const settings: GeminiSettingsShape = existingSettings ? JSON.parse(existingSettings) : {}; + const plugin = JSON.parse(pluginHooksJson) as ClaudeHooksShape; + const pluginHooks = plugin.hooks ?? {}; + const existingHooks = settings.hooks ?? {}; + const { merged, warnings } = appendMappedHooks(existingHooks, pluginHooks); + return { + content: `${JSON.stringify({ ...settings, hooks: merged }, null, 2)}\n`, + warnings, + }; +} + +function appendMappedHooks( + existing: Record, + incoming: Record +): { merged: Record; warnings: readonly string[] } { + const merged: Record = { ...existing }; + const warnings: string[] = []; + for (const [claudeEvent, matchers] of Object.entries(incoming)) { + const geminiEvent = GEMINI_HOOK_EVENT_MAP[claudeEvent]; + if (!geminiEvent) { + warnings.push(`gemini: unmapped hook event '${claudeEvent}' skipped`); + continue; + } + const entries = convertToGeminiEntries(matchers); + merged[geminiEvent] = [...(merged[geminiEvent] ?? []), ...entries]; + } + return { merged, warnings }; +} + +function convertToGeminiEntries(matchers: ClaudeMatcherGroup[]): GeminiMatcherGroup[] { + return matchers.map((group) => ({ + ...(group.matcher !== undefined ? { matcher: group.matcher } : {}), + hooks: group.hooks + .filter((item) => typeof item.command === "string") + .map((item) => buildGeminiHookEntry(item)), + })); +} + +function buildGeminiHookEntry(item: ClaudeHookItem): GeminiHookEntry { + const entry: GeminiHookEntry = { type: item.type ?? "command", command: item.command as string }; + if (typeof item.timeout === "number") entry.timeout = item.timeout; + if (typeof item.description === "string") entry.description = item.description; + return entry; +} + +// ── Settings seed: idempotent context.fileName array union ─────────────────── + +/** + * Merges the bundled settings seed (carrying `context.fileName`) into the accumulated + * `.gemini/settings.json` after hooks and mcp have already written their keys. + * + * Every key survives except `context`, which is merged specially: `fileName` is an + * idempotent, order-preserving array union (existing entries first, never removed; + * new seed entries appended) β€” never a wholesale replace, so a pre-existing user + * `context.fileName` is never lost. Every other top-level key from `existing` wins + * over the seed (the seed only fills in what is not already present). + * + * @param existing - Current accumulated file content, or "" if the file does not exist yet. + * @param seed - Bundled settings seed content (assets/configs/gemini/settings.json). + */ +export function mergeGeminiSettingsSeed(existing: string, seed: string): string { + const existingObj: GeminiSettingsShape = existing.trim() ? JSON.parse(existing) : {}; + const seedObj: GeminiSettingsShape = JSON.parse(seed); + const context = mergeGeminiContext(existingObj.context, seedObj.context); + return `${JSON.stringify({ ...seedObj, ...existingObj, context }, null, 2)}\n`; +} + +function mergeGeminiContext( + existingContext: GeminiSettingsShape["context"], + seedContext: GeminiSettingsShape["context"] +): Record { + const existing = existingContext ?? {}; + const seed = seedContext ?? {}; + const fileName = unionFileNames( + normalizeFileNames(existing.fileName), + normalizeFileNames(seed.fileName) + ); + return { ...seed, ...existing, fileName }; +} + +function normalizeFileNames(value: string | string[] | undefined): readonly string[] { + if (typeof value === "string") return [value]; + if (Array.isArray(value)) return value.filter((v): v is string => typeof v === "string"); + return []; +} + +function unionFileNames(existing: readonly string[], incoming: readonly string[]): string[] { + const result = [...existing]; + for (const name of incoming) { + if (!result.includes(name)) result.push(name); + } + return result; +} diff --git a/cli/tests/domain/formats/gemini-settings-merge.unit.test.ts b/cli/tests/domain/formats/gemini-settings-merge.unit.test.ts new file mode 100644 index 000000000..b80cc1f74 --- /dev/null +++ b/cli/tests/domain/formats/gemini-settings-merge.unit.test.ts @@ -0,0 +1,150 @@ +import { describe, expect, it } from "vitest"; +import { + GEMINI_HOOK_EVENT_MAP, + mergeGeminiSettingsHooks, + mergeGeminiSettingsSeed, +} from "../../../src/domain/formats/gemini-settings-merge.js"; + +// ── mergeGeminiSettingsHooks ────────────────────────────────────────────────── + +describe("mergeGeminiSettingsHooks", () => { + it("merges plugin hooks into an empty settings.json, translating the event name", () => { + const plugin = JSON.stringify({ + hooks: { + SessionStart: [ + { hooks: [{ type: "command", command: "node ./.gemini/hooks/plugin/run.js" }] }, + ], + }, + }); + const { content, warnings } = mergeGeminiSettingsHooks(null, plugin); + const result = JSON.parse(content) as { hooks: Record }; + expect(result.hooks.SessionStart).toHaveLength(1); + expect(warnings).toEqual([]); + }); + + it("translates UserPromptSubmit to BeforeAgent", () => { + const plugin = JSON.stringify({ + hooks: { UserPromptSubmit: [{ hooks: [{ type: "command", command: "prompt.js" }] }] }, + }); + const { content } = mergeGeminiSettingsHooks(null, plugin); + const result = JSON.parse(content) as { hooks: Record }; + expect(result.hooks.BeforeAgent).toHaveLength(1); + expect(result.hooks.UserPromptSubmit).toBeUndefined(); + }); + + it("preserves existing settings keys (mcpServers, context) when merging hooks", () => { + const existing = JSON.stringify({ + mcpServers: { "plugin-server": { command: "node", args: ["server.js"] } }, + context: { fileName: ["AGENTS.md"] }, + }); + const plugin = JSON.stringify({ + hooks: { SessionStart: [{ hooks: [{ type: "command", command: "run.js" }] }] }, + }); + const { content } = mergeGeminiSettingsHooks(existing, plugin); + const result = JSON.parse(content) as Record; + expect(result.mcpServers).toBeDefined(); + expect(result.context).toEqual({ fileName: ["AGENTS.md"] }); + expect(result.hooks).toBeDefined(); + }); + + it("additively appends hooks from a second plugin without overwriting", () => { + const plugin1 = JSON.stringify({ + hooks: { SessionStart: [{ hooks: [{ type: "command", command: "first.js" }] }] }, + }); + const { content: after1 } = mergeGeminiSettingsHooks(null, plugin1); + + const plugin2 = JSON.stringify({ + hooks: { SessionStart: [{ hooks: [{ type: "command", command: "second.js" }] }] }, + }); + const { content: after2 } = mergeGeminiSettingsHooks(after1, plugin2); + const result = JSON.parse(after2) as { hooks: { SessionStart: unknown[] } }; + expect(result.hooks.SessionStart).toHaveLength(2); + }); + + it("warns and drops an unmapped Claude hook event rather than writing an invalid name", () => { + const plugin = JSON.stringify({ + hooks: { PreCompact: [{ hooks: [{ type: "command", command: "compact.js" }] }] }, + }); + const { content, warnings } = mergeGeminiSettingsHooks(null, plugin); + const result = JSON.parse(content) as { hooks: Record }; + expect(result.hooks.PreCompress).toBeDefined(); + expect(warnings).toEqual([]); + }); + + it("warns and drops a truly unmapped event (not in GEMINI_HOOK_EVENT_MAP)", () => { + const plugin = JSON.stringify({ + hooks: { SomeFutureClaudeEvent: [{ hooks: [{ type: "command", command: "x.js" }] }] }, + }); + const { content, warnings } = mergeGeminiSettingsHooks(null, plugin); + const result = JSON.parse(content) as { hooks: Record }; + expect(result.hooks.SomeFutureClaudeEvent).toBeUndefined(); + expect(warnings).toEqual(["gemini: unmapped hook event 'SomeFutureClaudeEvent' skipped"]); + }); + + it("preserves the matcher field when present", () => { + const plugin = JSON.stringify({ + hooks: { + PreToolUse: [{ matcher: "Edit", hooks: [{ type: "command", command: "guard.js" }] }], + }, + }); + const { content } = mergeGeminiSettingsHooks(null, plugin); + const result = JSON.parse(content) as { hooks: { BeforeTool: Array<{ matcher?: string }> } }; + expect(result.hooks.BeforeTool[0]?.matcher).toBe("Edit"); + }); + + it("maps every event AIDD ships today (SessionStart, UserPromptSubmit)", () => { + expect(GEMINI_HOOK_EVENT_MAP.SessionStart).toBe("SessionStart"); + expect(GEMINI_HOOK_EVENT_MAP.UserPromptSubmit).toBe("BeforeAgent"); + }); +}); + +// ── mergeGeminiSettingsSeed ──────────────────────────────────────────────────── + +describe("mergeGeminiSettingsSeed", () => { + const seed = JSON.stringify({ context: { fileName: ["AGENTS.md"] } }); + + it("seeds context.fileName into an empty file", () => { + const content = mergeGeminiSettingsSeed("", seed); + const result = JSON.parse(content) as { context: { fileName: string[] } }; + expect(result.context.fileName).toEqual(["AGENTS.md"]); + }); + + it("merging twice produces identical bytes (idempotent)", () => { + const once = mergeGeminiSettingsSeed("", seed); + const twice = mergeGeminiSettingsSeed(once, seed); + expect(twice).toBe(once); + }); + + it("a pre-existing user context.fileName array retains its entries and gains AGENTS.md", () => { + const existing = JSON.stringify({ context: { fileName: ["CUSTOM.md", "OTHER.md"] } }); + const content = mergeGeminiSettingsSeed(existing, seed); + const result = JSON.parse(content) as { context: { fileName: string[] } }; + expect(result.context.fileName).toEqual(["CUSTOM.md", "OTHER.md", "AGENTS.md"]); + }); + + it("a pre-existing user context.fileName string is normalized into the union", () => { + const existing = JSON.stringify({ context: { fileName: "CUSTOM.md" } }); + const content = mergeGeminiSettingsSeed(existing, seed); + const result = JSON.parse(content) as { context: { fileName: string[] } }; + expect(result.context.fileName).toEqual(["CUSTOM.md", "AGENTS.md"]); + }); + + it("a user-authored unrelated top-level key survives the merge", () => { + const existing = JSON.stringify({ security: { folderTrust: { enabled: false } } }); + const content = mergeGeminiSettingsSeed(existing, seed); + const result = JSON.parse(content) as Record; + expect(result.security).toEqual({ folderTrust: { enabled: false } }); + expect((result.context as { fileName: string[] }).fileName).toEqual(["AGENTS.md"]); + }); + + it("mcpServers and hooks written by earlier merges survive the seed step", () => { + const existing = JSON.stringify({ + mcpServers: { "plugin-server": { command: "node" } }, + hooks: { SessionStart: [{ hooks: [{ type: "command", command: "run.js" }] }] }, + }); + const content = mergeGeminiSettingsSeed(existing, seed); + const result = JSON.parse(content) as Record; + expect(result.mcpServers).toBeDefined(); + expect(result.hooks).toBeDefined(); + }); +}); From 66d7e270d8be0dc29c52f84b1574d07325e6db35 Mon Sep 17 00:00:00 2001 From: Thierry Kwiatkowski Date: Wed, 29 Jul 2026 16:46:02 +0200 Subject: [PATCH 03/23] feat(cli): add plugin-exclusion mechanism to framework build MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lets a ToolBuildContract exclude a plugin by name (excludedPlugins), consumed via a new shouldBuildPlugin(name) predicate on BuildOutputStrategy β€” always true for marketplace, contract-driven for flat β€” with zero per-tool/per-artifact branching added to either orchestrator. FrameworkBuildUseCase's plugin loop skips a rejected plugin with one warn and excludes it from the build result entirely. This is what will let gemini's flat contract exclude aidd-orchestrator (Claude-Code-coupled, no Gemini equivalent) in Phase 4 without any tool-specific branch in the orchestrators. Phase 3 of part-1 (feat/511-gemini-flat-build-target). Co-Authored-By: Claude Sonnet 5 --- .../2026_07_27-511-gemini-cli-tool-part-1.md | 14 +- .../framework/framework-build-use-case.ts | 22 ++- .../strategies/build-output-strategy.ts | 6 + .../strategies/flat-build-strategy.ts | 4 + .../strategies/marketplace-build-strategy.ts | 4 + cli/src/domain/tools/build-contract.ts | 6 + .../plugin-exclusion.integration.test.ts | 159 ++++++++++++++++++ 7 files changed, 205 insertions(+), 10 deletions(-) create mode 100644 cli/tests/application/use-cases/framework/plugin-exclusion.integration.test.ts diff --git a/aidd_docs/tasks/2026_07/2026_07_27_gemini-cli-build-target/2026_07_27-511-gemini-cli-tool-part-1.md b/aidd_docs/tasks/2026_07/2026_07_27_gemini-cli-build-target/2026_07_27-511-gemini-cli-tool-part-1.md index 8db656c56..30a6aebe1 100644 --- a/aidd_docs/tasks/2026_07/2026_07_27_gemini-cli-build-target/2026_07_27-511-gemini-cli-tool-part-1.md +++ b/aidd_docs/tasks/2026_07/2026_07_27_gemini-cli-build-target/2026_07_27-511-gemini-cli-tool-part-1.md @@ -187,13 +187,13 @@ flowchart TD 2. Add the predicate to the build-output strategy interface. 3. Implement it in both strategies: contract-driven for flat, always true for marketplace. 4. Skip rejected plugins in the build use-case plugin loop, emitting one `warn` per skip. -5. Write the integration test proving the excluded plugin is absent from gemini and present elsewhere. +5. πŸ€– Write the integration test proving the mechanism (excluded plugin absent, other plugin present, skip reported, excluded from returned results) β€” named `plugin-exclusion.integration.test.ts`, not `gemini-plugin-exclusion...`, since gemini's real contract (excluding `aidd-orchestrator`) doesn't exist until Phase 4; tested here against a synthetic codex-contract override instead. See Amendments. #### Acceptance criteria -- [ ] The build use-case contains no tool-name literal -- [ ] A grep for `if (tool === ` and `if (kind === "agents")` in both orchestrators returns nothing -- [ ] Skipping a plugin is reported on stderr, and no skip is silent +- [x] The build use-case contains no tool-name literal +- [x] A grep for `if (tool === ` and `if (kind === "agents")` in both orchestrators returns nothing +- [x] Skipping a plugin is reported on stderr, and no skip is silent ### Phase 4: Declare the gemini flat contract @@ -203,10 +203,11 @@ flowchart TD #### Tasks 1. ~~Write the tool definition~~ β€” done in Phase 1 (`agents`, `skills`, `mcp`, `plugins: unsupported`; no `configOutputPaths`). `hooks` and `settings` capabilities (install-mode fidelity) are deliberately not on gemini's `AiTool` yet β€” they need real per-tool merge logic (Claudeβ†’Gemini hook event translation, `context.fileName` array union) that today's generic install pipeline can't express without a capability-class change. Out of this part's objective (`aidd framework build`, not `aidd install`); tracked for Part 3 (registry citizen). -2. Write the flat contract declaring all six artifact kinds, with rules and commands explicitly unsupported. +2. Write the flat contract declaring all six artifact kinds, with rules and commands explicitly unsupported, and `excludedPlugins: ["aidd-orchestrator"]` (Phase 3's mechanism, applied for real). 3. Reuse codex's skills path and transform without copying them; extract a shared helper if needed. 4. Wire the contract and the module import into the dependency graph, and add the target to the command surface and its help text. 5. Add the CI matrix row. +6. Add `gemini-plugin-exclusion.integration.test.ts` β€” the real gemini contract excludes `aidd-orchestrator` and builds every other plugin (Phase 3's test covered the generic mechanism only, against a synthetic contract). #### Acceptance criteria @@ -241,6 +242,8 @@ flowchart TD πŸ€– Phase 1/Phase 4 boundary was unsound as originally scoped: adding `"gemini"` to `AI_TOOL_IDS` (Phase 1) without also calling `registerTool(gemini)` (originally Phase 4 task 1) broke every existing test that spreads `AI_TOOL_IDS` as "install all tools" (`tests/application/use-cases/setup-use-case.unit.test.ts`, 3 failures β€” `UnregisteredToolError: Tool 'gemini' is not registered.`), because `AI_TOOL_IDS` is the real runtime source of truth for "which AI tools does `all` install," not just a type-level list. This would have shipped a crash in the real CLI (`aidd setup --tools all`) had Phase 1 landed alone. Flagged to the user; resolved by pulling the tool-definition write (`domain/tools/ai/gemini.ts`) into Phase 1, atomic with the id registration. Phase 4 shrinks accordingly (its task 1 is struck). +πŸ€– Phase 3's integration test is generic (`plugin-exclusion.integration.test.ts`, spreading `{ ...buildCodexFlatContract(), excludedPlugins: [...] }`), not gemini-specific, because gemini's own flat contract doesn't exist yet at this point in the phase order β€” it lands in Phase 4. Phase 4 gets its own task to add the plan's originally-named `gemini-plugin-exclusion.integration.test.ts` against the real contract excluding `aidd-orchestrator`. + πŸ€– gemini's `AiTool` capability intersection is `HasAgents & HasSkills & HasMcp & HasPlugins` β€” narrower than codex/opencode. Deliberately omitted: `hooks` (Claudeβ†’Gemini event-name translation has no expression point in the current `HooksCapability`/generic install pipeline β€” content passes through untransformed) and `settings` (the idempotent `context.fileName` array union needs custom merge logic; `SettingsCapability` only supports generic `MergeStrategy` enums or static content, not a custom merge function). `plugins` is `{ mode: "unsupported" }` (no marketplace, no native activation, per the master plan). None of this blocks this part's objective β€” `aidd framework build` never reads `AiTool.capabilities` (`FlatBuildStrategy`/`ToolBuildContract` are fully standalone) β€” so the gap is real install-mode functionality deferred to Part 3, not a stub masking Phase 1/4 work. ## Log @@ -249,6 +252,7 @@ flowchart TD - Phase 1: `AiToolId`/`AI_TOOL_IDS`/`FrameworkBuildTarget` extended; `CONFIG_ASSETS["gemini"]` + `assets/configs/gemini/settings.json` seed (`context.fileName: ["AGENTS.md"]`) added; `domain/tools/ai/gemini.ts` written and registered (see Amendments for scope); side-effect imports added to `deps.ts` + both test helpers; two exact-array assertions and `isAiToolId` test fixed; new `gemini` block added to `asset-loader.unit.test.ts`. Verified: `pnpm typecheck` (0 errors), `pnpm test:unit` (1413/1413), `pnpm test:e2e` golden framework-build suite (9-cell matrix byte-identical, claude cell frozen), `biome check` (clean after one formatting auto-fix). Two pre-existing, environment-coupled e2e failures observed and confirmed unrelated (`auth status` / `self-update --check`, depend on local `gh auth login` state, no gemini involvement). Committed 8540e4e9. - Phase 2: `domain/formats/gemini-settings-merge.ts` written β€” `GEMINI_HOOK_EVENT_MAP` (verified against the shipped `@google/gemini-cli@0.52.0` bundle's `EVENT_MAPPING` in `gemini-6K6USV55.js`'s hooks-migrate command, and its settings/hooks JSON-schema in `chunk-SZMWXEEI.js`), `mergeGeminiSettingsHooks` (event-translated additive hooks merge, preserves other keys, warns+drops unmapped events), `mergeGeminiSettingsSeed` (idempotent `context.fileName` array union, string-or-array normalization, preserves all other keys including ones written by prior mcp/hooks merges). 14 new unit tests in `gemini-settings-merge.unit.test.ts`. Verified: `pnpm typecheck` (0 errors), full `pnpm test:unit` (1427/1427, up from 1413), `biome check` (clean after one formatting auto-fix). +- Phase 3: `excludedPlugins?: readonly string[]` added to `ToolBuildContract`; `shouldBuildPlugin(pluginName)` added to `BuildOutputStrategy` (always `true` in `MarketplaceBuildStrategy`, contract-driven in `FlatBuildStrategy`); `FrameworkBuildUseCase`'s plugin loop extracted into `buildAllPlugins`, skipping rejected plugins with one `logger.warn` each and excluding them from the returned `plugins`/marketplace-catalog entries. New `tests/application/use-cases/framework/plugin-exclusion.integration.test.ts` (4 tests, generic mechanism β€” see Amendments for the naming/scope note). Verified: `pnpm typecheck` (0 errors), `pnpm test:unit` (1427/1427), `pnpm test:integration` (505/505, up from 501), grep gate for `if (tool === ` / `if (kind === "agents")` in both orchestrators returns nothing, `biome check` (clean after one formatting auto-fix). ## Validation flow demonstration diff --git a/cli/src/application/use-cases/framework/framework-build-use-case.ts b/cli/src/application/use-cases/framework/framework-build-use-case.ts index d835102d4..cc6577b83 100644 --- a/cli/src/application/use-cases/framework/framework-build-use-case.ts +++ b/cli/src/application/use-cases/framework/framework-build-use-case.ts @@ -34,16 +34,28 @@ export class FrameworkBuildUseCase { this.guardPaths(sourceDir, outDir); const sourceMarketplace = await this.readSourceMarketplace(sourceDir); await this.strategy.preBuild(outDir, sourceDir); - const builtPlugins: BuildPluginResult[] = []; - for (const entry of sourceMarketplace.plugins) { - const plugin = await this.buildPlugin(entry, sourceDir, outDir); - builtPlugins.push(plugin); - } + const builtPlugins = await this.buildAllPlugins(sourceMarketplace, sourceDir, outDir); const extraFiles = await this.strategy.postBuild(sourceMarketplace, builtPlugins, outDir); const totalFiles = builtPlugins.reduce((sum, p) => sum + p.filesWritten, 0) + extraFiles; return { outDir, plugins: builtPlugins, totalFiles }; } + private async buildAllPlugins( + sourceMarketplace: SourceMarketplace, + sourceDir: string, + outDir: string + ): Promise { + const builtPlugins: BuildPluginResult[] = []; + for (const entry of sourceMarketplace.plugins) { + if (!this.strategy.shouldBuildPlugin(entry.name)) { + this.logger.warn(`Skipping plugin '${entry.name}' (excluded for this target).`); + continue; + } + builtPlugins.push(await this.buildPlugin(entry, sourceDir, outDir)); + } + return builtPlugins; + } + private guardPaths(sourceDir: string, outDir: string): void { if (sourceDir === outDir) throw new InvalidBuildPathsError(sourceDir, outDir); if (outDir.startsWith(`${sourceDir}/`) || sourceDir.startsWith(`${outDir}/`)) { diff --git a/cli/src/application/use-cases/framework/strategies/build-output-strategy.ts b/cli/src/application/use-cases/framework/strategies/build-output-strategy.ts index 90ba1d5ec..d0dac0423 100644 --- a/cli/src/application/use-cases/framework/strategies/build-output-strategy.ts +++ b/cli/src/application/use-cases/framework/strategies/build-output-strategy.ts @@ -31,6 +31,12 @@ export interface SourceMarketplace { * and file I/O for its layout. */ export interface BuildOutputStrategy { + /** + * Whether this plugin should be built for this tool. Marketplace mode always + * returns true; flat mode consults the contract's `excludedPlugins`. + */ + shouldBuildPlugin(pluginName: string): boolean; + /** * Called once before iterating plugins. Mode A wipes and recreates outDir. * Flat mode validates outDir exists and is a directory. diff --git a/cli/src/application/use-cases/framework/strategies/flat-build-strategy.ts b/cli/src/application/use-cases/framework/strategies/flat-build-strategy.ts index f701a21fa..217d5281c 100644 --- a/cli/src/application/use-cases/framework/strategies/flat-build-strategy.ts +++ b/cli/src/application/use-cases/framework/strategies/flat-build-strategy.ts @@ -35,6 +35,10 @@ export class FlatBuildStrategy implements BuildOutputStrategy { private readonly logger?: Logger ) {} + shouldBuildPlugin(pluginName: string): boolean { + return !this.contract.excludedPlugins?.includes(pluginName); + } + async preBuild(outDir: string, sourceDir: string): Promise { const exists = await this.fs.fileExists(outDir); if (!exists || !(await this.isDirectory(outDir))) throw new OutDirNotDirectoryError(outDir); diff --git a/cli/src/application/use-cases/framework/strategies/marketplace-build-strategy.ts b/cli/src/application/use-cases/framework/strategies/marketplace-build-strategy.ts index c3cc931c9..77b05841e 100644 --- a/cli/src/application/use-cases/framework/strategies/marketplace-build-strategy.ts +++ b/cli/src/application/use-cases/framework/strategies/marketplace-build-strategy.ts @@ -26,6 +26,10 @@ export class MarketplaceBuildStrategy implements BuildOutputStrategy { private readonly contract: ToolBuildContract ) {} + shouldBuildPlugin(): boolean { + return true; + } + async preBuild(outDir: string): Promise { await this.fs.deleteDirectory(outDir); await this.fs.createDirectory(outDir); diff --git a/cli/src/domain/tools/build-contract.ts b/cli/src/domain/tools/build-contract.ts index c220f6047..fcdc5992e 100644 --- a/cli/src/domain/tools/build-contract.ts +++ b/cli/src/domain/tools/build-contract.ts @@ -112,6 +112,12 @@ export interface ToolBuildContract { readonly commands: ArtifactContract; }; + /** + * Plugin names structurally incompatible with this tool (e.g. Claude-Code-coupled + * plugins with no equivalent runtime). Absent or empty means every plugin is built. + */ + readonly excludedPlugins?: readonly string[]; + /** * Optional post-build step emitting a config artifact (e.g. config.toml for codex, opencode.json). * Returns count of files written. diff --git a/cli/tests/application/use-cases/framework/plugin-exclusion.integration.test.ts b/cli/tests/application/use-cases/framework/plugin-exclusion.integration.test.ts new file mode 100644 index 000000000..99e37cf72 --- /dev/null +++ b/cli/tests/application/use-cases/framework/plugin-exclusion.integration.test.ts @@ -0,0 +1,159 @@ +import { resolve } from "node:path"; +import { beforeEach, describe, expect, it } from "vitest"; +import { FrameworkBuildUseCase } from "../../../../src/application/use-cases/framework/framework-build-use-case.js"; +import { FlatBuildStrategy } from "../../../../src/application/use-cases/framework/strategies/flat-build-strategy.js"; +import { buildCodexFlatContract } from "../../../../src/application/use-cases/framework/strategies/tool-contracts.js"; +import type { AssetProvider } from "../../../../src/domain/ports/asset-provider.js"; +import type { JsonSchemaValidator } from "../../../../src/domain/ports/json-schema-validator.js"; +import { AjvSchemaValidatorAdapter } from "../../../../src/infrastructure/adapters/ajv-schema-validator-adapter.js"; +import { CapturingLogger } from "../../../helpers/ports/capturing-logger.js"; +import { InMemoryFileAdapter } from "../../../helpers/ports/in-memory-file-adapter.js"; +import { seedFromDirectory } from "../../../helpers/ports/seed-from-directory.js"; + +const FIXTURE_DIR = resolve(process.cwd(), "tests/fixtures/framework"); +const ABS_OUT = "/tmp/aidd-plugin-exclusion-test"; +const EXCLUDED_PLUGIN = "aidd-test"; +const KEPT_PLUGIN = "aidd-other"; + +const MINIMAL_SCHEMA = { type: "object" }; + +function makeValidator(): JsonSchemaValidator { + return { validate: () => {} }; +} + +function makeAssetProvider(): AssetProvider { + return { + loadConfigAsset: () => { + throw new Error("not used"); + }, + loadDefaultMarketplace: () => { + throw new Error("not used"); + }, + loadSchema: () => MINIMAL_SCHEMA, + }; +} + +function makeIsDirectory(fs: InMemoryFileAdapter): (path: string) => Promise { + return async (path: string): Promise => { + if (fs.has(path)) return false; + const prefix = path.endsWith("/") ? path : `${path}/`; + return fs.listAll().some((k) => k.startsWith(prefix)); + }; +} + +async function seedTwoPluginMarketplace(): Promise { + const memFs = new InMemoryFileAdapter(); + await seedFromDirectory(memFs, FIXTURE_DIR, { useAbsolutePaths: true }); + memFs.setFile(`${ABS_OUT}/.keep`, ""); + memFs.setFile( + `${FIXTURE_DIR}/.claude-plugin/marketplace.json`, + JSON.stringify({ + name: "aidd-framework", + owner: { name: "Test" }, + plugins: [ + { name: EXCLUDED_PLUGIN, source: `./plugins/${EXCLUDED_PLUGIN}` }, + { name: KEPT_PLUGIN, source: `./plugins/${KEPT_PLUGIN}` }, + ], + }) + ); + memFs.setFile( + `${FIXTURE_DIR}/plugins/${KEPT_PLUGIN}/.claude-plugin/plugin.json`, + JSON.stringify({ name: KEPT_PLUGIN, version: "0.1.0", description: "Kept plugin" }) + ); + memFs.setFile( + `${FIXTURE_DIR}/plugins/${KEPT_PLUGIN}/agents/helper.md`, + "---\nname: helper\ndescription: Helps.\n---\n\nBody.\n" + ); + return memFs; +} + +function makeUseCase( + memFs: InMemoryFileAdapter, + excludedPlugins?: readonly string[] +): FrameworkBuildUseCase { + const ap = makeAssetProvider(); + const av = new AjvSchemaValidatorAdapter(); + const contract = { ...buildCodexFlatContract(), excludedPlugins }; + const strategy = new FlatBuildStrategy( + memFs, + av, + ap, + contract, + false, + ABS_OUT, + makeIsDirectory(memFs), + new CapturingLogger() + ); + return new FrameworkBuildUseCase(memFs, makeValidator(), ap, new CapturingLogger(), strategy); +} + +describe("Plugin exclusion mechanism", () => { + let memFs: InMemoryFileAdapter; + + beforeEach(async () => { + memFs = await seedTwoPluginMarketplace(); + }); + + it("excludes the named plugin from the target's output while keeping the other", async () => { + const useCase = makeUseCase(memFs, [EXCLUDED_PLUGIN]); + await useCase.execute({ + sourceDir: FIXTURE_DIR, + outDir: ABS_OUT, + target: "codex", + mode: "flat", + }); + const excludedAgent = `${ABS_OUT}/.codex/agents/${EXCLUDED_PLUGIN}-code-reviewer.toml`; + const keptAgent = `${ABS_OUT}/.codex/agents/${KEPT_PLUGIN}-helper.toml`; + expect(memFs.has(excludedAgent)).toBe(false); + expect(memFs.has(keptAgent)).toBe(true); + }); + + it("builds every plugin, including the one another target excludes, when no exclusion is set", async () => { + const useCase = makeUseCase(memFs); + await useCase.execute({ + sourceDir: FIXTURE_DIR, + outDir: ABS_OUT, + target: "codex", + mode: "flat", + }); + const excludedAgent = `${ABS_OUT}/.codex/agents/${EXCLUDED_PLUGIN}-code-reviewer.toml`; + const keptAgent = `${ABS_OUT}/.codex/agents/${KEPT_PLUGIN}-helper.toml`; + expect(memFs.has(excludedAgent)).toBe(true); + expect(memFs.has(keptAgent)).toBe(true); + }); + + it("reports the skip on stderr rather than silently dropping the plugin", async () => { + const logger = new CapturingLogger(); + const ap = makeAssetProvider(); + const contract = { ...buildCodexFlatContract(), excludedPlugins: [EXCLUDED_PLUGIN] }; + const strategy = new FlatBuildStrategy( + memFs, + new AjvSchemaValidatorAdapter(), + ap, + contract, + false, + ABS_OUT, + makeIsDirectory(memFs), + logger + ); + const useCase = new FrameworkBuildUseCase(memFs, makeValidator(), ap, logger, strategy); + await useCase.execute({ + sourceDir: FIXTURE_DIR, + outDir: ABS_OUT, + target: "codex", + mode: "flat", + }); + expect(logger.warnMessages.some((m) => m.includes(EXCLUDED_PLUGIN))).toBe(true); + }); + + it("the excluded plugin never appears in the returned plugin results", async () => { + const useCase = makeUseCase(memFs, [EXCLUDED_PLUGIN]); + const result = await useCase.execute({ + sourceDir: FIXTURE_DIR, + outDir: ABS_OUT, + target: "codex", + mode: "flat", + }); + expect(result.plugins.map((p) => p.name)).toEqual([KEPT_PLUGIN]); + }); +}); From 119340f8faaf5d65df96e1f3fb4c964eefee6340 Mon Sep 17 00:00:00 2001 From: Thierry Kwiatkowski Date: Wed, 29 Jul 2026 16:57:06 +0200 Subject: [PATCH 04/23] feat(cli): declare gemini's flat build contract buildGeminiFlatContract() declares all six artifact kinds: skills reuse codex's exact .agents/skills/ tree (extracted AGENTS_SKILLS_PREFIX into flat-paths.ts as a shared constant so this is byte-identical by construction, not convention), agents are rebuilt to name+description only (Gemini's strict Zod schema rejects unknown frontmatter keys), mcp reuses mergeVscodeMcp against the mcpServers key (no shape transform needed), hooks reuse Phase 2's event-translating merge, and the settings seed lands via emitConfigArtifact. excludedPlugins applies Phase 3's mechanism to drop aidd-orchestrator (Claude-Code- coupled, no Gemini equivalent). No gemini:marketplace row exists. Wired into deps.ts (gemini:flat registry row), the framework/ai commands' target lists and help text, the interactive menu hint, and the CI build matrix (10 cells). biome.json and package.json updated for the new tool directory/keyword. Phase 4 of part-1 (feat/511-gemini-flat-build-target). The tool definition itself landed in Phase 1; see that phase's Amendments. Co-Authored-By: Claude Sonnet 5 --- .github/workflows/ci.yml | 5 +- .../2026_07_27-511-gemini-cli-tool-part-1.md | 15 +- cli/biome.json | 1 + cli/package.json | 1 + cli/src/application/commands/ai.ts | 2 +- cli/src/application/commands/framework.ts | 5 +- .../framework/strategies/tool-contracts.ts | 111 ++++++++++++++- .../application/use-cases/menu-use-case.ts | 2 +- cli/src/domain/formats/flat-paths.ts | 8 ++ cli/src/infrastructure/deps.ts | 16 +++ .../flat-build-strategy.integration.test.ts | 123 +++++++++++++++++ ...emini-plugin-exclusion.integration.test.ts | 128 ++++++++++++++++++ cli/tests/e2e/framework-build.e2e.test.ts | 56 ++++++++ 13 files changed, 461 insertions(+), 12 deletions(-) create mode 100644 cli/tests/application/use-cases/framework/gemini-plugin-exclusion.integration.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cd90271c3..9ae91c804 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -145,8 +145,8 @@ jobs: runs-on: ubuntu-latest strategy: fail-fast: false - # 9-cell matrix: 4 marketplace (claude/cursor/copilot/codex) + 5 flat - # (+opencode, which is flat-only). Mirrors the CLI golden snapshot matrix. + # 10-cell matrix: 4 marketplace (claude/cursor/copilot/codex) + 6 flat + # (+opencode, +gemini, both flat-only). Mirrors the CLI golden snapshot matrix. matrix: include: - { tool: claude, mode: marketplace, flag: "" } @@ -158,6 +158,7 @@ jobs: - { tool: copilot, mode: flat, flag: "--flat" } - { tool: codex, mode: flat, flag: "--flat" } - { tool: opencode, mode: flat, flag: "--flat" } + - { tool: gemini, mode: flat, flag: "--flat" } steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 diff --git a/aidd_docs/tasks/2026_07/2026_07_27_gemini-cli-build-target/2026_07_27-511-gemini-cli-tool-part-1.md b/aidd_docs/tasks/2026_07/2026_07_27_gemini-cli-build-target/2026_07_27-511-gemini-cli-tool-part-1.md index 30a6aebe1..bf80aca7c 100644 --- a/aidd_docs/tasks/2026_07/2026_07_27_gemini-cli-build-target/2026_07_27-511-gemini-cli-tool-part-1.md +++ b/aidd_docs/tasks/2026_07/2026_07_27_gemini-cli-build-target/2026_07_27-511-gemini-cli-tool-part-1.md @@ -211,10 +211,10 @@ flowchart TD #### Acceptance criteria -- [ ] All six artifact kinds are declared; none is omitted -- [ ] No `gemini:marketplace` row exists, and `--target gemini` without `--flat` exits 1 -- [ ] Skills, agents, MCP and hooks all land at the mapped paths in a real build -- [ ] `.gemini/settings.json` contains `mcpServers`, `hooks` and `context.fileName` simultaneously +- [x] All six artifact kinds are declared; none is omitted +- [x] No `gemini:marketplace` row exists, and `--target gemini` without `--flat` exits 1 +- [x] Skills, agents, MCP and hooks all land at the mapped paths in a real build +- [x] `.gemini/settings.json` contains `mcpServers`, `hooks` and `context.fileName` simultaneously ### Phase 5: Prove it against the real binary @@ -242,6 +242,12 @@ flowchart TD πŸ€– Phase 1/Phase 4 boundary was unsound as originally scoped: adding `"gemini"` to `AI_TOOL_IDS` (Phase 1) without also calling `registerTool(gemini)` (originally Phase 4 task 1) broke every existing test that spreads `AI_TOOL_IDS` as "install all tools" (`tests/application/use-cases/setup-use-case.unit.test.ts`, 3 failures β€” `UnregisteredToolError: Tool 'gemini' is not registered.`), because `AI_TOOL_IDS` is the real runtime source of truth for "which AI tools does `all` install," not just a type-level list. This would have shipped a crash in the real CLI (`aidd setup --tools all`) had Phase 1 landed alone. Flagged to the user; resolved by pulling the tool-definition write (`domain/tools/ai/gemini.ts`) into Phase 1, atomic with the id registration. Phase 4 shrinks accordingly (its task 1 is struck). +πŸ€– Extracted `AGENTS_SKILLS_PREFIX` (`.agents/skills/`) from tool-contracts.ts's module-private `CODEX_SKILLS_PREFIX` into `domain/formats/flat-paths.ts` as a shared, exported constant, and pointed gemini's flat skill path at the same constant β€” the actual "extract a shared helper" the plan's Phase 4 task 3 asked for, satisfying the byte-identical-subset requirement by construction (same prefix, same `genericFlatSkillPath` primitive, same `rewriteSkillName: true`) rather than by convention alone. + +πŸ€– Gemini's flat agent transform rebuilds frontmatter from scratch as `{ name, description }` only (dropping everything else, including `tools`), because Gemini's real agent schema (confirmed against the shipped `gemini-cli` 0.52.0 bundle) is a Zod `.strict()` schema β€” any surviving unknown key throws `AgentLoadError`. This mirrors the codebase's own established pattern (claude/cursor/opencode's flat-agent transforms already rebuild frontmatter rather than pass it through) rather than inventing a new one, and avoids the unresolved question of mapping AIDD/Claude tool names (`Edit`, `Bash`, ...) to Gemini's own tool identifiers (`replace`, `run_shell_command`, ...) β€” real, but out of this part's scope; Phase 5's real-binary check is what actually proves no agent gets rejected. + +πŸ€– `gemini-plugin-exclusion.integration.test.ts` (real contract) and the e2e gemini test both had to work around `tests/fixtures/framework`'s single-plugin marketplace: the shared fixture carries no `aidd-orchestrator` entry (adding one would perturb every other target's golden output, since all nine existing build-target tests iterate the same fixture marketplace). The integration test overlays a synthetic `aidd-orchestrator` plugin directly into the in-memory fs (same pattern as Phase 3's generic test); the e2e test drops the orchestrator-exclusion assertion entirely and only proves the real CLI invocation succeeds with the expected `.gemini/settings.json` shape, deferring to the integration test for the exclusion proof itself. + πŸ€– Phase 3's integration test is generic (`plugin-exclusion.integration.test.ts`, spreading `{ ...buildCodexFlatContract(), excludedPlugins: [...] }`), not gemini-specific, because gemini's own flat contract doesn't exist yet at this point in the phase order β€” it lands in Phase 4. Phase 4 gets its own task to add the plan's originally-named `gemini-plugin-exclusion.integration.test.ts` against the real contract excluding `aidd-orchestrator`. πŸ€– gemini's `AiTool` capability intersection is `HasAgents & HasSkills & HasMcp & HasPlugins` β€” narrower than codex/opencode. Deliberately omitted: `hooks` (Claudeβ†’Gemini event-name translation has no expression point in the current `HooksCapability`/generic install pipeline β€” content passes through untransformed) and `settings` (the idempotent `context.fileName` array union needs custom merge logic; `SettingsCapability` only supports generic `MergeStrategy` enums or static content, not a custom merge function). `plugins` is `{ mode: "unsupported" }` (no marketplace, no native activation, per the master plan). None of this blocks this part's objective β€” `aidd framework build` never reads `AiTool.capabilities` (`FlatBuildStrategy`/`ToolBuildContract` are fully standalone) β€” so the gap is real install-mode functionality deferred to Part 3, not a stub masking Phase 1/4 work. @@ -253,6 +259,7 @@ flowchart TD - Phase 1: `AiToolId`/`AI_TOOL_IDS`/`FrameworkBuildTarget` extended; `CONFIG_ASSETS["gemini"]` + `assets/configs/gemini/settings.json` seed (`context.fileName: ["AGENTS.md"]`) added; `domain/tools/ai/gemini.ts` written and registered (see Amendments for scope); side-effect imports added to `deps.ts` + both test helpers; two exact-array assertions and `isAiToolId` test fixed; new `gemini` block added to `asset-loader.unit.test.ts`. Verified: `pnpm typecheck` (0 errors), `pnpm test:unit` (1413/1413), `pnpm test:e2e` golden framework-build suite (9-cell matrix byte-identical, claude cell frozen), `biome check` (clean after one formatting auto-fix). Two pre-existing, environment-coupled e2e failures observed and confirmed unrelated (`auth status` / `self-update --check`, depend on local `gh auth login` state, no gemini involvement). Committed 8540e4e9. - Phase 2: `domain/formats/gemini-settings-merge.ts` written β€” `GEMINI_HOOK_EVENT_MAP` (verified against the shipped `@google/gemini-cli@0.52.0` bundle's `EVENT_MAPPING` in `gemini-6K6USV55.js`'s hooks-migrate command, and its settings/hooks JSON-schema in `chunk-SZMWXEEI.js`), `mergeGeminiSettingsHooks` (event-translated additive hooks merge, preserves other keys, warns+drops unmapped events), `mergeGeminiSettingsSeed` (idempotent `context.fileName` array union, string-or-array normalization, preserves all other keys including ones written by prior mcp/hooks merges). 14 new unit tests in `gemini-settings-merge.unit.test.ts`. Verified: `pnpm typecheck` (0 errors), full `pnpm test:unit` (1427/1427, up from 1413), `biome check` (clean after one formatting auto-fix). - Phase 3: `excludedPlugins?: readonly string[]` added to `ToolBuildContract`; `shouldBuildPlugin(pluginName)` added to `BuildOutputStrategy` (always `true` in `MarketplaceBuildStrategy`, contract-driven in `FlatBuildStrategy`); `FrameworkBuildUseCase`'s plugin loop extracted into `buildAllPlugins`, skipping rejected plugins with one `logger.warn` each and excluding them from the returned `plugins`/marketplace-catalog entries. New `tests/application/use-cases/framework/plugin-exclusion.integration.test.ts` (4 tests, generic mechanism β€” see Amendments for the naming/scope note). Verified: `pnpm typecheck` (0 errors), `pnpm test:unit` (1427/1427), `pnpm test:integration` (505/505, up from 501), grep gate for `if (tool === ` / `if (kind === "agents")` in both orchestrators returns nothing, `biome check` (clean after one formatting auto-fix). +- Phase 4: `buildGeminiFlatContract()` added to `tool-contracts.ts` β€” all six artifact kinds declared (skills/agents/mcp/hooks via shared helpers and the new merge module, rules/commands `{supported:false}`), `excludedPlugins: ["aidd-orchestrator"]` applying Phase 3's mechanism for real. `AGENTS_SKILLS_PREFIX` extracted from tool-contracts.ts into `flat-paths.ts` (shared with codex, byte-identical subset by construction). Wired into `deps.ts` (`gemini:flat` registry row), `commands/framework.ts` (`SUPPORTED_TARGETS` + help text), `commands/ai.ts` (description), `menu-use-case.ts` (interactive hint), `biome.json` (`!.gemini`), `package.json` (keyword), `.github/workflows/ci.yml` (10-cell matrix row). New tests: 6 gemini cases in `flat-build-strategy.integration.test.ts` (skills/agents paths, strict-schema frontmatter rebuild, settings.json triple-key coexistence, event translation, hooks-then-mcp non-clobbering, force re-run), `gemini-plugin-exclusion.integration.test.ts` (2 tests, real contract against a synthetic orchestrator overlay), 2 e2e cases in `framework-build.e2e.test.ts` (flat success + shape, non-flat exits 1). Verified: `pnpm typecheck` (0 errors), `pnpm test:unit` (1427/1427), `pnpm test:integration` (514/514), `pnpm test:e2e` (128/130 β€” same 2 pre-existing auth-state failures, golden 9-cell matrix still byte-identical), grep gate clean, `biome check` (clean after one formatting auto-fix). ## Validation flow demonstration diff --git a/cli/biome.json b/cli/biome.json index 6174733d6..348b5ed93 100644 --- a/cli/biome.json +++ b/cli/biome.json @@ -32,6 +32,7 @@ "!.claude", "!.cursor", "!.opencode", + "!.gemini", "!.specstory", "!.vscode", "!setup-test", diff --git a/cli/package.json b/cli/package.json index 0a4240e76..b22fdc15b 100644 --- a/cli/package.json +++ b/cli/package.json @@ -11,6 +11,7 @@ "github-copilot", "codex", "opencode", + "gemini", "developer-tools", "ai-coding-assistant" ], diff --git a/cli/src/application/commands/ai.ts b/cli/src/application/commands/ai.ts index bc29bb513..0485fa47a 100644 --- a/cli/src/application/commands/ai.ts +++ b/cli/src/application/commands/ai.ts @@ -19,7 +19,7 @@ function assertAiToolId(toolId: string): asserts toolId is AiToolId { export function registerAiCommand(program: Command): void { const ai = program .command("ai") - .description("Manage AI tools (claude, cursor, copilot, codex, opencode)"); + .description("Manage AI tools (claude, cursor, copilot, codex, opencode, gemini)"); ai.action(async () => { if (!process.stdout.isTTY) { diff --git a/cli/src/application/commands/framework.ts b/cli/src/application/commands/framework.ts index 9c0eba7a0..62fffe0e4 100644 --- a/cli/src/application/commands/framework.ts +++ b/cli/src/application/commands/framework.ts @@ -20,7 +20,10 @@ export function registerFrameworkCommand(program: Command): void { "Build a Claude-format framework into a target-native plugin marketplace tree or project workspace" ) .requiredOption("--source ", "Path to the source framework directory") - .requiredOption("--target ", "Build target (claude, cursor, copilot, codex, opencode)") + .requiredOption( + "--target ", + "Build target (claude, cursor, copilot, codex, opencode, gemini)" + ) .requiredOption("--out ", "Output directory (marketplace dist or project root)") .option("--flat", "Materialize directly into project workspace, bypass marketplace") .option("--force", "Overwrite existing files at canonical paths (flat mode only)") diff --git a/cli/src/application/use-cases/framework/strategies/tool-contracts.ts b/cli/src/application/use-cases/framework/strategies/tool-contracts.ts index 3f1f09e84..c2b1d7af9 100644 --- a/cli/src/application/use-cases/framework/strategies/tool-contracts.ts +++ b/cli/src/application/use-cases/framework/strategies/tool-contracts.ts @@ -35,12 +35,17 @@ import { mergeCursorFlatHooks, } from "../../../../domain/formats/flat-hooks-merge.js"; import { + AGENTS_SKILLS_PREFIX, flatMcpKeyPrefix, genericFlatAgentPath, genericFlatHooksFile, genericFlatHooksScriptPath, genericFlatSkillPath, } from "../../../../domain/formats/flat-paths.js"; +import { + mergeGeminiSettingsHooks, + mergeGeminiSettingsSeed, +} from "../../../../domain/formats/gemini-settings-merge.js"; import { parseFrontmatter, serializeFrontmatter } from "../../../../domain/formats/markdown.js"; import { buildOpencodeFlatConfig } from "../../../../domain/formats/opencode-mcp-merge.js"; import { rewriteRelativeLinks } from "../../../../domain/formats/relative-link-rewrite.js"; @@ -626,11 +631,11 @@ export function buildCursorFlatContract(): ToolBuildContract { // Codex scans `.agents/skills/` (cwd β†’ repo root) for workspace skills β€” the documented // project skill root (developers.openai.com/codex/skills). Verified live on codex-cli 0.136: // a SKILL.md there appears in Codex's "Available skills" context. (`.codex/skills/` also -// resolves on 0.136 but is undocumented, so we target the documented root.) -const CODEX_SKILLS_PREFIX = ".agents/skills/"; +// resolves on 0.136 but is undocumented, so we target the documented root.) Gemini CLI's +// `.agents/skills/` alias resolves to the same tree β€” see AGENTS_SKILLS_PREFIX. function codexFlatSkillPath(plugin: string, rel: string): string { - return genericFlatSkillPath(CODEX_SKILLS_PREFIX, plugin, rel.replace(/^skills\//, "")); + return genericFlatSkillPath(AGENTS_SKILLS_PREFIX, plugin, rel.replace(/^skills\//, "")); } function codexFlatAgentPath(plugin: string, rel: string): string { @@ -809,3 +814,103 @@ export function buildOpencodeFlatContract(): ToolBuildContract { }, }; } + +// ── Gemini flat contract ─────────────────────────────────────────────────────── + +// aidd-orchestrator is structurally Claude-Code-coupled (enabledPlugins in +// .claude/settings.json, the Claude Code GitHub Action) with no Gemini equivalent. +const GEMINI_EXCLUDED_PLUGINS: readonly string[] = ["aidd-orchestrator"]; + +function geminiFlatSkillPath(plugin: string, rel: string): string { + return genericFlatSkillPath(AGENTS_SKILLS_PREFIX, plugin, rel.replace(/^skills\//, "")); +} + +function geminiFlatAgentPath(plugin: string, rel: string): string { + return genericFlatAgentPath(".gemini/agents/", plugin, rel.replace(/^agents\//, ""), ".md"); +} + +function geminiFlatHooksPath(plugin: string, rel: string): string { + return genericFlatHooksScriptPath(".gemini/hooks/", plugin, rel.replace(/^hooks\//, "")); +} + +function geminiFlatResolveTarget(plugin: string, rel: string): string { + if (rel.startsWith("agents/")) return geminiFlatAgentPath(plugin, rel); + if (rel.startsWith("skills/")) return geminiFlatSkillPath(plugin, rel); + return rel; +} + +// Strict Gemini agent frontmatter schema (Zod .strict()) rejects unknown keys, so the +// agent frontmatter is rebuilt from only its required fields rather than passed through. +function transformGeminiFlatAgent(content: string, plugin: string, outName: string): string { + const { frontmatter, body } = parseFrontmatter(content); + const flatRelPath = geminiFlatAgentPath(plugin, `agents/${outName}`); + const rewrittenBody = rewriteRelativeLinks(body, { + currentFilePluginRelative: flatRelPath, + resolveTargetPath: (rel) => geminiFlatResolveTarget(plugin, rel), + }); + const prefixedName = `${plugin}-${outName.replace(/\.md$/, "")}`; + return serializeFrontmatter( + { name: prefixedName, description: frontmatter.description }, + rewrittenBody + ); +} + +export function buildGeminiFlatContract(): ToolBuildContract { + return { + manifestDir: null, + marketplaceRelative: null, + manifestFileRelative: null, + synthesizeManifest: null, + manifestSchemaName: null, + excludedPlugins: GEMINI_EXCLUDED_PLUGINS, + artifacts: { + skills: { + supported: true, + source: { kind: "fullTree", srcDir: "skills" }, + path: geminiFlatSkillPath, + rewriteSkillName: true, + }, + agents: { + supported: true, + source: { kind: "filteredTree", srcDir: "agents", inputExt: ".md" }, + path: geminiFlatAgentPath, + transform: transformGeminiFlatAgent, + }, + mcp: { + supported: true, + source: { kind: "configFile", srcPath: ".mcp.json" }, + path: () => ".gemini/settings.json", + merge: (existing, incoming, force) => + mergeVscodeMcp(existing, incoming, force, "mcpServers"), + mcpServersKey: "mcpServers", + mergeDest: (outDir) => `${outDir}/.gemini/settings.json`, + }, + hooks: { + supported: true, + source: { kind: "hooksBundle", jsonPath: "hooks/hooks.json", scriptDir: "hooks" }, + path: geminiFlatHooksPath, + hooksMerge: (existing, incoming) => mergeGeminiSettingsHooks(existing, incoming), + hooksMergeDest: (outDir) => `${outDir}/.gemini/settings.json`, + }, + rules: { supported: false }, + commands: { supported: false }, + }, + buildMarketplaceCatalog: null, + buildMarketplaceEntry: null, + emitConfigArtifact: async ( + _builtPlugins, + outDir, + _sourceDir, + fs, + _validator, + assetProvider + ) => { + const configPath = `${outDir}/.gemini/settings.json`; + const existing = (await fs.fileExists(configPath)) ? await fs.readFile(configPath) : ""; + const seedAsset = assetProvider.loadConfigAsset("gemini", "settings.json"); + const seed = typeof seedAsset === "string" ? seedAsset : JSON.stringify(seedAsset); + await fs.writeFile(configPath, mergeGeminiSettingsSeed(existing, seed)); + return 1; + }, + }; +} diff --git a/cli/src/application/use-cases/menu-use-case.ts b/cli/src/application/use-cases/menu-use-case.ts index 743e711ee..1c8436db8 100644 --- a/cli/src/application/use-cases/menu-use-case.ts +++ b/cli/src/application/use-cases/menu-use-case.ts @@ -82,7 +82,7 @@ const INSTALLED_NODES: MenuNode[] = [ value: "ai-install", description: "Add an AI tool to this project", command: ["ai", "install"], - inputPrompt: "AI tool (e.g. claude, cursor, copilot, codex)", + inputPrompt: "AI tool (e.g. claude, cursor, copilot, codex, gemini)", }, { name: "Uninstall", diff --git a/cli/src/domain/formats/flat-paths.ts b/cli/src/domain/formats/flat-paths.ts index f4dafb731..46164a6f2 100644 --- a/cli/src/domain/formats/flat-paths.ts +++ b/cli/src/domain/formats/flat-paths.ts @@ -6,6 +6,14 @@ * logic with different prefixes/extensions. */ +/** + * Shared workspace-skills root, per the documented Codex convention + * (developers.openai.com/codex/skills) that Gemini CLI's `.agents/skills/` alias + * also resolves to. Both tools reuse this exact prefix so their skills output is a + * byte-identical subset of one shared tree, rather than two divergent renderings. + */ +export const AGENTS_SKILLS_PREFIX = ".agents/skills/"; + /** * Returns the flat-output path for an agent file. * The source `.md` extension is stripped and replaced with `outputExt`. diff --git a/cli/src/infrastructure/deps.ts b/cli/src/infrastructure/deps.ts index 9603c9d66..505e23674 100644 --- a/cli/src/infrastructure/deps.ts +++ b/cli/src/infrastructure/deps.ts @@ -29,6 +29,7 @@ import { buildCopilotMarketplaceContract, buildCursorContract, buildCursorFlatContract, + buildGeminiFlatContract, buildOpencodeFlatContract, } from "../application/use-cases/framework/strategies/tool-contracts.js"; import { DoctorAllUseCase } from "../application/use-cases/global/doctor-all-use-case.js"; @@ -337,6 +338,21 @@ const FRAMEWORK_BUILD_REGISTRY: Record = { d.logger ) ), + "gemini:flat": (deps, ctx) => + buildFrameworkUseCase( + deps, + (d, av) => + new FlatBuildStrategy( + d.fs, + av, + d.assetProvider, + buildGeminiFlatContract(), + ctx.force, + ctx.outDir, + isDirectory, + d.logger + ) + ), }; export function createFrameworkBuildUseCase( diff --git a/cli/tests/application/use-cases/framework/flat-build-strategy.integration.test.ts b/cli/tests/application/use-cases/framework/flat-build-strategy.integration.test.ts index f5e3e5c55..fd2e24c5b 100644 --- a/cli/tests/application/use-cases/framework/flat-build-strategy.integration.test.ts +++ b/cli/tests/application/use-cases/framework/flat-build-strategy.integration.test.ts @@ -4,6 +4,7 @@ import { FrameworkBuildUseCase } from "../../../../src/application/use-cases/fra import { FlatBuildStrategy } from "../../../../src/application/use-cases/framework/strategies/flat-build-strategy.js"; import { buildCopilotFlatContract, + buildGeminiFlatContract, buildOpencodeFlatContract, } from "../../../../src/application/use-cases/framework/strategies/tool-contracts.js"; import { @@ -11,6 +12,7 @@ import { JsonSchemaValidationError, OutDirNotDirectoryError, } from "../../../../src/domain/errors.js"; +import { parseFrontmatter } from "../../../../src/domain/formats/markdown.js"; import type { AssetProvider } from "../../../../src/domain/ports/asset-provider.js"; import type { JsonSchemaValidator } from "../../../../src/domain/ports/json-schema-validator.js"; import { AjvSchemaValidatorAdapter } from "../../../../src/infrastructure/adapters/ajv-schema-validator-adapter.js"; @@ -58,6 +60,9 @@ function makeAssetProvider(): AssetProvider { instructions: [".opencode/rules/**/*.md"], }; } + if (fileName === "settings.json") { + return { context: { fileName: ["AGENTS.md"] } }; + } throw new Error("not used"); }, loadDefaultMarketplace: () => { @@ -398,4 +403,122 @@ describe("FlatOutputStrategy integration", () => { expect(hooksFiles).toHaveLength(0); }); }); + + describe("gemini flat contract", () => { + function makeGeminiUseCase(fs: InMemoryFileAdapter, force = false): FrameworkBuildUseCase { + const ap = makeAssetProvider(); + const strategy = new FlatBuildStrategy( + fs, + new AjvSchemaValidatorAdapter(), + ap, + buildGeminiFlatContract(), + force, + ABS_OUT, + makeIsDirectory(fs) + ); + return new FrameworkBuildUseCase(fs, makeValidator(), ap, new CapturingLogger(), strategy); + } + + it("writes skills under .agents/skills/ and agents under .gemini/agents/", async () => { + await makeGeminiUseCase(memFs).execute({ + sourceDir: FIXTURE_DIR, + outDir: ABS_OUT, + target: "gemini", + mode: "flat", + }); + expect(memFs.has(`${ABS_OUT}/.agents/skills/${PLUGIN}-commit/SKILL.md`)).toBe(true); + expect(memFs.has(`${ABS_OUT}/.gemini/agents/${PLUGIN}-code-reviewer.md`)).toBe(true); + }); + + it("rebuilds agent frontmatter to only name and description (strict Gemini schema)", async () => { + await makeGeminiUseCase(memFs).execute({ + sourceDir: FIXTURE_DIR, + outDir: ABS_OUT, + target: "gemini", + mode: "flat", + }); + const content = memFs.getFile(`${ABS_OUT}/.gemini/agents/${PLUGIN}-code-reviewer.md`) ?? ""; + const { frontmatter } = parseFrontmatter(content); + expect(frontmatter).toEqual({ + name: `${PLUGIN}-code-reviewer`, + description: "Reviews code for quality and correctness.", + }); + }); + + it("emits .gemini/settings.json with mcpServers, hooks and context.fileName simultaneously", async () => { + await makeGeminiUseCase(memFs).execute({ + sourceDir: FIXTURE_DIR, + outDir: ABS_OUT, + target: "gemini", + mode: "flat", + }); + const raw = memFs.getFile(`${ABS_OUT}/.gemini/settings.json`) ?? "{}"; + const settings = JSON.parse(raw) as { + mcpServers: Record; + hooks: Record; + context: { fileName: string[] }; + }; + expect(settings.mcpServers).toHaveProperty(`${PLUGIN}-aidd-test-server`); + expect(settings.hooks.BeforeTool).toBeDefined(); + expect(settings.context.fileName).toEqual(["AGENTS.md"]); + }); + + it("translates PreToolUse to BeforeTool (Claude β†’ Gemini event mapping)", async () => { + await makeGeminiUseCase(memFs).execute({ + sourceDir: FIXTURE_DIR, + outDir: ABS_OUT, + target: "gemini", + mode: "flat", + }); + const raw = memFs.getFile(`${ABS_OUT}/.gemini/settings.json`) ?? "{}"; + const settings = JSON.parse(raw) as { hooks: Record }; + expect(settings.hooks.PreToolUse).toBeUndefined(); + expect(settings.hooks.BeforeTool).toBeDefined(); + }); + + it("writes hooks before mcp, and neither clobbers the other's key in the shared settings file", async () => { + await makeGeminiUseCase(memFs).execute({ + sourceDir: FIXTURE_DIR, + outDir: ABS_OUT, + target: "gemini", + mode: "flat", + }); + // Both writers target .gemini/settings.json; the hooks-then-mcp write order + // (framework-build-use-case's plugin loop) must not clobber the other's key. + const raw = memFs.getFile(`${ABS_OUT}/.gemini/settings.json`) ?? "{}"; + const settings = JSON.parse(raw) as Record; + expect(settings).toHaveProperty("hooks"); + expect(settings).toHaveProperty("mcpServers"); + }); + + it("does NOT write a marketplace.json (flat mode, no marketplace support)", async () => { + await makeGeminiUseCase(memFs).execute({ + sourceDir: FIXTURE_DIR, + outDir: ABS_OUT, + target: "gemini", + mode: "flat", + }); + expect(memFs.has(`${ABS_OUT}/.gemini/marketplace.json`)).toBe(false); + }); + + it("re-run with --force reproduces the same agent file (collision-checked artifacts are byte-identical)", async () => { + // .gemini/settings.json is a merge target, not collision-checked β€” like claude/cursor/codex's + // own hooksMerge targets, a repeated merge is additive rather than idempotent by design. + // Agents and skills go through checkCollision + overwrite instead, so those ARE byte-identical. + await makeGeminiUseCase(memFs, false).execute({ + sourceDir: FIXTURE_DIR, + outDir: ABS_OUT, + target: "gemini", + mode: "flat", + }); + const snapshot = memFs.getFile(`${ABS_OUT}/.gemini/agents/${PLUGIN}-code-reviewer.md`); + await makeGeminiUseCase(memFs, true).execute({ + sourceDir: FIXTURE_DIR, + outDir: ABS_OUT, + target: "gemini", + mode: "flat", + }); + expect(memFs.getFile(`${ABS_OUT}/.gemini/agents/${PLUGIN}-code-reviewer.md`)).toBe(snapshot); + }); + }); }); diff --git a/cli/tests/application/use-cases/framework/gemini-plugin-exclusion.integration.test.ts b/cli/tests/application/use-cases/framework/gemini-plugin-exclusion.integration.test.ts new file mode 100644 index 000000000..7c8dd0a59 --- /dev/null +++ b/cli/tests/application/use-cases/framework/gemini-plugin-exclusion.integration.test.ts @@ -0,0 +1,128 @@ +import { resolve } from "node:path"; +import { beforeEach, describe, expect, it } from "vitest"; +import { FrameworkBuildUseCase } from "../../../../src/application/use-cases/framework/framework-build-use-case.js"; +import { FlatBuildStrategy } from "../../../../src/application/use-cases/framework/strategies/flat-build-strategy.js"; +import { buildGeminiFlatContract } from "../../../../src/application/use-cases/framework/strategies/tool-contracts.js"; +import type { AssetProvider } from "../../../../src/domain/ports/asset-provider.js"; +import type { JsonSchemaValidator } from "../../../../src/domain/ports/json-schema-validator.js"; +import { AjvSchemaValidatorAdapter } from "../../../../src/infrastructure/adapters/ajv-schema-validator-adapter.js"; +import { CapturingLogger } from "../../../helpers/ports/capturing-logger.js"; +import { InMemoryFileAdapter } from "../../../helpers/ports/in-memory-file-adapter.js"; +import { seedFromDirectory } from "../../../helpers/ports/seed-from-directory.js"; + +const FIXTURE_DIR = resolve(process.cwd(), "tests/fixtures/framework"); +const ABS_OUT = "/tmp/aidd-gemini-plugin-exclusion-test"; +const EXISTING_PLUGIN = "aidd-test"; +const ORCHESTRATOR_PLUGIN = "aidd-orchestrator"; + +function makeValidator(): JsonSchemaValidator { + return { validate: () => {} }; +} + +function makeAssetProvider(): AssetProvider { + return { + loadConfigAsset: (_toolId, fileName) => { + if (fileName === "settings.json") return { context: { fileName: ["AGENTS.md"] } }; + throw new Error("not used"); + }, + loadDefaultMarketplace: () => { + throw new Error("not used"); + }, + loadSchema: () => ({ type: "object" }), + }; +} + +function makeIsDirectory(fs: InMemoryFileAdapter): (path: string) => Promise { + return async (path: string): Promise => { + if (fs.has(path)) return false; + const prefix = path.endsWith("/") ? path : `${path}/`; + return fs.listAll().some((k) => k.startsWith(prefix)); + }; +} + +async function seedWithOrchestratorPlugin(): Promise { + const memFs = new InMemoryFileAdapter(); + await seedFromDirectory(memFs, FIXTURE_DIR, { useAbsolutePaths: true }); + memFs.setFile(`${ABS_OUT}/.keep`, ""); + memFs.setFile( + `${FIXTURE_DIR}/.claude-plugin/marketplace.json`, + JSON.stringify({ + name: "aidd-framework", + owner: { name: "Test" }, + plugins: [ + { name: EXISTING_PLUGIN, source: `./plugins/${EXISTING_PLUGIN}` }, + { name: ORCHESTRATOR_PLUGIN, source: `./plugins/${ORCHESTRATOR_PLUGIN}` }, + ], + }) + ); + memFs.setFile( + `${FIXTURE_DIR}/plugins/${ORCHESTRATOR_PLUGIN}/.claude-plugin/plugin.json`, + JSON.stringify({ name: ORCHESTRATOR_PLUGIN, version: "0.1.0", description: "Orchestrator" }) + ); + memFs.setFile( + `${FIXTURE_DIR}/plugins/${ORCHESTRATOR_PLUGIN}/agents/dispatcher.md`, + "---\nname: dispatcher\ndescription: Dispatches work.\n---\n\nBody.\n" + ); + return memFs; +} + +describe("Gemini's real flat contract excludes aidd-orchestrator", () => { + let memFs: InMemoryFileAdapter; + + beforeEach(async () => { + memFs = await seedWithOrchestratorPlugin(); + }); + + it("builds aidd-test but not aidd-orchestrator", async () => { + const ap = makeAssetProvider(); + const strategy = new FlatBuildStrategy( + memFs, + new AjvSchemaValidatorAdapter(), + ap, + buildGeminiFlatContract(), + false, + ABS_OUT, + makeIsDirectory(memFs) + ); + const useCase = new FrameworkBuildUseCase( + memFs, + makeValidator(), + ap, + new CapturingLogger(), + strategy + ); + const result = await useCase.execute({ + sourceDir: FIXTURE_DIR, + outDir: ABS_OUT, + target: "gemini", + mode: "flat", + }); + + expect(result.plugins.map((p) => p.name)).toEqual([EXISTING_PLUGIN]); + expect(memFs.has(`${ABS_OUT}/.gemini/agents/${ORCHESTRATOR_PLUGIN}-dispatcher.md`)).toBe(false); + expect(memFs.has(`${ABS_OUT}/.gemini/agents/${EXISTING_PLUGIN}-code-reviewer.md`)).toBe(true); + }); + + it("reports the exclusion on stderr", async () => { + const ap = makeAssetProvider(); + const logger = new CapturingLogger(); + const strategy = new FlatBuildStrategy( + memFs, + new AjvSchemaValidatorAdapter(), + ap, + buildGeminiFlatContract(), + false, + ABS_OUT, + makeIsDirectory(memFs), + logger + ); + const useCase = new FrameworkBuildUseCase(memFs, makeValidator(), ap, logger, strategy); + await useCase.execute({ + sourceDir: FIXTURE_DIR, + outDir: ABS_OUT, + target: "gemini", + mode: "flat", + }); + expect(logger.warnMessages.some((m) => m.includes(ORCHESTRATOR_PLUGIN))).toBe(true); + }); +}); diff --git a/cli/tests/e2e/framework-build.e2e.test.ts b/cli/tests/e2e/framework-build.e2e.test.ts index 3edaf24be..0f330b6b7 100644 --- a/cli/tests/e2e/framework-build.e2e.test.ts +++ b/cli/tests/e2e/framework-build.e2e.test.ts @@ -710,4 +710,60 @@ describe.concurrent("E2E: aidd framework build", () => { await cleanup(); } }); + + it("AC #6: --target gemini --flat emits .agents/skills, .gemini/agents, .gemini/settings.json with mcpServers/hooks/context.fileName", async () => { + const { tempDir, projectDir, fakeHome, cleanup } = await createTestEnv("fw-flat-gemini"); + try { + const projRoot = join(tempDir, "proj"); + await mkdir(projRoot, { recursive: true }); + const result = await runCli( + [ + "framework", + "build", + "--source", + FRAMEWORK_PATH, + "--target", + "gemini", + "--flat", + "--out", + projRoot, + ], + projectDir, + fakeHome + ); + expect(result.exitCode).toBe(0); + expect(existsSync(join(projRoot, ".agents", "skills"))).toBe(true); + expect(existsSync(join(projRoot, ".gemini", "agents"))).toBe(true); + const settingsPath = join(projRoot, ".gemini", "settings.json"); + expect(existsSync(settingsPath)).toBe(true); + const settings = JSON.parse(await readFile(settingsPath, "utf-8")) as { + mcpServers?: Record; + hooks?: Record; + context?: { fileName?: string[] }; + }; + expect(settings.mcpServers).toBeDefined(); + expect(settings.hooks).toBeDefined(); + expect(settings.context?.fileName).toEqual(["AGENTS.md"]); + // aidd-orchestrator exclusion is covered by gemini-plugin-exclusion.integration.test.ts β€” + // the fixture marketplace here has no such plugin to exercise it against. + } finally { + await cleanup(); + } + }); + + it("AC #7: --target gemini without --flat exits 1 (no marketplace mode)", async () => { + const { tempDir, projectDir, fakeHome, cleanup } = await createTestEnv("fw-gemini-no-flat"); + try { + const outDir = join(tempDir, "dist"); + const result = await runCli( + ["framework", "build", "--source", FRAMEWORK_PATH, "--target", "gemini", "--out", outDir], + projectDir, + fakeHome + ); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toMatch(/Unsupported target.mode/i); + } finally { + await cleanup(); + } + }); }); From c44e935e86013299319caf809b6628f3f9a5f096 Mon Sep 17 00:00:00 2001 From: Thierry Kwiatkowski Date: Wed, 29 Jul 2026 17:03:39 +0200 Subject: [PATCH 05/23] test(cli): verify gemini against the real binary, extend golden matrix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Empirically proved the flat archive against the real gemini-cli 0.52.0 binary in a sandboxed HOME: skills discovered via `gemini skills list --all`, agents parse cleanly (AgentRegistry loaded, zero AgentLoadError), both hooks fire with translated event names, and AGENTS.md is picked up via context.fileName. Standalone (no codex present), never touching the repo root or real user config. Adds gemini to the golden framework-build snapshot's FLAT_TARGETS (9-cell matrix -> 10-cell). Regenerated additively: diffed old vs. new snapshot to confirm all 9 pre-existing cells are byte-identical, only the new gemini:flat cell (188 files) was added. Phase 5 of part-1 (feat/511-gemini-flat-build-target) β€” the last phase of this child plan. Co-Authored-By: Claude Sonnet 5 --- .../2026_07_27-511-gemini-cli-tool-part-1.md | 11 +- .../golden/framework-build-golden.e2e.test.ts | 12 +- .../snapshots/framework-build/golden.json | 190 ++++++++++++++++++ 3 files changed, 204 insertions(+), 9 deletions(-) diff --git a/aidd_docs/tasks/2026_07/2026_07_27_gemini-cli-build-target/2026_07_27-511-gemini-cli-tool-part-1.md b/aidd_docs/tasks/2026_07/2026_07_27_gemini-cli-build-target/2026_07_27-511-gemini-cli-tool-part-1.md index bf80aca7c..d34b1131d 100644 --- a/aidd_docs/tasks/2026_07/2026_07_27_gemini-cli-build-target/2026_07_27-511-gemini-cli-tool-part-1.md +++ b/aidd_docs/tasks/2026_07/2026_07_27_gemini-cli-build-target/2026_07_27-511-gemini-cli-tool-part-1.md @@ -231,15 +231,17 @@ flowchart TD #### Acceptance criteria -- [ ] `gemini skills list --all` lists every published AIDD skill from `.agents/skills/` -- [ ] No agent file is rejected by the strict frontmatter schema -- [ ] The nine pre-existing golden keys are byte-identical to the pre-change baseline -- [ ] `cd cli && pnpm typecheck && pnpm lint && pnpm test` exits 0 +- [x] `gemini skills list --all` lists every published AIDD skill from `.agents/skills/` +- [x] No agent file is rejected by the strict frontmatter schema +- [x] The nine pre-existing golden keys are byte-identical to the pre-change baseline +- [x] `cd cli && pnpm typecheck && pnpm lint && pnpm test` exits 0 β€” modulo 2 pre-existing, environment-coupled failures; see Amendments ## Amendments +πŸ€– `pnpm test`'s two failures (`self-update --check`, `auth status`) are pre-existing and environment-coupled, not caused by this work: both assert an *unauthenticated* exit code/message, but this dev machine has `gh auth login` active, so the CLI's real auth-detection correctly reports authenticated β€” the opposite of what the test hard-codes. Confirmed unrelated to gemini: same 2 failures were already present in Phase 1's baseline run, before any gemini-specific code existed beyond tool-id registration, and CI runners (no local `gh` session) don't carry this state. Left as-is; not in this plan's scope to fix a local-environment test assumption. + πŸ€– Phase 1/Phase 4 boundary was unsound as originally scoped: adding `"gemini"` to `AI_TOOL_IDS` (Phase 1) without also calling `registerTool(gemini)` (originally Phase 4 task 1) broke every existing test that spreads `AI_TOOL_IDS` as "install all tools" (`tests/application/use-cases/setup-use-case.unit.test.ts`, 3 failures β€” `UnregisteredToolError: Tool 'gemini' is not registered.`), because `AI_TOOL_IDS` is the real runtime source of truth for "which AI tools does `all` install," not just a type-level list. This would have shipped a crash in the real CLI (`aidd setup --tools all`) had Phase 1 landed alone. Flagged to the user; resolved by pulling the tool-definition write (`domain/tools/ai/gemini.ts`) into Phase 1, atomic with the id registration. Phase 4 shrinks accordingly (its task 1 is struck). πŸ€– Extracted `AGENTS_SKILLS_PREFIX` (`.agents/skills/`) from tool-contracts.ts's module-private `CODEX_SKILLS_PREFIX` into `domain/formats/flat-paths.ts` as a shared, exported constant, and pointed gemini's flat skill path at the same constant β€” the actual "extract a shared helper" the plan's Phase 4 task 3 asked for, satisfying the byte-identical-subset requirement by construction (same prefix, same `genericFlatSkillPath` primitive, same `rewriteSkillName: true`) rather than by convention alone. @@ -259,6 +261,7 @@ flowchart TD - Phase 1: `AiToolId`/`AI_TOOL_IDS`/`FrameworkBuildTarget` extended; `CONFIG_ASSETS["gemini"]` + `assets/configs/gemini/settings.json` seed (`context.fileName: ["AGENTS.md"]`) added; `domain/tools/ai/gemini.ts` written and registered (see Amendments for scope); side-effect imports added to `deps.ts` + both test helpers; two exact-array assertions and `isAiToolId` test fixed; new `gemini` block added to `asset-loader.unit.test.ts`. Verified: `pnpm typecheck` (0 errors), `pnpm test:unit` (1413/1413), `pnpm test:e2e` golden framework-build suite (9-cell matrix byte-identical, claude cell frozen), `biome check` (clean after one formatting auto-fix). Two pre-existing, environment-coupled e2e failures observed and confirmed unrelated (`auth status` / `self-update --check`, depend on local `gh auth login` state, no gemini involvement). Committed 8540e4e9. - Phase 2: `domain/formats/gemini-settings-merge.ts` written β€” `GEMINI_HOOK_EVENT_MAP` (verified against the shipped `@google/gemini-cli@0.52.0` bundle's `EVENT_MAPPING` in `gemini-6K6USV55.js`'s hooks-migrate command, and its settings/hooks JSON-schema in `chunk-SZMWXEEI.js`), `mergeGeminiSettingsHooks` (event-translated additive hooks merge, preserves other keys, warns+drops unmapped events), `mergeGeminiSettingsSeed` (idempotent `context.fileName` array union, string-or-array normalization, preserves all other keys including ones written by prior mcp/hooks merges). 14 new unit tests in `gemini-settings-merge.unit.test.ts`. Verified: `pnpm typecheck` (0 errors), full `pnpm test:unit` (1427/1427, up from 1413), `biome check` (clean after one formatting auto-fix). - Phase 3: `excludedPlugins?: readonly string[]` added to `ToolBuildContract`; `shouldBuildPlugin(pluginName)` added to `BuildOutputStrategy` (always `true` in `MarketplaceBuildStrategy`, contract-driven in `FlatBuildStrategy`); `FrameworkBuildUseCase`'s plugin loop extracted into `buildAllPlugins`, skipping rejected plugins with one `logger.warn` each and excluding them from the returned `plugins`/marketplace-catalog entries. New `tests/application/use-cases/framework/plugin-exclusion.integration.test.ts` (4 tests, generic mechanism β€” see Amendments for the naming/scope note). Verified: `pnpm typecheck` (0 errors), `pnpm test:unit` (1427/1427), `pnpm test:integration` (505/505, up from 501), grep gate for `if (tool === ` / `if (kind === "agents")` in both orchestrators returns nothing, `biome check` (clean after one formatting auto-fix). +- Phase 5: Built the real archive (`aidd framework build --source .. --target gemini --flat --out /tmp/...`) against the actual repo β€” 6 plugins built, `aidd-orchestrator` correctly warned-and-skipped. `git init`'d the output, disabled `security.folderTrust.enabled` in a sandboxed `HOME` (no real user config touched), and ran the real `gemini` 0.52.0 binary: `gemini skills list --all` discovered and enabled every AIDD skill (`aidd-context-*`, `aidd-dev-*`, `aidd-pm-*`, `aidd-refine-*`, `aidd-ui-*`, `aidd-vcs-*`) from `.agents/skills/`; a headless `gemini --debug -p` run logged `[AgentRegistry] Loaded with 3 agents.` with zero `AgentLoadError`, and both hooks fired correctly (`SessionStart` and the translated `BeforeAgent`) before failing only on the (deliberately fake) API key; dropping a test `AGENTS.md` at the project root produced `[MemoryDiscovery] Successfully read and processed imports: .../AGENTS.md`, confirming the `context.fileName` wiring works end to end. `FLAT_TARGETS` in `framework-build-golden.e2e.test.ts` gained `"gemini"`; regenerated the snapshot against `tests/fixtures/framework-real` and diffed old vs. new β€” all 9 pre-existing cells byte-identical, only `gemini:flat` (188 files) added. Verified: `pnpm typecheck` (0 errors), `pnpm lint` (clean), `pnpm test` (2069/2071 β€” 2 pre-existing environment-coupled auth failures, see Amendments), golden 10-cell matrix green. - Phase 4: `buildGeminiFlatContract()` added to `tool-contracts.ts` β€” all six artifact kinds declared (skills/agents/mcp/hooks via shared helpers and the new merge module, rules/commands `{supported:false}`), `excludedPlugins: ["aidd-orchestrator"]` applying Phase 3's mechanism for real. `AGENTS_SKILLS_PREFIX` extracted from tool-contracts.ts into `flat-paths.ts` (shared with codex, byte-identical subset by construction). Wired into `deps.ts` (`gemini:flat` registry row), `commands/framework.ts` (`SUPPORTED_TARGETS` + help text), `commands/ai.ts` (description), `menu-use-case.ts` (interactive hint), `biome.json` (`!.gemini`), `package.json` (keyword), `.github/workflows/ci.yml` (10-cell matrix row). New tests: 6 gemini cases in `flat-build-strategy.integration.test.ts` (skills/agents paths, strict-schema frontmatter rebuild, settings.json triple-key coexistence, event translation, hooks-then-mcp non-clobbering, force re-run), `gemini-plugin-exclusion.integration.test.ts` (2 tests, real contract against a synthetic orchestrator overlay), 2 e2e cases in `framework-build.e2e.test.ts` (flat success + shape, non-flat exits 1). Verified: `pnpm typecheck` (0 errors), `pnpm test:unit` (1427/1427), `pnpm test:integration` (514/514), `pnpm test:e2e` (128/130 β€” same 2 pre-existing auth-state failures, golden 9-cell matrix still byte-identical), grep gate clean, `biome check` (clean after one formatting auto-fix). ## Validation flow demonstration diff --git a/cli/tests/golden/framework-build-golden.e2e.test.ts b/cli/tests/golden/framework-build-golden.e2e.test.ts index 50e72f4fe..5cda0ed1b 100644 --- a/cli/tests/golden/framework-build-golden.e2e.test.ts +++ b/cli/tests/golden/framework-build-golden.e2e.test.ts @@ -17,6 +17,8 @@ * RE-BASELINED CELLS (agents-manifest-fix pass: `agents` is now a list of * ./agents/*.md file paths instead of the invalid `["./agents"]` dir form): * claude, cursor, copilot (marketplace) + * ADDITIVE (gemini-flat-build-target pass): gemini:flat added as a new cell; + * all 9 pre-existing cells verified byte-identical to the pre-change baseline. * * USAGE: * Capture all: UPDATE_FRAMEWORK_GOLDEN=1 pnpm test:e2e tests/golden/framework-build-golden.e2e.test.ts @@ -39,8 +41,8 @@ type GoldenSnapshot = Record; // key β†’ files /** All marketplace targets */ const MARKETPLACE_TARGETS = ["copilot", "codex", "claude", "cursor"] as const; -/** All flat targets (including opencode which is flat-only) */ -const FLAT_TARGETS = ["claude", "cursor", "copilot", "codex", "opencode"] as const; +/** All flat targets (including opencode and gemini, both flat-only) */ +const FLAT_TARGETS = ["claude", "cursor", "copilot", "codex", "opencode", "gemini"] as const; /** * Frozen marketplace cell: its fresh build is byte-compared to the stored hash on @@ -112,7 +114,7 @@ async function captureAllCells( return captured; } -describe.concurrent("Framework build golden β€” 9-cell matrix", () => { +describe.concurrent("Framework build golden β€” 10-cell matrix", () => { it("snapshot is deterministic (two captures of each target are byte-identical)", async () => { const env1 = await createTestEnv("fb-golden-det-1"); const env2 = await createTestEnv("fb-golden-det-2"); @@ -157,7 +159,7 @@ describe.concurrent("Framework build golden β€” 9-cell matrix", () => { } }); - it("stored golden baseline covers all 9 cells and the frozen claude cell is byte-identical (AC #1)", async () => { + it("stored golden baseline covers all 10 cells and the frozen claude cell is byte-identical (AC #1)", async () => { const { tempDir, projectDir, fakeHome, cleanup } = await createTestEnv("fb-golden-baseline"); try { const captured = await captureAllCells(projectDir, fakeHome, tempDir); @@ -192,7 +194,7 @@ describe.concurrent("Framework build golden β€” 9-cell matrix", () => { } }); - it("all 9 cells are non-empty", async () => { + it("all 10 cells are non-empty", async () => { const stored = JSON.parse(await readFile(SNAPSHOT_FILE, "utf-8")) as GoldenSnapshot; const expectedCells = [...MARKETPLACE_TARGETS, ...FLAT_TARGETS.map((t) => `${t}:flat`)]; for (const key of expectedCells) { diff --git a/cli/tests/golden/snapshots/framework-build/golden.json b/cli/tests/golden/snapshots/framework-build/golden.json index 1de3fda93..42988f54b 100644 --- a/cli/tests/golden/snapshots/framework-build/golden.json +++ b/cli/tests/golden/snapshots/framework-build/golden.json @@ -1752,5 +1752,195 @@ ".opencode/agents/aidd-dev-implementer.md": "4b4fe709e0ed56b097b697a49f6adfc200cccaad36a0e978a12e4f062a3a5e38", ".opencode/agents/aidd-dev-planner.md": "c83648c34068b6fa762fdc6b073e31cb3e9dfe5bd5bf2fd7e2cb4ad3e865feee", ".opencode/agents/aidd-dev-reviewer.md": "30e2dbb93309d23fc99cbce36a4fcfd0b6d76e49c3425979827727327ba697a6" + }, + "gemini:flat": { + ".gemini/settings.json": "7bbb5d8cff7f488f80bcdb24160664a88e2d5af1dc672218b581683f1224152c", + ".gemini/hooks/aidd-context/update_memory.js": "140d7db788452f5f4c32316d522f595a36e06638b19a42d32e42a1a7324b7149", + ".gemini/agents/aidd-async-dev-async-orchestrator.md": "5ca31d8117dcc4800265ab04093432090485fdda1de8a867b29c3d3e55d30e3d", + ".gemini/agents/aidd-dev-implementer.md": "ae86fb9aa52a65f76c200fde226e5f23e88d66aef16cf1606d204afa3b2fe292", + ".gemini/agents/aidd-dev-planner.md": "1af575bcd00fbfcdb2dbc1759f5454f386dbe4d2a4632b522fef2409a3d0eeab", + ".gemini/agents/aidd-dev-reviewer.md": "5e4b954a7636948ac910abc887c535dffbc9ab788e6e5aa290d6f6a09ddf8735", + ".agents/skills/aidd-vcs-04-issue-create/SKILL.md": "3983ed1c71124786fdf737ec5447637a31b1cc146eff43003978cec50c2ddc67", + ".agents/skills/aidd-vcs-04-issue-create/evals/scenarios.json": "d113c62aae4867e425737948c39c9f687f56c3d37234eca345a2f65f1a234c30", + ".agents/skills/aidd-vcs-04-issue-create/assets/CONTRIBUTING.md": "1372c7512c02c26e21643ae523a98d7c90ff92c03633532a8c417b4ca8d9e75f", + ".agents/skills/aidd-vcs-04-issue-create/assets/issue-template.md": "66b4ef6090208512205fce0bb16edbbcf0ffb19009eae76dc317d738c7a10cdc", + ".agents/skills/aidd-vcs-04-issue-create/actions/01-issue-create.md": "e3f60414fa3ce2d78583e5cb118dad8df956927aa86e2f6e802031a7d742d7ed", + ".agents/skills/aidd-vcs-03-release-tag/SKILL.md": "0052405cdf2deeb07b123e8f94ee47a2b4ecea6031641315ce71fc6cff3faabb", + ".agents/skills/aidd-vcs-03-release-tag/evals/scenarios.json": "fdba5d61f815b956512f84baf712cde92be651593282b43ac173b2459ee9eca3", + ".agents/skills/aidd-vcs-03-release-tag/assets/release-template.md": "bce05178ae5ea7da6c356849ba69eebd249ba344c94a3c571d7d40be9d4e9e27", + ".agents/skills/aidd-vcs-03-release-tag/actions/01-release-tag.md": "b2f6c6cce5f7c83f83e4b2fd84c7aae3888bdfcd29f1be3d3e2bc362b18eae6b", + ".agents/skills/aidd-vcs-02-pull-request/SKILL.md": "80c8d4f2dc28e4d1b67d418c12a865ba74ffc8b57720eb05bdf1e1fca3b82a6a", + ".agents/skills/aidd-vcs-02-pull-request/evals/scenarios.json": "63f63e6b13038672fbe325509deb32a9b25a50a996b687c001dfc74815a0dccd", + ".agents/skills/aidd-vcs-02-pull-request/assets/CONTRIBUTING.md": "1372c7512c02c26e21643ae523a98d7c90ff92c03633532a8c417b4ca8d9e75f", + ".agents/skills/aidd-vcs-02-pull-request/assets/README.md": "fb774bb7e5a19a39b21619879ee5045d7b3a0952be3f4b61aea524b93e9c9086", + ".agents/skills/aidd-vcs-02-pull-request/assets/branch.md": "92880244478c3e48c4f105e3ab2f9c09850778f84f1e44108ff8e912ccb0bea3", + ".agents/skills/aidd-vcs-02-pull-request/assets/pull_request.md": "66939eaae42c729f3b05f8dcc2546a1e4227441d9d156d692fc7ced89d07a11f", + ".agents/skills/aidd-vcs-02-pull-request/actions/01-pull-request.md": "3c3753569b5f336e6c53ac8312ba6f9538ccc711e16fb7847ca1ebf04250a790", + ".agents/skills/aidd-vcs-01-commit/SKILL.md": "1825b3994a89c0394271c3437a772e74892e4bfb5fac9c2dbc90739544404585", + ".agents/skills/aidd-vcs-01-commit/evals/scenarios.json": "bdefba21f63f7ef737ff3431077278cc45b722e85ba4b66e3a4adf4d34e0b2ff", + ".agents/skills/aidd-vcs-01-commit/assets/commit-template.md": "b3c392c5c3faecc903bf80eb2bd287d2a02e42d4c3896490a5745d66fca60bf2", + ".agents/skills/aidd-vcs-01-commit/actions/01-commit.md": "06ded7f8c23e950b3b1a76b0bb89c219d1ca161cb8f9a02e04e8083ad7061dc0", + ".agents/skills/aidd-refine-03-condense/SKILL.md": "2d6db1071f826db1cf030fb8a34aeaf0adc79884a29750efa9fe378ab12d8593", + ".agents/skills/aidd-refine-03-condense/references/intensity-levels.md": "8e6aa26fc675a2d30dbb4df13e68d0ce6d2844c88bafd39e0ea3f2945e3276f1", + ".agents/skills/aidd-refine-03-condense/evals/scenarios.json": "c03016b5e98c5a9a8b6680035bf8ee0f2a75edc5ce33e33cf1e1430cd91f55ec", + ".agents/skills/aidd-refine-03-condense/actions/01-condense.md": "bffb26d5a306bc90f40614b29ca21e9a2151dc5ba82880c857e29c39486a16fd", + ".agents/skills/aidd-refine-02-challenge/SKILL.md": "0e05299821bb3e41834cd8ff97ffa23f48342974b4e20c1e9ea59ce83255eed8", + ".agents/skills/aidd-refine-02-challenge/references/confidence-rubric.md": "714f1bfd0c33f2adf911c93e9bb2f113be9fd29bad07d83e54be293fc44de823", + ".agents/skills/aidd-refine-02-challenge/evals/scenarios.json": "332403843b5b5d99a9dcb3464f298ca3b7274edbd1b5c337407a4dd977bc19a5", + ".agents/skills/aidd-refine-02-challenge/actions/01-challenge.md": "6f47920ccec8682708789e07583e32698695f1274bf883757ab13fca6f3e76c0", + ".agents/skills/aidd-refine-01-brainstorm/SKILL.md": "e29675b330838d6bd843f40c227fd64ae46e9162c3c3b5db4d8695d9f6b38f17", + ".agents/skills/aidd-refine-01-brainstorm/references/ambiguity-detection.md": "c869338af0d8e0bdde5915000ce44980fef750cc40d070d4055bcba4c1334e0f", + ".agents/skills/aidd-refine-01-brainstorm/evals/scenarios.json": "c6858f3b6b8efa666e2c7f21f4325f5e57297e3fdfc475b836f3ce5de9d635bf", + ".agents/skills/aidd-refine-01-brainstorm/assets/question-templates.md": "bd744429e69f26caf37359e2b1d1c38dc4faba1a2c54dfaa99bfde48da0dc3c8", + ".agents/skills/aidd-refine-01-brainstorm/actions/01-capture-request.md": "bc133963be4eeb68d6653a65f88f2e1820d711d6d7bf74b79a6225fff7b8e6c8", + ".agents/skills/aidd-refine-01-brainstorm/actions/02-ask-probing-questions.md": "d8a25b7e2c0861bc4bce878e7d10dcda279855fa0db06d1997ad84f3e5682d54", + ".agents/skills/aidd-refine-01-brainstorm/actions/03-integrate-answers.md": "b6f2c34f4f03cca48e83ed6fff5936e8ddbecae5fef1f597e99fd764e13f32cd", + ".agents/skills/aidd-refine-01-brainstorm/actions/04-refine-and-validate.md": "4a6368a3e189c3022f133050f996eda3d9195281a3e4bbabdd120f5b8b414c3c", + ".agents/skills/aidd-refine-01-brainstorm/actions/05-confirm-approval.md": "3c6c8184910a210bb6698c2c010aebec88a28313b31bbed0ef9c1189530cd6fe", + ".agents/skills/aidd-pm-05-spec/SKILL.md": "32a14c6e9b275630363a0cca29ebd3c6c35271fa38be9eb79cc0e41e8e6e478b", + ".agents/skills/aidd-pm-05-spec/assets/spec-template.md": "2f7e446c4ec58d05a471212e9ab3ba64395bdd059943620d02bf1bf5c98777c2", + ".agents/skills/aidd-pm-05-spec/assets/spec-validator.yml": "2358c6f0baa5656ddcff9410149cf8261d54199d8722f77769f76ca0d7c8cf18", + ".agents/skills/aidd-pm-04-clarity/SKILL.md": "5f50dcfdf1c96f1ce71eed10dd791e1a2945eb9e761e8d42beca3519370ba55b", + ".agents/skills/aidd-pm-03-prd/SKILL.md": "b3ac6ea89c9642651135cb9f7dea8bdf47fb3bc4ea777216d2f65b5459dd0c6b", + ".agents/skills/aidd-pm-03-prd/assets/prd-template.md": "af01423720e8c9527ff12a2e9ad1de39c6e63c6569359be08defd0a059aabea9", + ".agents/skills/aidd-pm-03-prd/assets/task-template.md": "c0069ed2ecce4742629dccd6be709d4e5a71f3db3821310c381c054aeb479289", + ".agents/skills/aidd-pm-03-prd/actions/01-prd.md": "c0da6597f8c465ad50a17a07543152f53de1fbccc28cd906763d439004084a22", + ".agents/skills/aidd-pm-02-user-stories-create/SKILL.md": "d95bad3cfd000c1e8dba905a152384320a177f5891bdaa218819ffe55f6ad710", + ".agents/skills/aidd-pm-02-user-stories-create/assets/user-story-template.md": "59ecd98d00057313414a74a94c19c0f2d167957e1080b880ee68a69a31948320", + ".agents/skills/aidd-pm-02-user-stories-create/actions/01-create-user-stories.md": "6fad8968a1e787c197764c4a8c09b17c521ae5ea2286a5082addc717bd72b1e6", + ".agents/skills/aidd-pm-01-ticket-info/SKILL.md": "26e0491f8527a073f6685ffe7c3e15a22f230a22837aea92694d34a7a2f2f29d", + ".agents/skills/aidd-pm-01-ticket-info/actions/01-ticket-info.md": "f075ea6ff626534dbe0826d06b67fae93b6488ec4c5c7a3d857f1d05f73d0630", + ".agents/skills/aidd-dev-08-for-sure/SKILL.md": "07a94d0a975a8b6f4e2be89b3fd9d68b9b71bd9843836c76e1aa074685cc4376", + ".agents/skills/aidd-dev-08-for-sure/actions/01-init-tracking.md": "3ce6bb19135e5d31c4d2ad1c2151c1563886a611f2399b47624368268a79dcdf", + ".agents/skills/aidd-dev-08-for-sure/actions/02-auto-accept.md": "235818118c772c0f499ea0668bfbc7bd00c2aae34e6bbd8b13b912fddfa79705", + ".agents/skills/aidd-dev-08-for-sure/actions/03-autonomous-loop.md": "b19d4520ad716def3c2a4611c37725cba9af713cd4bcb668c237e874e1e43843", + ".agents/skills/aidd-dev-07-debug/SKILL.md": "860f4137ca48a43470d52f76ae8e631204e5fd7dd82a661905d40bd583bf56fc", + ".agents/skills/aidd-dev-07-debug/references/mermaid-conventions.md": "85826285744909dd4c4706b82f0dbeff4f88a8f191cb22d538aa12c3c96365eb", + ".agents/skills/aidd-dev-07-debug/assets/task-template.md": "c0069ed2ecce4742629dccd6be709d4e5a71f3db3821310c381c054aeb479289", + ".agents/skills/aidd-dev-07-debug/actions/01-reproduce.md": "7e902e5aadc9162b444b341deb33f2a683000c1cc4722ad856496f9d740ec17d", + ".agents/skills/aidd-dev-07-debug/actions/02-debug.md": "dc3900ce6fb76074b88034d7b7316f4051d78a1828caa3acef9786e8ea582b18", + ".agents/skills/aidd-dev-07-debug/actions/03-reflect-issue.md": "3d5f18634618737870da4c783c7525cee25e09d942faee80125dd7536978ce12", + ".agents/skills/aidd-dev-06-refactor/SKILL.md": "6fbf8c363728a11288c58f6967d36845aa4709a9a69a62bd6c6d9ca6d39135ba", + ".agents/skills/aidd-dev-06-refactor/actions/01-performance.md": "1f7d500967de58875aeb14b732a1af377b17edff8654f4365b3f29743debb3c7", + ".agents/skills/aidd-dev-06-refactor/actions/02-security.md": "db55b81ab824d81b765189296deb1ce31fdb134edac186baf22a2c3bb1cde938", + ".agents/skills/aidd-dev-05-test/SKILL.md": "131c810499c0673ace51a2385640dc533203ca89560f72a381bef4a2abf9d388", + ".agents/skills/aidd-dev-05-test/actions/01-test.md": "f6db9653cd29729c51653df7afe473fae4ae45bb126a694a367bfd84fa1785a4", + ".agents/skills/aidd-dev-05-test/actions/02-test-journey.md": "1eeb85da69abc3f1fc3e5671e83962f44fcb5686d9ca5972eeb2ba4cab31306d", + ".agents/skills/aidd-dev-04-review/SKILL.md": "87b2de872bd37fa19b12f458f6c30d60ca65de8ce2988dc63d0811cdec4720cc", + ".agents/skills/aidd-dev-04-review/assets/code-review-template.md": "e270c4b6b8c69e4fbbbcc08c2f91cc9a09fc8ad155b6f52cec3e3d34a262324d", + ".agents/skills/aidd-dev-04-review/assets/review-functional-template.md": "a84b48347caf4d09d84994b07329e4d05c8053d45630b37d47ca3ccb386e3146", + ".agents/skills/aidd-dev-04-review/assets/review-template.md": "b0ad0ab703e4d9ed960bd324bc37efe5f682cbcd40ed6293791d9074f174201c", + ".agents/skills/aidd-dev-04-review/actions/01-review-code.md": "8d5e4fc6c9243a83025961ae40146f1d96f321d9ff4d93ab74c370bbc53c18ed", + ".agents/skills/aidd-dev-04-review/actions/02-review-functional.md": "9e5d837715f5610a42f1c294b0fa71277538e2629dc47cf09675487dcfa98324", + ".agents/skills/aidd-dev-03-audit/SKILL.md": "da84a82a3446283034f26ad5a335d7f8a48ecd501c254af2f4ddaccd09cd4e0f", + ".agents/skills/aidd-dev-03-audit/actions/01-audit.md": "0371ae3d0c9a383f8b90657f3381717f323742bad922df2c1c508f6db013267e", + ".agents/skills/aidd-dev-02-assert/SKILL.md": "a75c5fe87f257fe476129d8eb5b89267df6563924e5c84bb4ea6c1d7215b14ad", + ".agents/skills/aidd-dev-02-assert/assets/task-template.md": "c0069ed2ecce4742629dccd6be709d4e5a71f3db3821310c381c054aeb479289", + ".agents/skills/aidd-dev-02-assert/actions/01-assert.md": "b8a9680e7cf956f2e1ff7720f755586e2523d6951c35ec686ded659f7fa3a4b1", + ".agents/skills/aidd-dev-02-assert/actions/02-assert-architecture.md": "e5d7effa39f33c045e4d605ed909d46ddf7562e2a0199eb2e39fcc8f3fc92620", + ".agents/skills/aidd-dev-02-assert/actions/03-assert-frontend.md": "ed9751b32580c7fd3fbe02a8205cbb5d85a8e7bc38f982b5130b810950bbd091", + ".agents/skills/aidd-dev-01-plan/SKILL.md": "272aaa80349b438a1536c78e91d5c55c1ef26b65f942caa1c47edc062e0591c9", + ".agents/skills/aidd-dev-01-plan/references/mermaid-conventions.md": "85826285744909dd4c4706b82f0dbeff4f88a8f191cb22d538aa12c3c96365eb", + ".agents/skills/aidd-dev-01-plan/assets/master-plan-template.md": "f793f2bc8fad34f056e824def49527552f50cf12e43f0929731d64278376b822", + ".agents/skills/aidd-dev-01-plan/assets/plan-template.md": "cf462e831d994230c811c71eaa72a8f9880536ba3b657b70342083dee00ab254", + ".agents/skills/aidd-dev-01-plan/assets/tech-choice-template.md": "c9f16f9b598aded5953127b4bf2768b95d7abda0b75d937e0b31dfee9317c40a", + ".agents/skills/aidd-dev-01-plan/actions/01-plan.md": "c480fc1cffe39f117003b5aeea23e10f808952a0eeea067dbc0ab44c0af992ff", + ".agents/skills/aidd-dev-01-plan/actions/02-components-behavior.md": "cacb673334d8947c3c252feaaf0ef50c269c06c5cf8f51b45663cbd42402e094", + ".agents/skills/aidd-dev-01-plan/actions/03-image-extract-details.md": "6f6cddd0893888c71b0aed5ecfe48217afe0eef10feec6415db433bfcc69f8e3", + ".agents/skills/aidd-dev-00-sdlc/SKILL.md": "34964c02e338adbf393c25abc4f5862e28452f874c7640f85c950e170130c17c", + ".agents/skills/aidd-context-06-discovery/SKILL.md": "45cf7373cddfe1614b6edba32b36a9411d9136b8eab29a3ae9b43dab7ee4e912", + ".agents/skills/aidd-context-06-discovery/actions/01-find-skill.md": "600caf7822017bfe75d40773e1673ada784227b98f5b88d4ba98a57822fa0dcf", + ".agents/skills/aidd-context-05-learn/SKILL.md": "9ca8b41c8a8fac66d943ad1658ae7a2f374373d13a8a2edd66e1f7fd058694de", + ".agents/skills/aidd-context-05-learn/assets/adr-template.md": "1f9feb18109b178226885ab7edabc3d1c4dd2a77dd1fa7345be856643107ac16", + ".agents/skills/aidd-context-05-learn/assets/decision-template.md": "1e6229157fd0a07c090ce0bd159336a13554975e146966b306bf25665a179938", + ".agents/skills/aidd-context-05-learn/actions/01-learn.md": "92400bfcc2cdfe9f0bad8f2feadd7d28dce882240fe58971d25dfa880521d8bb", + ".agents/skills/aidd-context-04-mermaid/SKILL.md": "d25ac37dc7e065a1bf6eafc5a49dfaef14af85cb947e26095d6b376a32c3b416", + ".agents/skills/aidd-context-04-mermaid/references/mermaid-conventions.md": "85826285744909dd4c4706b82f0dbeff4f88a8f191cb22d538aa12c3c96365eb", + ".agents/skills/aidd-context-04-mermaid/actions/01-mermaid.md": "f70b18f429701d0c3b46a4c6e75153c732ee9e06a16aa01efec05e810ac7a6dd", + ".agents/skills/aidd-context-03-context-generate/SKILL.md": "e0f9b6c4a2eadc331c5611e1cc18c74d10633e94180bf56a6fee5aed6f984f07", + ".agents/skills/aidd-context-03-context-generate/references/agents-coordination.md": "eaaa31ed554a53f7870151307853e1add7e3ddd1de8e442b8d5e7c5698ab0098", + ".agents/skills/aidd-context-03-context-generate/references/ai-mapping.md": "4ad192b5ae53c7ceadff165a1cef8c3f24949498375bedc0cc359194dea3117e", + ".agents/skills/aidd-context-03-context-generate/references/naming-conventions.md": "fd39d00b1449f0770ee89aad9f6e84d21e1fee2efc0ff547a240b0e18f648cd8", + ".agents/skills/aidd-context-03-context-generate/references/rule-structure.md": "c8ce6eea6dbaae5309a77237d702db193a7748db5c581d0918f17db3989e414f", + ".agents/skills/aidd-context-03-context-generate/references/rule-writing.md": "e4cddd88dca3162d576a1b616c419f5ff2f3b7681a09a569da68358f3713c9e6", + ".agents/skills/aidd-context-03-context-generate/references/skill-structure.md": "2b6114ff8aa30f1a5d7bfd3bf0d7d87e429cb8d65ba13b369e6cdc9c8bec501c", + ".agents/skills/aidd-context-03-context-generate/evals/scenarios.json": "db355f13f2ca3499f912f39ada136472f9bc993846564aeaeb6eab371d485520", + ".agents/skills/aidd-context-03-context-generate/assets/skills/action-template.md": "8bd80e05dcbb10e24adb668778fd9dfb058c92193c52c6012e9f5cc11305103c", + ".agents/skills/aidd-context-03-context-generate/assets/skills/evals-template.md": "c0468fe9869895b42f7a59b48f7613ca4ef3b1af93acb7c41dc063559964ff27", + ".agents/skills/aidd-context-03-context-generate/assets/skills/skill-template.md": "f30bc5a47574cc85dc0daddfa1abd0d7f8bd8a39e201fbb3df5784506a61b13a", + ".agents/skills/aidd-context-03-context-generate/assets/rules/rule-template.md": "200ec56d8ed43acd51b94df783f7de9236b00a6e5398a98a30640d02edb76ba1", + ".agents/skills/aidd-context-03-context-generate/assets/agents/agent-template.md": "476802721ac4947d741b4bd17626860c4914037992a2ebf0ef282553f819aa25", + ".agents/skills/aidd-context-03-context-generate/actions/skills/01-capture-intent.md": "850c9b7dab101eba66284950eb3b8b612aca3ad40a1b13fcdc5ac181d1f72069", + ".agents/skills/aidd-context-03-context-generate/actions/skills/02-design-evals.md": "fc51fb2c85a7462fe27586ddc3cc5c2699214dcd059c28f7ab8daf26a6479ced", + ".agents/skills/aidd-context-03-context-generate/actions/skills/03-decompose-actions.md": "8bf7ff1ac05d968fc7a3e9b6aa5de3df3be04b07e96517cbe06268baec4151fc", + ".agents/skills/aidd-context-03-context-generate/actions/skills/04-draft-skill.md": "08946a56b720408bee9c63cd7141e6c869e6818610546f8a42628fe6d2e12514", + ".agents/skills/aidd-context-03-context-generate/actions/skills/05-write-actions.md": "948ac436af5e9c9e897b8c4e44b12a278943bff2b93afc10b4ddaee587d58cc5", + ".agents/skills/aidd-context-03-context-generate/actions/skills/06-validate.md": "658c1077b03c461bb0c3fa17db047e4d2fbc3874f4d4fbd2e3dc5df606aa03ee", + ".agents/skills/aidd-context-03-context-generate/actions/rules/01-generate-rules.md": "855b9adf65b0b0cc31ca15c68b8ac37da6a816c9a6416fbc3aea424cffa238c3", + ".agents/skills/aidd-context-03-context-generate/actions/agents/01-generate-agent.md": "3dce30335eba1d60c5a62d43184d133eed621bbc344e9277ab0f05ead443e0ba", + ".agents/skills/aidd-context-02-project-init/SKILL.md": "4bd40beb8290018b6fa58b651d3681e13465b39b84c3e4c0c39964aefcc52002", + ".agents/skills/aidd-context-02-project-init/references/mapping-ai-context-file.md": "cf251454634037d4affd1d27cee2593c555dfe29305b521d3bed4564f4cad273", + ".agents/skills/aidd-context-02-project-init/assets/AGENTS.md": "8b3d349220e9f96b45dedf316eeb2b3418666d96d0a4f64d315cadfc9ef337ae", + ".agents/skills/aidd-context-02-project-init/assets/GUIDELINES.md": "bf41995402b46268ba53ceec41be00c455004603f69035349b6ff3bef14cc18a", + ".agents/skills/aidd-context-02-project-init/assets/README.md": "575086cda27a386806a28c1bb4865cb5f974dc3fe6e39ca8bdd2c19764cf2059", + ".agents/skills/aidd-context-02-project-init/assets/golden-principles.md": "e83c582a9e1477a42531734deed90061f4e004ea28e1b1616bf714070856c888", + ".agents/skills/aidd-context-02-project-init/assets/templates/workflows/README.md": "33d50760dfde22feb8878cf3e24d399a748eef8ed2df630f465d673d9f125a53", + ".agents/skills/aidd-context-02-project-init/assets/templates/stack/README.md": "8dbccfc7d47424411b7725374247b50973a962fff777b272a23109b7e0f2a099", + ".agents/skills/aidd-context-02-project-init/assets/templates/memory/README.md": "66e6275cd7e7187ca520ad8f2f3c57ebecf967742901e00085c139fd9eea5892", + ".agents/skills/aidd-context-02-project-init/assets/templates/memory/architecture.md": "f00c02db4135a43e99f5bdd7888f40f290e39dd4a65bb1539eca9a06c9873427", + ".agents/skills/aidd-context-02-project-init/assets/templates/memory/codebase-map.md": "5d1d184bc70c93e0073cb64312f4fa8d50fcf465b0da982f37bde25d624a5386", + ".agents/skills/aidd-context-02-project-init/assets/templates/memory/coding-assertions.md": "2e3d7b16f85e8af3cf9971c3b0c23409653e8d693c969ff11892c5a4f10bea90", + ".agents/skills/aidd-context-02-project-init/assets/templates/memory/deployment.md": "361e80ebbddda1f0497856530e7a5a35cd2c8ae56df2a6ef64268657de333d28", + ".agents/skills/aidd-context-02-project-init/assets/templates/memory/project-brief.md": "0240ee41e05c6091bd136b7c3b18f603731128941c987449917886b191e38e90", + ".agents/skills/aidd-context-02-project-init/assets/templates/memory/testing.md": "760e58855270ee9210b20987b3f01b537b25f6108f8d164e2a88000f4cb278b7", + ".agents/skills/aidd-context-02-project-init/assets/templates/memory/vcs.md": "3eccc51898b8c0c17245a737c9fa540f30706676ea01ff771d6898f3861060f1", + ".agents/skills/aidd-context-02-project-init/assets/templates/memory/frontend/browsing.md": "f8ab234ba45571979a5dbc2181859a8f6859cd56c9d86371560516b3323dd8a1", + ".agents/skills/aidd-context-02-project-init/assets/templates/memory/frontend/design.md": "edcaa8ab125acd64d330a32abac102fa3d0b37b45ef03a9552c71c885c1ea847", + ".agents/skills/aidd-context-02-project-init/assets/templates/memory/frontend/forms.md": "662dd1b62a9d3fef587cf38f1c2f6fe3de2d0cb226a9ec50dd7b576bf824115b", + ".agents/skills/aidd-context-02-project-init/assets/templates/memory/backend/api-docs.md": "09b9633c1d8c3b5a11bf0d171abf148bf159836ae96223a78a52b9e56ffb52e8", + ".agents/skills/aidd-context-02-project-init/assets/templates/memory/backend/backend-communication.md": "87d548e12bed0fa2a2d7601fe1c36d810f40051abd713cc3d4462582319babde", + ".agents/skills/aidd-context-02-project-init/assets/templates/memory/backend/database.md": "899ceb107598c8b9a1adc3ff7cf8b6217e4582553f5c27f3e22b9e7fb0f555b9", + ".agents/skills/aidd-context-02-project-init/actions/01-init-context-file.md": "33c907084418ec22f8c22381fbefecfbf3d88c250781c852059bf611be0f2ae2", + ".agents/skills/aidd-context-02-project-init/actions/02-scaffold-docs.md": "c07f175692241d477b029441a2a984c2fff6de0041ff6f47a6851d73900a1ac3", + ".agents/skills/aidd-context-02-project-init/actions/03-generate-memory.md": "f7cde767985a1d7ca8c2d74c1ffcc11eac2232a97e26b46573533eefcdd5ac96", + ".agents/skills/aidd-context-02-project-init/actions/04-review-memory.md": "211ad0ad0d7c4925acbd8a41f775a16b09de51b85d9f3fc6f972170ac63fd353", + ".agents/skills/aidd-context-02-project-init/actions/05-init-rules-skeleton.md": "12924f4535c083e114a5a4a4ef148e4aa2c8c4c436d23c8f50e152f869e3be2c", + ".agents/skills/aidd-context-02-project-init/actions/06-sync-memory.md": "3f6825c8d230cea72f729aee15cf6136b613b7a9b07ddec237b91fe5ad3be59b", + ".agents/skills/aidd-context-01-bootstrap/SKILL.md": "be11990b997d32ff2acb7c49eba7d63899ffbe161827e4c25d330debace71805", + ".agents/skills/aidd-context-01-bootstrap/references/stack-heuristics.md": "17f7c0df7b19090f42c26ddf70df1049897b3973d9c1bf8df358b87a1cc9985a", + ".agents/skills/aidd-context-01-bootstrap/evals/scenarios.json": "c5998a91c584d561618dbabe66f21bd8f446260731778de4f467a70a6da21784", + ".agents/skills/aidd-context-01-bootstrap/assets/checklist.md": "64b84a7712ca78bc1901d2c78c183310336a4abe1b5890a689c3cf166b026d21", + ".agents/skills/aidd-context-01-bootstrap/assets/install-template.md": "cd67115a7b11fddea0258810b9cee1d35c3e12840f515c5364fa769cad9d6a60", + ".agents/skills/aidd-context-01-bootstrap/actions/01-gather-needs.md": "b3251034285434b06775ab4a59ffe27259bb23cb97ac65927c946a4846ea946d", + ".agents/skills/aidd-context-01-bootstrap/actions/02-propose-candidates.md": "57376071d9d0b011c5f19ba218a3ef28c3bf1fba1220a87260806a2d79e341d6", + ".agents/skills/aidd-context-01-bootstrap/actions/03-audit-candidates.md": "def30caedeca0babb4500b2081fe46bdcaf72ee911e4ad94ae860b068c2ee0e0", + ".agents/skills/aidd-context-01-bootstrap/actions/04-pick-and-design.md": "1444ede4a16e6df368792d7f214e0bb61912621a9c266205e031ec73151606e7", + ".agents/skills/aidd-context-01-bootstrap/actions/05-write-install-md.md": "c94afb152683cc29f10d2d4451632265b7fdc763a8a41b69f8ddc2c00b81dfd0", + ".agents/skills/aidd-async-dev-03-review/SKILL.md": "12577d34b7e03ac89407075bbeb998925e9d1043620913767d390d1153151e39", + ".agents/skills/aidd-async-dev-03-review/references/stop-conditions.md": "47280ec7ebb0bd2d25bb7bf7dd716f5ef41a0ad8163653fa139981b5283e7ba0", + ".agents/skills/aidd-async-dev-03-review/evals/scenarios.json": "562d462eedcb589487585a6840d3077a8cce760ea4ecd5e416a8a01b8472d995", + ".agents/skills/aidd-async-dev-03-review/actions/skills/01-collect-comments.md": "a7494fcd9ec705cf76d36ed9b7ce59b2d9ed6e82ce64084e2ed935d52ab57527", + ".agents/skills/aidd-async-dev-03-review/actions/skills/02-detect-stop.md": "b5d40992028d2e0a1d59f31dd699237005ebb51d9c70a648aca7f757f7449659", + ".agents/skills/aidd-async-dev-03-review/actions/skills/03-fix-iteration.md": "a3133409542ed6fc8e9b814658be68504e646cc44ed58ca749251c6b6dd45f25", + ".agents/skills/aidd-async-dev-03-review/actions/skills/04-finalize.md": "bf01c103e3940fda6fd07092bd35f44993f24cdd25ec7ff5c52004b20a37cb17", + ".agents/skills/aidd-async-dev-02-run/SKILL.md": "63fa98e5a2e3baaa8a4b1e55fa894f4d98fe47e4dbdc0ae5be2c6c5d7f894e01", + ".agents/skills/aidd-async-dev-02-run/evals/scenarios.json": "cf78e4c46ac988912a7de98b1a1143c439d3c744bef234d7294f17b4836a84d0", + ".agents/skills/aidd-async-dev-02-run/actions/skills/01-poll-ready.md": "38b0767d080c60149fc4dd1b04395c72b6cda204bd2152d2f9be54db814626fd", + ".agents/skills/aidd-async-dev-02-run/actions/skills/02-resolve-deps.md": "9ede89ff3396287bde2ed9fed6e8068a8411b9190dc446ec392badaf31a52a5f", + ".agents/skills/aidd-async-dev-02-run/actions/skills/03-acquire-lock.md": "0a9b54f4bc9ebdb653932b57bd9d61e9ee7248d86104bc95841617beb4eeeee5", + ".agents/skills/aidd-async-dev-02-run/actions/skills/04-check-sdlc.md": "1934b9f0fb5824736349070ceca67105892ef7e4f319ca95ae955f564e821633", + ".agents/skills/aidd-async-dev-02-run/actions/skills/05-delegate-sdlc.md": "3142a7d2ee15d1871fecc1b4977ff3fb43b9a343f25913a54cc943d9fab31a7c", + ".agents/skills/aidd-async-dev-02-run/actions/skills/06-write-audit.md": "3cfbabf8f6c6e267994afcf189d17234eac4c3bb35ab68408776f1acb644b6ba", + ".agents/skills/aidd-async-dev-02-run/actions/skills/07-emit-webhook.md": "4c8fe76bdf265386247a636dafc9365946114afa54c479ef13abfd929d7af3db", + ".agents/skills/aidd-async-dev-01-setup/SKILL.md": "ce55273c1d1109267f8725edfeb654df9ad4c84e9055998b0e29f9817c6befa3", + ".agents/skills/aidd-async-dev-01-setup/references/auth-modes.md": "4122f895e4b210fce0e8f4cac81a79f6711eca56edac02ac43235359a77c2f62", + ".agents/skills/aidd-async-dev-01-setup/evals/scenarios.json": "79b49984a0999a3f34348bedbf8e7fe815ebdddf825701fabfedbe39f7a1ede2", + ".agents/skills/aidd-async-dev-01-setup/assets/config-template.json": "d47d1d93014157d525f48f3c96626472d940fafffc0deb106af2371db97b4e83", + ".agents/skills/aidd-async-dev-01-setup/assets/workflow-template.yml": "203382209de920a9405b85cc89817ddf0f731a7604e8a465fc4fb3c32589d168", + ".agents/skills/aidd-async-dev-01-setup/actions/skills/01-detect-context.md": "83f3ba3022dd75fa2597a872a55e8f75173762f932ed39ae73d8181c0b1b4536", + ".agents/skills/aidd-async-dev-01-setup/actions/skills/02-ask-config.md": "986335d1e86a633d0083fe4488aae58266a82ec35d0cdb0b74f0bbcea70e6f4e", + ".agents/skills/aidd-async-dev-01-setup/actions/skills/03-generate-workflow.md": "11f7ec6c03284d0524179f71337691301a6362cf77bf3aa666fe41686b4df40b", + ".agents/skills/aidd-async-dev-01-setup/actions/skills/04-write-config.md": "eb7ecb812e8bdaaeba2e56c71c77bf8c14fce0a2c44311ff7985444617635dd5", + ".agents/skills/aidd-async-dev-01-setup/actions/skills/05-bootstrap-labels.md": "f53177ce1c58767f1bdfcfa3e72f7d4cc5e3d4fd782c35c3998815317be108b1" } } From 76b53307dbd916abe20b397485daad1d8fb664a6 Mon Sep 17 00:00:00 2001 From: Thierry Kwiatkowski Date: Wed, 29 Jul 2026 17:05:12 +0200 Subject: [PATCH 06/23] docs: mark part-1 (gemini flat build target) implemented All 5 phases done, success_condition (cd cli && pnpm typecheck && pnpm lint && pnpm test) green modulo 2 pre-existing environment- coupled auth test failures unrelated to this work (documented in the plan's Amendments). Master plan's Part 1 checkpoint checked, Part 2 (shared tree safety) unblocked. Co-Authored-By: Claude Sonnet 5 --- .../2026_07_27-511-gemini-cli-tool-master.md | 6 +++--- .../2026_07_27-511-gemini-cli-tool-part-1.md | 4 +++- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/aidd_docs/tasks/2026_07/2026_07_27_gemini-cli-build-target/2026_07_27-511-gemini-cli-tool-master.md b/aidd_docs/tasks/2026_07/2026_07_27_gemini-cli-build-target/2026_07_27-511-gemini-cli-tool-master.md index 18ad39709..89cf5fedd 100644 --- a/aidd_docs/tasks/2026_07/2026_07_27_gemini-cli-build-target/2026_07_27-511-gemini-cli-tool-master.md +++ b/aidd_docs/tasks/2026_07/2026_07_27_gemini-cli-build-target/2026_07_27-511-gemini-cli-tool-master.md @@ -50,8 +50,8 @@ Neither appears in the issue or the brainstorm, and both gate the stated success | # | Plan | File | Status | Validated | | --- | ----------------------- | ------------------------------------------------ | ------- | --------- | -| 1 | Build target | `./2026_07_27-511-gemini-cli-tool-part-1.md` | pending | [ ] | -| 2 | Shared tree safety | `./2026_07_27-511-gemini-cli-tool-part-2.md` | blocked | [ ] | +| 1 | Build target | `./2026_07_27-511-gemini-cli-tool-part-1.md` | done | [x] | +| 2 | Shared tree safety | `./2026_07_27-511-gemini-cli-tool-part-2.md` | pending | [ ] | | 3 | Registry citizen | `./2026_07_27-511-gemini-cli-tool-part-3.md` | blocked | [ ] | | 4 | Content and docs | `./2026_07_27-511-gemini-cli-tool-part-4.md` | blocked | [ ] | @@ -83,7 +83,7 @@ This supersedes one brainstorm decision. The brainstorm chose tool-neutral skill ## Validation Protocol 1. Complete Part 1, run its `success_condition` -2. [ ] Checkpoint 1: gemini archive builds, 9 existing golden cells byte-identical +2. [x] Checkpoint 1: gemini archive builds, 9 existing golden cells byte-identical 3. Unblock Part 2, run its `success_condition` 4. [ ] Checkpoint 2: shared-path deletion guarded, subset invariant green 5. Unblock Part 3, run its `success_condition` diff --git a/aidd_docs/tasks/2026_07/2026_07_27_gemini-cli-build-target/2026_07_27-511-gemini-cli-tool-part-1.md b/aidd_docs/tasks/2026_07/2026_07_27_gemini-cli-build-target/2026_07_27-511-gemini-cli-tool-part-1.md index d34b1131d..cf6424626 100644 --- a/aidd_docs/tasks/2026_07/2026_07_27_gemini-cli-build-target/2026_07_27-511-gemini-cli-tool-part-1.md +++ b/aidd_docs/tasks/2026_07/2026_07_27_gemini-cli-build-target/2026_07_27-511-gemini-cli-tool-part-1.md @@ -6,7 +6,7 @@ objective: "aidd framework build --target gemini --flat produces an archive a Ge success_condition: "cd cli && pnpm typecheck && pnpm lint && pnpm test" iteration: 0 created_at: "2026-07-27T22:44:41+02:00" -status: in-progress +status: implemented --- # Instruction: Gemini CLI flat build target @@ -254,6 +254,8 @@ flowchart TD πŸ€– gemini's `AiTool` capability intersection is `HasAgents & HasSkills & HasMcp & HasPlugins` β€” narrower than codex/opencode. Deliberately omitted: `hooks` (Claudeβ†’Gemini event-name translation has no expression point in the current `HooksCapability`/generic install pipeline β€” content passes through untransformed) and `settings` (the idempotent `context.fileName` array union needs custom merge logic; `SettingsCapability` only supports generic `MergeStrategy` enums or static content, not a custom merge function). `plugins` is `{ mode: "unsupported" }` (no marketplace, no native activation, per the master plan). None of this blocks this part's objective β€” `aidd framework build` never reads `AiTool.capabilities` (`FlatBuildStrategy`/`ToolBuildContract` are fully standalone) β€” so the gap is real install-mode functionality deferred to Part 3, not a stub masking Phase 1/4 work. +πŸ€– Rebasing this branch onto a `main` that had moved 76 commits ahead replayed it over three exhaustive registry guards that did not exist when the acceptance criteria above were verified, and `gemini` was in none of them. `FRAMEWORK_BUILD_TARGET_MODES` (`domain/models/framework-build.ts`) had replaced framework build's hardcoded target list, so `gemini` needed an entry there or `--target gemini` was rejected outright by the command. `tests/domain/tools/registry-conformance.unit.test.ts` arrived carrying its own side-effect registration list, which `gemini` had to join. That suite's marketplace-probe assertion fired on the mere presence of a plugins capability; `gemini` is the first and only tool to declare `mode: "unsupported"`, so the guard was narrowed to the modes that actually have a marketplace, rather than given a probe entry describing a marketplace format Gemini CLI does not have. Every acceptance criterion above was re-verified after the rebase against a bundle built in the same run β€” the e2e suite executes `dist/cli.js`, and a stale bundle silently reports on code that is not the code under test. Planned and carried out in `aidd_docs/tasks/2026_08/2026_08_12_gemini-branch-rebase-repair/`. + ## Log From 58a0daecc24d44d044a04bff1f9387ca364a3e82 Mon Sep 17 00:00:00 2001 From: Thierry Kwiatkowski Date: Wed, 12 Aug 2026 15:32:31 +0200 Subject: [PATCH 07/23] chore: review --- README.md | 2 + aidd_docs/memory/testing.md | 4 ++ .../review.md | 60 +++++++++++++++++++ 3 files changed, 66 insertions(+) create mode 100644 aidd_docs/tasks/2026_07/2026_07_27_gemini-cli-build-target/review.md diff --git a/README.md b/README.md index 62246e074..499a36d18 100644 --- a/README.md +++ b/README.md @@ -51,6 +51,8 @@ Why not just write your own commands? β†’ [FAQ](docs/FAQ.md#-why-aidd-instead-of **Marketplace** = installed and updated through your tool's plugin manager. **Flat** = files copied directly into your project, no plugin manager involved. Install steps per tool β†’ [Other tools](#other-tools). +**Gemini CLI**: the flat build target is implemented, but end-to-end validation on Gemini 3 Pro models is currently blocked by an upstream `gemini-cli` bug unrelated to AIDD β€” [google-gemini/gemini-cli#14437](https://github.com/google-gemini/gemini-cli/issues/14437) (missing `thought_signature` on chained tool calls). Use a Gemini 2.5 model to test in the meantime. + ## πŸ“¦ Install ### Claude Code diff --git a/aidd_docs/memory/testing.md b/aidd_docs/memory/testing.md index 648c2da2d..b41b633df 100644 --- a/aidd_docs/memory/testing.md +++ b/aidd_docs/memory/testing.md @@ -18,3 +18,7 @@ ## Mocking and Stubbing Not applicable: the framework has no runtime; all logic is markdown interpreted by an LLM. + +## Known Limitations + +- **Gemini CLI real-binary validation blocked on Gemini 3 Pro models**: activating any AIDD skill (`activate_skill`) followed by a second tool call in the same turn fails with `400 INVALID_ARGUMENT: Function call is missing a thought_signature`. Confirmed upstream `gemini-cli` bug β€” it fails to echo the model's `thoughtSignature` across chained function calls β€” not an AIDD defect ([google-gemini/gemini-cli#14437](https://github.com/google-gemini/gemini-cli/issues/14437), open, reproduced across many unrelated clients). Doesn't occur on Gemini 2.5 models. Workaround when smoke-testing the Gemini flat build target: run the session on a 2.5 model, or re-check once #14437 ships a fix. diff --git a/aidd_docs/tasks/2026_07/2026_07_27_gemini-cli-build-target/review.md b/aidd_docs/tasks/2026_07/2026_07_27_gemini-cli-build-target/review.md new file mode 100644 index 000000000..1490ef5e1 --- /dev/null +++ b/aidd_docs/tasks/2026_07/2026_07_27_gemini-cli-build-target/review.md @@ -0,0 +1,60 @@ +# Review: Gemini CLI flat build target (part 1 of 4) + +- **Verdict**: changes-requested +- **Diff**: `be83f251...ad7a12f4` +- **Axes run**: code, functional, relevancy +- **Date**: 2026_07_29 +- **Findings**: 0 critical, 1 warning, 1 minor + +## Phases + +### Phase 1 β€” Register the tool identity + +- [x] `pnpm typecheck` exits 0 with no `Record` exhaustiveness error β€” `cli/src/infrastructure/assets/asset-loader.ts:38` adds the mandatory `gemini` `CONFIG_ASSETS` entry; Log confirms 0 errors (`part-1.md:261`) +- [x] `pnpm test:unit` exits 0 β€” Log: 1413/1413 (`part-1.md:261`) +- [x] The nine existing golden cells are unchanged β€” `cli/tests/golden/snapshots/framework-build/golden.json` diff is purely additive (single hunk appending `gemini:flat`, no edits to any pre-existing key) + +### Phase 2 β€” Own the settings file + +- [x] Merging twice produces identical bytes β€” `cli/tests/domain/formats/gemini-settings-merge.unit.test.ts:2434` ("merging twice produces identical bytes (idempotent)") +- [x] A pre-existing user `context.fileName` array retains its entries and gains `AGENTS.md` β€” `gemini-settings-merge.unit.test.ts:2440` +- [x] A user-authored unrelated key in the settings file survives every merge β€” `gemini-settings-merge.unit.test.ts:2454` (seed merge); `gemini-settings-merge.unit.test.ts:2357` (hooks merge, `mcpServers`/`context` survive) +- [x] An unmapped hook event produces a warning and no output entry β€” `gemini-settings-merge.unit.test.ts:2396`, implemented at `cli/src/domain/formats/gemini-settings-merge.ts:1566` + +### Phase 3 β€” Add the plugin-exclusion mechanism + +- [x] The build use-case contains no tool-name literal β€” `cli/src/application/use-cases/framework/framework-build-use-case.ts:1236` (`buildAllPlugins`) dispatches only through `strategy.shouldBuildPlugin` +- [x] A grep for `if (tool === ` and `if (kind === "agents")` in both orchestrators returns nothing β€” verified directly against the working tree, zero matches +- [x] Skipping a plugin is reported on stderr, and no skip is silent β€” `framework-build-use-case.ts:1244` (`this.logger.warn`), asserted in `plugin-exclusion.integration.test.ts:2270` + +### Phase 4 β€” Declare the gemini flat contract + +- [x] All six artifact kinds are declared; none is omitted β€” `cli/src/application/use-cases/framework/strategies/tool-contracts.ts:1392-1422` (`skills`/`agents`/`mcp`/`hooks: supported:true`, `rules`/`commands: supported:false`) +- [x] No `gemini:marketplace` row exists, and `--target gemini` without `--flat` exits 1 β€” `cli/src/infrastructure/deps.ts` registers only `"gemini:flat"`; `commands/framework.ts:66-71` exits 1 when `createFrameworkBuildUseCase` returns `undefined`; asserted in `cli/tests/e2e/framework-build.e2e.test.ts:2573` (AC #7) +- [x] Skills, agents, MCP and hooks all land at the mapped paths in a real build β€” `flat-build-strategy.integration.test.ts:1903`, e2e AC #6 (`framework-build.e2e.test.ts:2533`) +- [x] `.gemini/settings.json` contains `mcpServers`, `hooks` and `context.fileName` simultaneously β€” `flat-build-strategy.integration.test.ts:1929`, e2e AC #6 + +### Phase 5 β€” Prove it against the real binary + +- [x] `gemini skills list --all` lists every published AIDD skill from `.agents/skills/` β€” self-reported, `part-1.md:264` (Log); not independently re-executed, see Verification +- [x] No agent file is rejected by the strict frontmatter schema β€” supported by the design at `tool-contracts.ts:1370` (`transformGeminiFlatAgent` rebuilds frontmatter to only `name`/`description`, per Amendments) and self-reported in `part-1.md:264` +- [x] The nine pre-existing golden keys are byte-identical to the pre-change baseline β€” same additive-only `golden.json` evidence as Phase 1 +- [x] `cd cli && pnpm typecheck && pnpm lint && pnpm test` exits 0 modulo the 2 documented pre-existing failures β€” self-reported, `part-1.md:264`, explained in `part-1.md:243` (Amendments) + +## Findings + +| Sev | Kind | Phase | Location | Issue | Fix | +| --- | ---- | ----- | -------- | ----- | --- | +| 🟑 warning | rot | 4 | `cli/src/domain/tools/ai/gemini.ts:20-32` | `skillNameFromPath` and `buildGeminiSkillFilePath` are copy-pasted verbatim from `cli/src/domain/tools/ai/codex.ts:163-173`, crossing the project's own DRY threshold ("Extract private helper when β‰₯2 callers share identical logic", `cli/.claude/rules/07-quality/7-clean-code.md`). Before this diff there was exactly one implementation (codex); this diff adds the second, identical one instead of extracting | Move `skillNameFromPath`/`buildXSkillFilePath` into a shared `domain/formats/` (or `domain/tools/ai/`) helper parametrized by prefix, imported by both `codex.ts` and `gemini.ts` | +| 🟒 minor | rot | 1 | `cli/src/domain/tools/ai/gemini.ts:20` | Module-private `AGENTS_SKILLS_PREFIX = ".agents/skills/"` duplicates the exact literal this same diff just extracted into the shared, exported `AGENTS_SKILLS_PREFIX` in `cli/src/domain/formats/flat-paths.ts:15`. The extraction (Amendments, `part-1.md:247`) was scoped to `tool-contracts.ts` only, leaving `gemini.ts` (and pre-existing `codex.ts:33`) with their own copies of the same string | Import the shared constant from `flat-paths.ts` in `gemini.ts` (and `codex.ts` while there) instead of re-declaring it | + +## Verification + +| Metric | Value | +| ------------- | -------------------------------------------------- | +| Verified | 100% (18/18 acceptance criteria checked) | +| Files checked | `cli/src/domain/formats/gemini-settings-merge.ts`, `cli/src/domain/tools/ai/gemini.ts`, `cli/src/application/use-cases/framework/strategies/tool-contracts.ts`, `cli/src/application/use-cases/framework/framework-build-use-case.ts`, `cli/src/application/use-cases/framework/strategies/{flat,marketplace}-build-strategy.ts`, `cli/src/application/use-cases/framework/strategies/build-output-strategy.ts`, `cli/src/domain/tools/build-contract.ts`, `cli/src/domain/models/{tool-ids,framework-build}.ts`, `cli/src/domain/formats/flat-paths.ts`, `cli/src/infrastructure/{deps,assets/asset-loader}.ts`, `cli/src/application/commands/{ai,framework}.ts`, `cli/src/application/use-cases/menu-use-case.ts`, `cli/biome.json`, `cli/package.json`, `.github/workflows/ci.yml`, `cli/tests/**` (all new/changed gemini-related suites), `cli/tests/golden/snapshots/framework-build/golden.json`, `aidd_docs/tasks/2026_07/2026_07_27_gemini-cli-build-target/2026_07_27-511-gemini-cli-tool-{master,part-1}.md` | +| Unchecked | none | +| Unplanned | none β€” every changed file traces to the plan's "Files to modify/create" list or a phase Log entry | + +Four Phase 5 criteria rest on the implementing agent's self-reported Log narrative (real `gemini` 0.52.0 binary run, real auth state) rather than an independent re-execution in this review, per this action's static-review-only constraint. The two claims checkable from the diff alone (golden-cell byte-identity, and the strict-frontmatter-safe transform design) are independently confirmed above. From 4371ec84430bade1decc8257873fc2917e6ef258 Mon Sep 17 00:00:00 2001 From: Thierry Kwiatkowski Date: Wed, 12 Aug 2026 16:22:45 +0200 Subject: [PATCH 08/23] docs(plan): record the gemini rebase repair MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The repair itself is folded into the two commits that opened the gaps, so this carries the plan and its phases only. The fold was done by rebuilding the branch with cherry-picks rather than the interactive rebase the plan names: interactive git is not available in the environment this ran in. Same end state, verified by diffing the rebuilt branch against a pre-fold backup β€” identical but for the two-line amendment in part-1. Co-Authored-By: Claude Opus 5 --- .../brainstorm.md | 28 +++++++ .../phase-1.md | 80 ++++++++++++++++++ .../phase-2.md | 81 +++++++++++++++++++ .../plan.md | 27 +++++++ 4 files changed, 216 insertions(+) create mode 100644 aidd_docs/tasks/2026_08/2026_08_12_gemini-branch-rebase-repair/brainstorm.md create mode 100644 aidd_docs/tasks/2026_08/2026_08_12_gemini-branch-rebase-repair/phase-1.md create mode 100644 aidd_docs/tasks/2026_08/2026_08_12_gemini-branch-rebase-repair/phase-2.md create mode 100644 aidd_docs/tasks/2026_08/2026_08_12_gemini-branch-rebase-repair/plan.md diff --git a/aidd_docs/tasks/2026_08/2026_08_12_gemini-branch-rebase-repair/brainstorm.md b/aidd_docs/tasks/2026_08/2026_08_12_gemini-branch-rebase-repair/brainstorm.md new file mode 100644 index 000000000..b088ad059 --- /dev/null +++ b/aidd_docs/tasks/2026_08/2026_08_12_gemini-branch-rebase-repair/brainstorm.md @@ -0,0 +1,28 @@ +# Brainstorm: repair the gemini branch after its rebase + +> Source: the branch `feat/511-gemini-flat-build-target`, rebased onto `main` on 2026-08-12. The work it carries is part 1 of `aidd_docs/tasks/2026_07/2026_07_27_gemini-cli-build-target/`, whose plan is already marked implemented. + +## Refined idea + +Bring the branch back to a working state after a rebase onto a `main` that had moved 76 commits ahead, by realigning how the `gemini` build target is declared onto the mechanism that replaced the old one during that interval, then obtain a binary verdict from the part-1 plan's own success condition (`cd cli && pnpm typecheck && pnpm lint && pnpm test`). The two auth-related failures already documented as environment-coupled in the part-1 amendments count as known noise, not as regressions. + +## What the rebase actually did + +The rebase itself completed and left a linear history: seven commits replayed, nothing behind `main`. One commit, `feat(cli): declare gemini's flat build contract`, was stopped and continued, so its conflict was resolved by hand. + +The breakage is semantic, not textual. Between the branch point and `main`, the pull request that derives framework build's supported targets from the build registry replaced the hardcoded target list with a table of target/mode pairs, `FRAMEWORK_BUILD_TARGET_MODES`, from which `SUPPORTED_BUILD_TARGETS` is now derived. The branch registers `gemini` through the old surface. The conflict resolution already migrated the command surface: `cli/src/application/commands/framework.ts` imports the derived constant and its `--target` help text lists `gemini`. What it did not do is add `gemini` to the table itself. + +Three consequences follow by construction, none of them observed by running anything: + +- `cli/tests/domain/tools/registry-conformance.unit.test.ts` asserts every registered AI tool has an entry in the table. `gemini` has been registered since the tool-id commit, so this guard fails. +- `cli/tests/infrastructure/framework-build-registry.unit.test.ts` asserts the `deps.ts` build registry matches the table exactly. The contract commit adds a `gemini:flat` row to `deps.ts`, so the two diverge. +- `framework.ts` rejects any target absent from the derived list, so `aidd framework build --target gemini --flat` would exit with `Unsupported target 'gemini'`, making the branch's whole feature unreachable in the real CLI. + +There is no choice to make between the old and new declaration mechanism: the old one no longer exists. The single uncommitted line adding `{ target: "gemini", mode: "flat" }` to the table is the migration, and as far as static reading goes, all of it. + +## Open assumptions and risks + +- That the one line is sufficient is unverified. Only the three guards above were traced; nothing else was searched for. Running the success condition is what settles it. +- The golden snapshot was rebaselined before the rebase, against `tests/fixtures/framework-real`. `main` has since changed framework content (the skill-contract rehoming, the QA and communication evaluations, the citation refactors). If that fixture tracks the real repository content, the nine pre-existing golden cells may have drifted legitimately rather than through this branch. The chosen verdict is binary, so this is only worth separating if the golden suite actually goes red. +- Where the fix lands in history is undecided: a new commit at the tip is simpler, folding it into the tool-id registration commit is what keeps the branch bisectable. To settle before committing. +- Parts 2 through 4 of the parent task stay pending or blocked and are out of scope here. diff --git a/aidd_docs/tasks/2026_08/2026_08_12_gemini-branch-rebase-repair/phase-1.md b/aidd_docs/tasks/2026_08/2026_08_12_gemini-branch-rebase-repair/phase-1.md new file mode 100644 index 000000000..4e3942a76 --- /dev/null +++ b/aidd_docs/tasks/2026_08/2026_08_12_gemini-branch-rebase-repair/phase-1.md @@ -0,0 +1,80 @@ +--- +status: done +--- + +# Instruction: close the registry conformance gap + +Main added exhaustive guards while the branch was away: every registered AI tool must appear in the build target/mode table, in the conformance suite's own registration list, and, when it declares a plugins capability, in the marketplace probe table. `gemini` was in none of them. The first two are already fixed in the working tree; the third is the open decision. + +## Architecture projection + +> Tree of the final files. βœ… create Β· ✏️ modify Β· ❌ delete + +```txt +. +└── cli/ + β”œβ”€β”€ src/domain/models/framework-build.ts ✏️ gemini:flat entry in FRAMEWORK_BUILD_TARGET_MODES (applied) + └── tests/domain/tools/ + └── registry-conformance.unit.test.ts ✏️ gemini side-effect import (applied) + narrow the marketplace-probe guard to plugin modes that have a marketplace +``` + +## User Journey + +```mermaid +--- +title: The marketplace-probe conformance guard, per registered tool +--- +flowchart TD + Tool["A registered AI tool"] + Declares{"Declares a plugins capability?"} + SkipA["Guard does not apply"] + Mode{"Plugins mode has a marketplace?"} + SkipB["Guard does not apply: nothing to detect"] + Probe{"MARKETPLACE_PROBES entry exists?"} + Fail["Fail: its native marketplace would never be detected"] + Pass["Conformant"] + + Tool --> Declares + Declares -. no .-> SkipA + Declares -- yes --> Mode + Mode -. "unsupported" .-> SkipB + Mode -- "native or flat" --> Probe + Probe -. no .-> Fail + Probe -- yes --> Pass +``` + +## Tasks to do + +### `1)` Narrow the marketplace-probe guard to the modes that have a marketplace + +> The guard must ask whether a marketplace exists, not merely whether the capability is declared. + +1. In `registry-conformance.unit.test.ts`, replace the `"plugins" in capabilities` presence check with one that also reads the capability's `mode`. +2. Exempt `mode: "unsupported"`, keep `native` and `flat` under the requirement. +3. Extend the failure message so it says which mode was expected to carry a probe entry, and keep it pointing at `domain/models/plugin-format.ts`. +4. Access the mode through the `PluginsCapability` public `readonly mode` field, without widening the test's `AiTool` typing to `any`. + +### `2)` Prove the guard still bites + +> An exemption that swallows the real case is worse than the gap it closes. + +1. Confirm the five pre-existing tools still run the assertion rather than skip it, `opencode` in particular, since it is the only non-native tool that does carry a probe entry. +2. Confirm the assertion still fails when a probe entry is removed, by temporary local mutation, reverted before finishing. + +### `3)` Run the suite against a freshly built binary + +> The e2e tests execute `dist/cli.js`. A stale bundle silently produces a verdict about code that is not the code under test. + +1. Build first and check the bundle's timestamp is from this run, not an earlier one. +2. Run typecheck, lint and the full suite. +3. Compare the failure set against the two failures part-1's amendments already document as coupled to the local `gh` session. + +## Test acceptance criteria + +| Task | Acceptance criteria | +| ---- | ------------------- | +| 1 | `gemini` passes the conformance suite without any entry being added to `MARKETPLACE_PROBES` | +| 2 | Removing `opencode`'s probe entry still fails the guard, so the exemption is scoped to the unsupported mode alone | +| 3 | `aidd framework build --target gemini --flat` succeeds and `--target gemini` alone exits 1, both against a bundle built in the same run | +| 3 | The whole suite passes except `auth status`, whose exit code depends on the machine's `gh` session and is documented as such | +| 3 | The ten-cell framework-build golden matrix passes, the nine pre-rebase cells included | diff --git a/aidd_docs/tasks/2026_08/2026_08_12_gemini-branch-rebase-repair/phase-2.md b/aidd_docs/tasks/2026_08/2026_08_12_gemini-branch-rebase-repair/phase-2.md new file mode 100644 index 000000000..27cd690b5 --- /dev/null +++ b/aidd_docs/tasks/2026_08/2026_08_12_gemini-branch-rebase-repair/phase-2.md @@ -0,0 +1,81 @@ +--- +status: done +--- + +# Instruction: land the repair in history + +The branch is seven commits of a plan whose part-1 document claims every acceptance criterion held. That claim was true before the rebase and false after it. This phase makes the history match the claim again. + +## Architecture projection + +> Tree of the final files. βœ… create Β· ✏️ modify Β· ❌ delete + +```txt +. +β”œβ”€β”€ aidd_docs/tasks/2026_07/2026_07_27_gemini-cli-build-target/ +β”‚ └── 2026_07_27-511-gemini-cli-tool-part-1.md ✏️ amendment recording what the rebase invalidated and how +└── cli/ ✏️ no new edits; phase 1's changes are redistributed across existing commits +``` + +## User Journey + +```mermaid +--- +title: Where each repair lands in the branch's history +--- +flowchart TD + C1["register gemini as a known AI tool id"] + C2["add gemini settings.json merge authority"] + C3["add plugin-exclusion mechanism"] + C4["declare gemini's flat build contract"] + C5["verify gemini against the real binary"] + C6["mark part-1 implemented"] + C7["chore: review"] + + FixA["gemini entry in the build target/mode table"] + FixB["gemini in the conformance suite's registration list"] + FixC["marketplace-probe guard exemption"] + Amend["part-1 amendment"] + + C1 --> C2 --> C3 --> C4 --> C5 --> C6 --> C7 + FixA -.->|"folded into"| C1 + FixB -.->|"folded into"| C1 + FixC -.->|"folded into"| C1 + Amend -.->|"folded into"| C6 +``` + +## Tasks to do + +### `1)` Fold each repair into the commit that opened its gap + +> All three gaps open the moment `gemini` becomes a registered tool id, so they belong there, not at the tip. + +1. Rebase interactively onto the merge base with main, editing the tool-id registration commit. +2. Apply the build target/mode entry, the conformance registration import, and the guard narrowing into that commit. +3. Leave the other six commits untouched, and confirm the branch still holds seven commits. + +### `2)` Record the amendment in part-1's plan + +> Part-1 is marked implemented on evidence gathered against a main that has since moved. The document has to say so. + +1. Add an amendment entry naming the three guards main introduced after part-1's verification run and what each required. +2. State that the golden re-verification was re-run after the rebase, and against a freshly built bundle. +3. Leave the acceptance criteria ticked, since they hold again; the amendment records the interruption, not a regression of scope. + +### `3)` Check the branch bisects clean + +> The point of folding rather than appending is that every commit stands on its own. Verify it rather than assume it. + +1. Run typecheck and the unit suite at the tool-id registration commit. +2. Run them again at the tip. +3. Where a middle commit fails for a reason the phase order makes unavoidable, record it rather than reshuffle the branch. + +## Test acceptance criteria + +| Task | Acceptance criteria | +| ---- | ------------------- | +| 1 | The working tree is clean and the branch carries no separate fixup commit | +| 1 | The branch still applies onto main without conflict, and its commit count is unchanged | +| 2 | Part-1's amendments name the three guards and how each was satisfied | +| 3 | The tool-id registration commit passes typecheck and the unit suite on its own | +| 3 | The tip passes typecheck, lint and the full suite, except the documented `gh`-session failure | diff --git a/aidd_docs/tasks/2026_08/2026_08_12_gemini-branch-rebase-repair/plan.md b/aidd_docs/tasks/2026_08/2026_08_12_gemini-branch-rebase-repair/plan.md new file mode 100644 index 000000000..5965d8015 --- /dev/null +++ b/aidd_docs/tasks/2026_08/2026_08_12_gemini-branch-rebase-repair/plan.md @@ -0,0 +1,27 @@ +--- +objective: "The rebased gemini branch passes typecheck, lint and the full test suite on top of main, with its repair landed as history that bisects clean." +status: in-progress +--- + +# Plan: repair the gemini branch after its rebase + +## Overview + +| Field | Value | +| ---------- | ----------------------------------------------------------------------------------------- | +| **Goal** | Realign the `gemini` tool with the exhaustive registry guards main added during the rebase window, then land the fix in history | +| **Source** | [`brainstorm.md`](./brainstorm.md), and the verification run recorded in it | + +## Phases + +| # | Phase | File | +| --- | --------------------------------- | ---------------------------- | +| 1 | Close the registry conformance gap | [`phase-1.md`](./phase-1.md) | +| 2 | Land the repair in history | [`phase-2.md`](./phase-2.md) | + +## Decisions + +| Decision | Why | +| -------- | --- | +| A tool declaring its plugins capability as `mode: "unsupported"` is exempt from the `MARKETPLACE_PROBES` conformance requirement | The guard exists so a tool with a native marketplace stays detectable. `gemini` has no marketplace at all, which is the master plan's binding decision, so the alternative reading of the guard would force a probe entry claiming aidd can detect a gemini marketplace format that does not exist | +| The repair is folded into the commits that introduced each gap, not appended at the branch tip | Both gaps make `aidd framework build --target gemini` unreachable from the commit that opens them, so appending would leave the branch red across most of its own range | From c5712bd747eeaeaf2c49affbc15d759e40329df7 Mon Sep 17 00:00:00 2001 From: Thierry Kwiatkowski Date: Wed, 12 Aug 2026 16:23:44 +0200 Subject: [PATCH 09/23] docs(plan): mark the gemini rebase repair implemented Co-Authored-By: Claude Opus 5 --- .../2026_08/2026_08_12_gemini-branch-rebase-repair/plan.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/aidd_docs/tasks/2026_08/2026_08_12_gemini-branch-rebase-repair/plan.md b/aidd_docs/tasks/2026_08/2026_08_12_gemini-branch-rebase-repair/plan.md index 5965d8015..1248a30d2 100644 --- a/aidd_docs/tasks/2026_08/2026_08_12_gemini-branch-rebase-repair/plan.md +++ b/aidd_docs/tasks/2026_08/2026_08_12_gemini-branch-rebase-repair/plan.md @@ -1,6 +1,6 @@ --- objective: "The rebased gemini branch passes typecheck, lint and the full test suite on top of main, with its repair landed as history that bisects clean." -status: in-progress +status: implemented --- # Plan: repair the gemini branch after its rebase From a0e9ab8f6c22dfafe4e390b47094a4fc27f1a076 Mon Sep 17 00:00:00 2001 From: Thierry Kwiatkowski Date: Wed, 12 Aug 2026 16:54:10 +0200 Subject: [PATCH 10/23] fix(cli): guard shared paths on every uninstall route Removing one owner of a co-owned tree destroyed files another owner still needed. Skills reach a project through the plugin path, so the plugin routes were the ones that mattered, and none of them was guarded: - plugin uninstall deleted every tracked file unconditionally - tool uninstall deleted all of the departing tool's plugin files with no guard at all - its shared-path computation built the retained set from the surviving tools' own files and merge files only, never their plugins, so even the route that looked guarded would delete a co-owned tree One function now answers "may this uninstall delete that file" for all three, spanning tool files, merge files and plugin files, with the departing claim expressed as a tool plus an optional plugin name so whole-tool and single-plugin removals share it. Each retained path is reported once, naming the owners that still claim it. Mutation-checked: neutralizing the guard fails 3 of the 5 new cases, the two that assert deletion staying green. Co-Authored-By: Claude Opus 5 --- .../2026_07_27-511-gemini-cli-tool-master.md | 2 +- .../2026_07_27-511-gemini-cli-tool-part-2.md | 18 ++- .../use-cases/uninstall/shared-path-guard.ts | 63 +++++++++ .../uninstall/uninstall-plugin-use-case.ts | 24 +++- .../uninstall/uninstall-tools-use-case.ts | 48 ++++--- .../use-cases/uninstall/uninstall-use-case.ts | 2 +- .../shared-path-guard.integration.test.ts | 133 ++++++++++++++++++ 7 files changed, 257 insertions(+), 33 deletions(-) create mode 100644 cli/src/application/use-cases/uninstall/shared-path-guard.ts create mode 100644 cli/tests/application/use-cases/uninstall/shared-path-guard.integration.test.ts diff --git a/aidd_docs/tasks/2026_07/2026_07_27_gemini-cli-build-target/2026_07_27-511-gemini-cli-tool-master.md b/aidd_docs/tasks/2026_07/2026_07_27_gemini-cli-build-target/2026_07_27-511-gemini-cli-tool-master.md index 89cf5fedd..61676f3fa 100644 --- a/aidd_docs/tasks/2026_07/2026_07_27_gemini-cli-build-target/2026_07_27-511-gemini-cli-tool-master.md +++ b/aidd_docs/tasks/2026_07/2026_07_27_gemini-cli-build-target/2026_07_27-511-gemini-cli-tool-master.md @@ -51,7 +51,7 @@ Neither appears in the issue or the brainstorm, and both gate the stated success | # | Plan | File | Status | Validated | | --- | ----------------------- | ------------------------------------------------ | ------- | --------- | | 1 | Build target | `./2026_07_27-511-gemini-cli-tool-part-1.md` | done | [x] | -| 2 | Shared tree safety | `./2026_07_27-511-gemini-cli-tool-part-2.md` | pending | [ ] | +| 2 | Shared tree safety | `./2026_07_27-511-gemini-cli-tool-part-2.md` | in-progress | [ ] | | 3 | Registry citizen | `./2026_07_27-511-gemini-cli-tool-part-3.md` | blocked | [ ] | | 4 | Content and docs | `./2026_07_27-511-gemini-cli-tool-part-4.md` | blocked | [ ] | diff --git a/aidd_docs/tasks/2026_07/2026_07_27_gemini-cli-build-target/2026_07_27-511-gemini-cli-tool-part-2.md b/aidd_docs/tasks/2026_07/2026_07_27_gemini-cli-build-target/2026_07_27-511-gemini-cli-tool-part-2.md index 1382cd492..dd54af692 100644 --- a/aidd_docs/tasks/2026_07/2026_07_27_gemini-cli-build-target/2026_07_27-511-gemini-cli-tool-part-2.md +++ b/aidd_docs/tasks/2026_07/2026_07_27_gemini-cli-build-target/2026_07_27-511-gemini-cli-tool-part-2.md @@ -125,10 +125,10 @@ flowchart TD #### Acceptance criteria -- [ ] Two owners of one path, removing one leaves the file on disk -- [ ] Removing the last owner deletes the file -- [ ] Every retained file is reported on stderr; no retention is silent -- [ ] The guard exists in exactly one place +- [x] Two owners of one path, removing one leaves the file on disk +- [x] Removing the last owner deletes the file +- [x] Every retained file is reported on stderr; no retention is silent +- [x] The guard exists in exactly one place ### Phase 2: Make co-ownership a first-class read @@ -184,10 +184,20 @@ flowchart TD +πŸ€– The projection's `uninstall-ide-use-case.ts:37-50` entry is stale and was dropped. `main` refactored IDE and AI tool removal onto one implementation (#553) while this plan sat unstarted, so that file is now 36 lines that delegate to `UninstallToolsUseCase`. It inherits the guard rather than needing its own. Every other line reference in the projection was re-checked and still holds, `manifest.ts:364-372` included. + +πŸ€– Two holes the plan does not name, both inside `uninstall-tools-use-case.ts`, and both in phase 1's scope by its own acceptance criterion that the guard exist in exactly one place. `removeAllPluginFiles` deleted every plugin file of the departing tool with no guard whatsoever, which is the same destruction the plan attributes to plugin uninstall alone. And `computeSharedPaths` built its retained set from the remaining tools' own files and merge files only, never their plugins, so even the path the plan calls "already guarded" would delete a tree a surviving tool's plugin still claims. Since skills reach a project through the plugin path, an owners view that skips plugins is the bug, not a subset of it. `computeRetainedPaths` therefore spans tool files, merge files and plugin files, and the departing claim is expressed as a `(toolId, pluginName | null)` pair so one function serves both tool uninstall and plugin uninstall. + +πŸ€– The guard returns a path-to-owners map rather than a set, because phase 1's acceptance criterion asks the warning to name the remaining owner. That is phase 2's owners view in application-layer form; phase 2 moves the derivation into the manifest model and rewires this function to consume it, as its task 3 already anticipates. + +πŸ€– `otherToolsOwnMergeFile` (`uninstall-tools-use-case.ts:214`) still reads the installed tool ids on its own. It answers a different question, whether a merge file may be deleted outright or must be stripped of this tool's entries, so it is not a second copy of the shared-path guard. Left alone in phase 1; a candidate for phase 2's accessor. + ## Log +- Phase 1: `application/use-cases/uninstall/shared-path-guard.ts` created as the single owner of "may this uninstall delete that file", spanning tool files, merge files and plugin files, keyed by a `(toolId, pluginName | null)` departing claim and returning a readonly path-to-owners map. Wired into all three deletion sites: the tool-file loop and `removeAllPluginFiles` in `uninstall-tools-use-case.ts` (its module-private `computeSharedPaths` deleted), and `deleteFiles` in `uninstall-plugin-use-case.ts`, which gains the `Logger` it needed to report a retention (single construction site, `uninstall-use-case.ts:35`). Every retained path emits one `warn` naming the surviving owners. New `tests/application/use-cases/uninstall/shared-path-guard.integration.test.ts`, 5 cases: co-owned file survives one owner leaving, solely-owned path still deleted, retention reported once and naming the owner, co-owned file deleted when the last owner goes, and co-owned file surviving a whole-tool uninstall. Mutation-checked: neutralizing `computeRetainedPaths` fails 3 of the 5, the two deletion assertions staying green as they should. Verified: `pnpm typecheck` (0 errors), `biome check` (clean, 2 pre-existing config infos from main's biome bump), full `pnpm test` 2195/2196 β€” the one failure is `auth status`, caused by an `AIDD_TOKEN` in the developer environment that the e2e sandbox does not scrub, unrelated to this work. + ## Validation flow demonstration 1. In a fresh temporary project, install AIDD for two tools that both claim the shared skills tree. diff --git a/cli/src/application/use-cases/uninstall/shared-path-guard.ts b/cli/src/application/use-cases/uninstall/shared-path-guard.ts new file mode 100644 index 000000000..4af74befb --- /dev/null +++ b/cli/src/application/use-cases/uninstall/shared-path-guard.ts @@ -0,0 +1,63 @@ +import type { Manifest } from "../../../domain/models/manifest.js"; +import type { ToolId } from "../../../domain/tools/registry.js"; + +/** + * One claim being released by an uninstall. A `null` plugin name means the whole tool + * leaves, plugins included. + */ +export interface DepartingClaim { + readonly toolId: ToolId; + readonly pluginName: string | null; +} + +/** Relative path to the tools that still claim it once the departing claims are released. */ +export type RetainedPaths = ReadonlyMap; + +/** + * The single answer to "may this uninstall delete that file?". + * + * A path is retained when any surviving owner still claims it. An owner claims a path + * through its own files, its merge files, or any file one of its plugins installed β€” + * skills reach a project through the plugin path, so an owners view that skips plugins + * lets one owner delete a tree another owner still needs. + */ +export function computeRetainedPaths( + manifest: Manifest, + departing: readonly DepartingClaim[] +): RetainedPaths { + const retained = new Map(); + for (const toolId of manifest.getInstalledToolIds()) { + if (departing.some((claim) => claim.toolId === toolId && claim.pluginName === null)) continue; + for (const path of ownedPaths(manifest, toolId, departingPluginsOf(departing, toolId))) { + const owners = retained.get(path); + if (owners === undefined) retained.set(path, [toolId]); + else if (!owners.includes(toolId)) owners.push(toolId); + } + } + return retained; +} + +function departingPluginsOf( + departing: readonly DepartingClaim[], + toolId: ToolId +): ReadonlySet { + const names = new Set(); + for (const claim of departing) { + if (claim.toolId === toolId && claim.pluginName !== null) names.add(claim.pluginName); + } + return names; +} + +function ownedPaths( + manifest: Manifest, + toolId: ToolId, + departingPlugins: ReadonlySet +): string[] { + const paths = manifest.getToolFiles(toolId).map((file) => file.relativePath); + paths.push(...manifest.getMergeFiles(toolId).map((merge) => merge.relativePath)); + for (const plugin of manifest.getPlugins(toolId)) { + if (departingPlugins.has(plugin.name)) continue; + paths.push(...plugin.files.keys()); + } + return paths; +} diff --git a/cli/src/application/use-cases/uninstall/uninstall-plugin-use-case.ts b/cli/src/application/use-cases/uninstall/uninstall-plugin-use-case.ts index 83e909327..9a8f24751 100644 --- a/cli/src/application/use-cases/uninstall/uninstall-plugin-use-case.ts +++ b/cli/src/application/use-cases/uninstall/uninstall-plugin-use-case.ts @@ -4,9 +4,11 @@ import type { Manifest } from "../../../domain/models/manifest.js"; import type { AiToolId } from "../../../domain/models/tool-ids.js"; import { AI_TOOL_IDS } from "../../../domain/models/tool-ids.js"; import type { FileWriter } from "../../../domain/ports/file-writer.js"; +import type { Logger } from "../../../domain/ports/logger.js"; import type { ManifestRepository } from "../../../domain/ports/manifest-repository.js"; import type { ToolId } from "../../../domain/tools/registry.js"; import { NoManifestError } from "../../errors.js"; +import { computeRetainedPaths, type RetainedPaths } from "./shared-path-guard.js"; export interface UninstallPluginOptions { pluginName: string; @@ -23,7 +25,8 @@ export interface UninstallPluginResult { export class UninstallPluginUseCase { constructor( private readonly fs: FileWriter, - private readonly manifestRepo: ManifestRepository + private readonly manifestRepo: ManifestRepository, + private readonly logger: Logger ) {} async execute(options: UninstallPluginOptions): Promise { @@ -31,7 +34,11 @@ export class UninstallPluginUseCase { const manifest = await this.manifestRepo.load(); if (manifest === null) throw new NoManifestError(); const scope = this.resolveToolScope(toolIds, manifest); - const results = await this.removeFromTools(pluginName, scope, projectRoot, manifest); + const retained = computeRetainedPaths( + manifest, + scope.map((toolId) => ({ toolId, pluginName })) + ); + const results = await this.removeFromTools(pluginName, scope, projectRoot, manifest, retained); if (results.length === 0) throw new PluginNotFoundError(pluginName); await this.manifestRepo.save(manifest); return results; @@ -46,13 +53,14 @@ export class UninstallPluginUseCase { pluginName: string, toolIds: AiToolId[], projectRoot: string, - manifest: Manifest + manifest: Manifest, + retained: RetainedPaths ): Promise { const results: UninstallPluginResult[] = []; for (const toolId of toolIds) { const plugin = manifest.getPlugins(toolId).find((p) => p.name === pluginName); if (plugin === undefined) continue; - const deletedFiles = await this.deleteFiles(plugin.files, projectRoot); + const deletedFiles = await this.deleteFiles(plugin.files, projectRoot, retained); manifest.removePlugin(toolId, pluginName); results.push({ toolId, fileCount: deletedFiles.length, deletedFiles }); } @@ -61,10 +69,16 @@ export class UninstallPluginUseCase { private async deleteFiles( files: ReadonlyMap, - projectRoot: string + projectRoot: string, + retained: RetainedPaths ): Promise { const deleted: string[] = []; for (const relativePath of files.keys()) { + const owners = retained.get(relativePath); + if (owners !== undefined) { + this.logger.warn(`Kept ${relativePath}: still installed for ${owners.join(", ")}`); + continue; + } const fullPath = join(projectRoot, relativePath); await this.fs.deleteFile(fullPath); await this.fs.deleteEmptyDirectories(dirname(fullPath)); diff --git a/cli/src/application/use-cases/uninstall/uninstall-tools-use-case.ts b/cli/src/application/use-cases/uninstall/uninstall-tools-use-case.ts index ede602dfc..173919374 100644 --- a/cli/src/application/use-cases/uninstall/uninstall-tools-use-case.ts +++ b/cli/src/application/use-cases/uninstall/uninstall-tools-use-case.ts @@ -9,6 +9,7 @@ import type { FileReader } from "../../../domain/ports/file-reader.js"; import type { FileWriter } from "../../../domain/ports/file-writer.js"; import type { Logger } from "../../../domain/ports/logger.js"; import { getToolConfig, isAiTool, type ToolId } from "../../../domain/tools/registry.js"; +import { computeRetainedPaths, type RetainedPaths } from "./shared-path-guard.js"; export interface UninstallToolsOptions { toolIds: ToolId[]; @@ -44,19 +45,22 @@ export class UninstallToolsUseCase { projectRoot: string ): Promise { this.logger.info(`Removing ${toolId} files...`); - const sharedPaths = this.computeSharedPaths(toolId, allToolIds, manifest); + const retained = computeRetainedPaths( + manifest, + allToolIds.map((id) => ({ toolId: id, pluginName: null })) + ); const mergeFilePaths = this.collectMergeFilePaths(toolId, manifest); const allPaths = this.collectToolPaths(toolId, manifest); const deletedFiles = await this.deleteToolFiles( toolId, allToolIds, allPaths, - sharedPaths, + retained, mergeFilePaths, manifest, projectRoot ); - await this.removeAllPluginFiles(toolId, manifest, projectRoot); + await this.removeAllPluginFiles(toolId, manifest, projectRoot, retained); manifest.removeTool(toolId); return { toolId, fileCount: deletedFiles.length, deletedFiles }; } @@ -65,14 +69,17 @@ export class UninstallToolsUseCase { toolId: ToolId, allToolIds: ToolId[], allPaths: string[], - sharedPaths: Set, + retained: RetainedPaths, mergeFilePaths: Set, manifest: Manifest, projectRoot: string ): Promise { const deleted: string[] = []; for (const relativePath of allPaths) { - if (sharedPaths.has(relativePath) && !mergeFilePaths.has(relativePath)) continue; + if (retained.has(relativePath) && !mergeFilePaths.has(relativePath)) { + this.warnRetained(relativePath, retained); + continue; + } if (mergeFilePaths.has(relativePath)) { const removed = await this.removeMergeFile( toolId, @@ -95,24 +102,35 @@ export class UninstallToolsUseCase { private async removeAllPluginFiles( toolId: ToolId, manifest: Manifest, - projectRoot: string + projectRoot: string, + retained: RetainedPaths ): Promise { for (const plugin of manifest.getPlugins(toolId)) { - await this.deletePluginFiles(plugin.files, projectRoot); + await this.deletePluginFiles(plugin.files, projectRoot, retained); } } private async deletePluginFiles( files: ReadonlyMap, - projectRoot: string + projectRoot: string, + retained: RetainedPaths ): Promise { for (const relativePath of files.keys()) { + if (retained.has(relativePath)) { + this.warnRetained(relativePath, retained); + continue; + } const fullPath = join(projectRoot, relativePath); await this.fs.deleteFile(fullPath); await this.fs.deleteEmptyDirectories(dirname(fullPath)); } } + private warnRetained(relativePath: string, retained: RetainedPaths): void { + const owners = retained.get(relativePath) ?? []; + this.logger.warn(`Kept ${relativePath}: still installed for ${owners.join(", ")}`); + } + private collectMergeFilePaths(toolId: ToolId, manifest: Manifest): Set { return new Set(manifest.getMergeFiles(toolId).map((m) => m.relativePath)); } @@ -197,18 +215,4 @@ export class UninstallToolsUseCase { .filter((id) => !uninstallingSet.has(id)) .some((id) => manifest.getMergeFiles(id).some((m) => m.relativePath === relativePath)); } - - private computeSharedPaths( - toolId: ToolId, - allToolIds: ToolId[], - manifest: Manifest - ): Set { - const remainingToolIds = manifest - .getInstalledToolIds() - .filter((id) => id !== toolId && !allToolIds.includes(id)); - return new Set([ - ...remainingToolIds.flatMap((id) => manifest.getToolFiles(id).map((f) => f.relativePath)), - ...remainingToolIds.flatMap((id) => manifest.getMergeFiles(id).map((m) => m.relativePath)), - ]); - } } diff --git a/cli/src/application/use-cases/uninstall/uninstall-use-case.ts b/cli/src/application/use-cases/uninstall/uninstall-use-case.ts index 45b04756b..a638fb9d8 100644 --- a/cli/src/application/use-cases/uninstall/uninstall-use-case.ts +++ b/cli/src/application/use-cases/uninstall/uninstall-use-case.ts @@ -32,7 +32,7 @@ export class UninstallUseCase { private readonly manifestRepo: ManifestRepository, logger: Logger ) { - this.pluginUninstall = new UninstallPluginUseCase(fs, manifestRepo); + this.pluginUninstall = new UninstallPluginUseCase(fs, manifestRepo, logger); this.toolsUninstall = new UninstallToolsUseCase(fs, logger); this.mcpExclusion = new UninstallMcpExclusionUseCase(fs, logger); } diff --git a/cli/tests/application/use-cases/uninstall/shared-path-guard.integration.test.ts b/cli/tests/application/use-cases/uninstall/shared-path-guard.integration.test.ts new file mode 100644 index 000000000..f2df8a253 --- /dev/null +++ b/cli/tests/application/use-cases/uninstall/shared-path-guard.integration.test.ts @@ -0,0 +1,133 @@ +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import "../../../../src/domain/tools/ai/codex.js"; +import "../../../../src/domain/tools/ai/gemini.js"; +import { UninstallUseCase } from "../../../../src/application/use-cases/uninstall/uninstall-use-case.js"; +import { Plugin } from "../../../../src/domain/models/plugin.js"; +import type { ToolId } from "../../../../src/domain/tools/registry.js"; +import { buildUnitDeps, initProject, installTool } from "../../../helpers/ports/build-unit-deps.js"; +import { CapturingLogger } from "../../../helpers/ports/capturing-logger.js"; + +const PROJECT_ROOT = "/test-project"; +const PLUGIN_NAME = "aidd-context"; + +/** Co-owned: both codex and gemini render the shared skills tree to the same path. */ +const SHARED_PATH = ".agents/skills/aidd-context/SKILL.md"; +/** Owned by codex alone, so it proves the guard does not retain everything. */ +const CODEX_ONLY_PATH = ".codex/agents/aidd-context.md"; + +type Deps = Awaited>; + +function buildPlugin(files: Record): Plugin { + return Plugin.fromJSON({ + name: PLUGIN_NAME, + source: { kind: "local", path: "/fixtures/aidd-context" }, + version: "1.0.0", + strict: false, + files, + }); +} + +/** Installs both tools, then claims the co-owned path from each of them through a plugin. */ +async function seedTwoOwners(deps: Deps): Promise { + await initProject(deps, PROJECT_ROOT); + await installTool(deps, PROJECT_ROOT, "codex" as ToolId); + await installTool(deps, PROJECT_ROOT, "gemini" as ToolId); + + const manifest = await deps.manifestRepo.load(); + if (manifest === null) throw new Error("manifest was not created by initProject"); + manifest.addPlugin( + "codex" as ToolId, + buildPlugin({ [SHARED_PATH]: "hash-shared", [CODEX_ONLY_PATH]: "hash-codex" }) + ); + manifest.addPlugin("gemini" as ToolId, buildPlugin({ [SHARED_PATH]: "hash-shared" })); + await deps.manifestRepo.save(manifest); + + await deps.fs.writeFile(join(PROJECT_ROOT, SHARED_PATH), "# shared skill\n"); + await deps.fs.writeFile(join(PROJECT_ROOT, CODEX_ONLY_PATH), "# codex agent\n"); +} + +function buildUninstall(deps: Deps, logger: CapturingLogger): UninstallUseCase { + return new UninstallUseCase(deps.fs, deps.manifestRepo, logger); +} + +describe("shared path guard", () => { + it("keeps a co-owned file when one of its two owners is removed", async () => { + const deps = await buildUnitDeps(PROJECT_ROOT); + await seedTwoOwners(deps); + + await buildUninstall(deps, new CapturingLogger()).execute({ + toolIds: ["codex" as ToolId], + projectRoot: PROJECT_ROOT, + mcpFilter: [], + pluginName: PLUGIN_NAME, + }); + + expect(deps.fs.has(join(PROJECT_ROOT, SHARED_PATH))).toBe(true); + }); + + it("deletes a path the departing owner holds alone", async () => { + const deps = await buildUnitDeps(PROJECT_ROOT); + await seedTwoOwners(deps); + + await buildUninstall(deps, new CapturingLogger()).execute({ + toolIds: ["codex" as ToolId], + projectRoot: PROJECT_ROOT, + mcpFilter: [], + pluginName: PLUGIN_NAME, + }); + + expect(deps.fs.has(join(PROJECT_ROOT, CODEX_ONLY_PATH))).toBe(false); + }); + + it("reports every retained file, naming the owner that still claims it", async () => { + const deps = await buildUnitDeps(PROJECT_ROOT); + await seedTwoOwners(deps); + const logger = new CapturingLogger(); + + await buildUninstall(deps, logger).execute({ + toolIds: ["codex" as ToolId], + projectRoot: PROJECT_ROOT, + mcpFilter: [], + pluginName: PLUGIN_NAME, + }); + + const retentions = logger.warnMessages.filter((m) => m.includes(SHARED_PATH)); + expect(retentions).toHaveLength(1); + expect(retentions[0]).toContain("gemini"); + }); + + it("deletes the co-owned file once its last owner is removed", async () => { + const deps = await buildUnitDeps(PROJECT_ROOT); + await seedTwoOwners(deps); + const uninstall = buildUninstall(deps, new CapturingLogger()); + + await uninstall.execute({ + toolIds: ["codex" as ToolId], + projectRoot: PROJECT_ROOT, + mcpFilter: [], + pluginName: PLUGIN_NAME, + }); + await uninstall.execute({ + toolIds: ["gemini" as ToolId], + projectRoot: PROJECT_ROOT, + mcpFilter: [], + pluginName: PLUGIN_NAME, + }); + + expect(deps.fs.has(join(PROJECT_ROOT, SHARED_PATH))).toBe(false); + }); + + it("keeps a plugin-owned path when the tool that co-owns it is uninstalled whole", async () => { + const deps = await buildUnitDeps(PROJECT_ROOT); + await seedTwoOwners(deps); + + await buildUninstall(deps, new CapturingLogger()).execute({ + toolIds: ["codex" as ToolId], + projectRoot: PROJECT_ROOT, + mcpFilter: [], + }); + + expect(deps.fs.has(join(PROJECT_ROOT, SHARED_PATH))).toBe(true); + }); +}); From 5c2c8fee546db55c15481b73a6cbac8b69bcb6a6 Mon Sep 17 00:00:00 2001 From: Thierry Kwiatkowski Date: Wed, 12 Aug 2026 16:57:46 +0200 Subject: [PATCH 11/23] feat(cli): make path co-ownership readable from the manifest The manifest could already express two tools claiming one path; nothing could read it that way. Every consumer walked tool-and-path pairs, which is how one owner came to delete a tree another still needed. getPathOwners() derives the inverse view on read: path to owners, each owner carrying how it claims the path (the tool itself, a merge file, or one of its plugins) and its hash. Hashes travel per owner so a divergence between two owners of one path stays visible instead of collapsing into a single entry. No schema change. The version stays 6 and no migration is added: co-ownership was always representable, only unreadable. The uninstall guard now reads this instead of walking tools, merge files and plugins itself, leaving the departing-claim match as the only logic in the application layer. Co-Authored-By: Claude Opus 5 --- .../2026_07_27-511-gemini-cli-tool-part-2.md | 7 +- .../use-cases/uninstall/shared-path-guard.ts | 51 +++---- cli/src/domain/models/manifest.ts | 71 ++++++++++ .../models/manifest-path-owners.unit.test.ts | 127 ++++++++++++++++++ 4 files changed, 219 insertions(+), 37 deletions(-) create mode 100644 cli/tests/domain/models/manifest-path-owners.unit.test.ts diff --git a/aidd_docs/tasks/2026_07/2026_07_27_gemini-cli-build-target/2026_07_27-511-gemini-cli-tool-part-2.md b/aidd_docs/tasks/2026_07/2026_07_27_gemini-cli-build-target/2026_07_27-511-gemini-cli-tool-part-2.md index dd54af692..3a8ffc08c 100644 --- a/aidd_docs/tasks/2026_07/2026_07_27_gemini-cli-build-target/2026_07_27-511-gemini-cli-tool-part-2.md +++ b/aidd_docs/tasks/2026_07/2026_07_27_gemini-cli-build-target/2026_07_27-511-gemini-cli-tool-part-2.md @@ -142,9 +142,9 @@ flowchart TD #### Acceptance criteria -- [ ] The manifest version is unchanged and no migration is added -- [ ] The accessor returns a readonly structure -- [ ] Divergent hashes between owners are observable through the accessor rather than hidden +- [x] The manifest version is unchanged and no migration is added +- [x] The accessor returns a readonly structure +- [x] Divergent hashes between owners are observable through the accessor rather than hidden ### Phase 3: Make the shared tree visible @@ -197,6 +197,7 @@ flowchart TD - Phase 1: `application/use-cases/uninstall/shared-path-guard.ts` created as the single owner of "may this uninstall delete that file", spanning tool files, merge files and plugin files, keyed by a `(toolId, pluginName | null)` departing claim and returning a readonly path-to-owners map. Wired into all three deletion sites: the tool-file loop and `removeAllPluginFiles` in `uninstall-tools-use-case.ts` (its module-private `computeSharedPaths` deleted), and `deleteFiles` in `uninstall-plugin-use-case.ts`, which gains the `Logger` it needed to report a retention (single construction site, `uninstall-use-case.ts:35`). Every retained path emits one `warn` naming the surviving owners. New `tests/application/use-cases/uninstall/shared-path-guard.integration.test.ts`, 5 cases: co-owned file survives one owner leaving, solely-owned path still deleted, retention reported once and naming the owner, co-owned file deleted when the last owner goes, and co-owned file surviving a whole-tool uninstall. Mutation-checked: neutralizing `computeRetainedPaths` fails 3 of the 5, the two deletion assertions staying green as they should. Verified: `pnpm typecheck` (0 errors), `biome check` (clean, 2 pre-existing config infos from main's biome bump), full `pnpm test` 2195/2196 β€” the one failure is `auth status`, caused by an `AIDD_TOKEN` in the developer environment that the e2e sandbox does not scrub, unrelated to this work. +- Phase 2: `Manifest.getPathOwners()` added, returning a readonly path-to-owners map derived on read. An owner carries its tool id, how it claims the path (`tool`, `merge` or `plugin`), the plugin name when one applies, and the hash β€” `null` for a merge file, which tracks entries rather than bytes, so a divergence between two owners of one path stays visible rather than collapsing into a single entry. No schema change: `MANIFEST_VERSION` stays 6 and no migration was added, verified by diffing the model. `computeRetainedPaths` now reads the accessor instead of walking tools, merge files and plugins itself, so the departing-claim match is the only logic left in the application layer. New `tests/domain/models/manifest-path-owners.unit.test.ts`, 8 cases: single owner, two tools on one path, a plugin owning in its own right, a tool and its own plugin on the same path, zero owners, divergent hashes, the merge file's null hash, and an owner disappearing when its claim is released. Verified: `pnpm typecheck` (0 errors), `biome check` (clean), full `pnpm test` 2203/2204, the one failure being the same environment-coupled `auth status` case. ## Validation flow demonstration diff --git a/cli/src/application/use-cases/uninstall/shared-path-guard.ts b/cli/src/application/use-cases/uninstall/shared-path-guard.ts index 4af74befb..1f4c15f88 100644 --- a/cli/src/application/use-cases/uninstall/shared-path-guard.ts +++ b/cli/src/application/use-cases/uninstall/shared-path-guard.ts @@ -1,4 +1,4 @@ -import type { Manifest } from "../../../domain/models/manifest.js"; +import type { Manifest, PathOwner } from "../../../domain/models/manifest.js"; import type { ToolId } from "../../../domain/tools/registry.js"; /** @@ -16,48 +16,31 @@ export type RetainedPaths = ReadonlyMap; /** * The single answer to "may this uninstall delete that file?". * - * A path is retained when any surviving owner still claims it. An owner claims a path - * through its own files, its merge files, or any file one of its plugins installed β€” - * skills reach a project through the plugin path, so an owners view that skips plugins - * lets one owner delete a tree another owner still needs. + * A path is retained when any owner survives the uninstall. Ownership is read from the + * manifest's own path-to-owners view, so tool files, merge files and plugin-installed files + * all count: skills reach a project through the plugin path, and an owners view that skips + * plugins lets one owner delete a tree another owner still needs. */ export function computeRetainedPaths( manifest: Manifest, departing: readonly DepartingClaim[] ): RetainedPaths { const retained = new Map(); - for (const toolId of manifest.getInstalledToolIds()) { - if (departing.some((claim) => claim.toolId === toolId && claim.pluginName === null)) continue; - for (const path of ownedPaths(manifest, toolId, departingPluginsOf(departing, toolId))) { - const owners = retained.get(path); - if (owners === undefined) retained.set(path, [toolId]); - else if (!owners.includes(toolId)) owners.push(toolId); + for (const [path, owners] of manifest.getPathOwners()) { + for (const owner of owners) { + if (isDeparting(owner, departing)) continue; + const survivors = retained.get(path); + if (survivors === undefined) retained.set(path, [owner.toolId]); + else if (!survivors.includes(owner.toolId)) survivors.push(owner.toolId); } } return retained; } -function departingPluginsOf( - departing: readonly DepartingClaim[], - toolId: ToolId -): ReadonlySet { - const names = new Set(); - for (const claim of departing) { - if (claim.toolId === toolId && claim.pluginName !== null) names.add(claim.pluginName); - } - return names; -} - -function ownedPaths( - manifest: Manifest, - toolId: ToolId, - departingPlugins: ReadonlySet -): string[] { - const paths = manifest.getToolFiles(toolId).map((file) => file.relativePath); - paths.push(...manifest.getMergeFiles(toolId).map((merge) => merge.relativePath)); - for (const plugin of manifest.getPlugins(toolId)) { - if (departingPlugins.has(plugin.name)) continue; - paths.push(...plugin.files.keys()); - } - return paths; +function isDeparting(owner: PathOwner, departing: readonly DepartingClaim[]): boolean { + return departing.some( + (claim) => + claim.toolId === owner.toolId && + (claim.pluginName === null || claim.pluginName === owner.pluginName) + ); } diff --git a/cli/src/domain/models/manifest.ts b/cli/src/domain/models/manifest.ts index bab13b1e7..c618a96fa 100644 --- a/cli/src/domain/models/manifest.ts +++ b/cli/src/domain/models/manifest.ts @@ -40,6 +40,22 @@ interface PluginsEntry { readonly files: readonly TrackedFile[]; } +/** How one tool comes to claim a tracked path. */ +export type PathOwnerKind = "tool" | "merge" | "plugin"; + +/** One tool's claim on one tracked path. */ +export interface PathOwner { + readonly toolId: ToolId; + readonly kind: PathOwnerKind; + /** The plugin that installed the file, or null when the tool claims it directly. */ + readonly pluginName: string | null; + /** Hash of the whole file, or null for a merge file, which tracks entries rather than bytes. */ + readonly hash: string | null; +} + +/** Every tracked path with the owners that claim it. */ +export type PathOwners = ReadonlyMap; + interface ToolEntry { readonly toolId: ToolId; readonly version: string; @@ -361,6 +377,55 @@ export class Manifest { return this._tools.get(toolId)?.version; } + /** + * Every tracked path with the owners that claim it, derived on read. No schema change: + * co-ownership is already expressible, it was simply unreadable. + * + * A path can be claimed by several tools, and by a tool's plugins as well as by the tool + * itself. Consumers that walk tool-and-path pairs cannot see that, which is how one owner + * came to delete a tree another still needed. Hashes travel per owner, so a divergence + * between two owners of one path stays visible instead of collapsing into one entry. + */ + getPathOwners(): PathOwners { + const owners = new Map(); + for (const entry of this._tools.values()) this.collectPathOwners(owners, entry); + return owners; + } + + private collectPathOwners(owners: Map, entry: ToolEntry): void { + const toolId = entry.toolId; + for (const file of entry.files) { + addPathOwner(owners, file.relativePath, { + toolId, + kind: "tool", + pluginName: null, + hash: file.hash.value, + }); + } + for (const merge of entry.mergeFiles) { + addPathOwner(owners, merge.relativePath, { + toolId, + kind: "merge", + pluginName: null, + hash: null, + }); + } + this.collectPluginOwners(owners, entry); + } + + private collectPluginOwners(owners: Map, entry: ToolEntry): void { + for (const plugin of entry.plugins) { + for (const [relativePath, hash] of plugin.files) { + addPathOwner(owners, relativePath, { + toolId: entry.toolId, + kind: "plugin", + pluginName: plugin.name, + hash, + }); + } + } + } + getInstalledDirectories(): Set { const dirs = new Set(); for (const entry of this._tools.values()) { @@ -527,3 +592,9 @@ export class Manifest { return data.map((p) => Plugin.fromJSON(p)); } } + +function addPathOwner(owners: Map, path: string, owner: PathOwner): void { + const existing = owners.get(path); + if (existing === undefined) owners.set(path, [owner]); + else existing.push(owner); +} diff --git a/cli/tests/domain/models/manifest-path-owners.unit.test.ts b/cli/tests/domain/models/manifest-path-owners.unit.test.ts new file mode 100644 index 000000000..d6157f059 --- /dev/null +++ b/cli/tests/domain/models/manifest-path-owners.unit.test.ts @@ -0,0 +1,127 @@ +import { describe, expect, it } from "vitest"; +import { FileHash, InstallationFile } from "../../../src/domain/models/file.js"; +import { Manifest, type PathOwner } from "../../../src/domain/models/manifest.js"; +import { Plugin } from "../../../src/domain/models/plugin.js"; +import type { ToolId } from "../../../src/domain/models/tool-ids.js"; + +const SHARED_PATH = ".agents/skills/aidd-context/SKILL.md"; +const HASH_A = "0123456789abcdef0123456789abcdef"; +const HASH_B = "fedcba9876543210fedcba9876543210"; + +function trackedFile(relativePath: string, hash: string): InstallationFile { + return new InstallationFile({ relativePath, content: "", hash: new FileHash(hash) }); +} + +function pluginClaiming(name: string, files: Record): Plugin { + return Plugin.fromJSON({ + name, + source: { kind: "local", path: "/fixtures/plugin" }, + version: "1.0.0", + strict: false, + files, + }); +} + +function ownersOf(manifest: Manifest, path: string): readonly PathOwner[] { + return manifest.getPathOwners().get(path) ?? []; +} + +describe("Manifest.getPathOwners", () => { + it("reports the single owner of a path claimed once", () => { + const manifest = Manifest.create(); + manifest.addTool("codex" as ToolId, "1.0.0", [trackedFile(SHARED_PATH, HASH_A)]); + + const owners = ownersOf(manifest, SHARED_PATH); + + expect(owners).toHaveLength(1); + expect(owners[0]).toMatchObject({ toolId: "codex", kind: "tool", pluginName: null }); + }); + + it("reports both owners of a path two tools claim", () => { + const manifest = Manifest.create(); + manifest.addTool("codex" as ToolId, "1.0.0", [trackedFile(SHARED_PATH, HASH_A)]); + manifest.addTool("gemini" as ToolId, "1.0.0", [trackedFile(SHARED_PATH, HASH_A)]); + + expect(ownersOf(manifest, SHARED_PATH).map((o) => o.toolId)).toEqual(["codex", "gemini"]); + }); + + it("counts a plugin as an owner in its own right, naming the plugin", () => { + const manifest = Manifest.create(); + manifest.addTool("codex" as ToolId, "1.0.0", []); + manifest.addPlugin( + "codex" as ToolId, + pluginClaiming("aidd-context", { [SHARED_PATH]: HASH_A }) + ); + + const owners = ownersOf(manifest, SHARED_PATH); + + expect(owners).toHaveLength(1); + expect(owners[0]).toMatchObject({ + toolId: "codex", + kind: "plugin", + pluginName: "aidd-context", + }); + }); + + it("distinguishes a tool's own claim from its plugin's claim on the same path", () => { + const manifest = Manifest.create(); + manifest.addTool("codex" as ToolId, "1.0.0", [trackedFile(SHARED_PATH, HASH_A)]); + manifest.addPlugin( + "codex" as ToolId, + pluginClaiming("aidd-context", { [SHARED_PATH]: HASH_A }) + ); + + expect(ownersOf(manifest, SHARED_PATH).map((o) => o.kind)).toEqual(["tool", "plugin"]); + }); + + it("returns no owners for a path nobody claims", () => { + const manifest = Manifest.create(); + manifest.addTool("codex" as ToolId, "1.0.0", [trackedFile(SHARED_PATH, HASH_A)]); + + expect(manifest.getPathOwners().has(".claude/never-installed.md")).toBe(false); + expect(ownersOf(manifest, ".claude/never-installed.md")).toEqual([]); + }); + + it("keeps divergent hashes visible instead of collapsing the owners into one", () => { + const manifest = Manifest.create(); + manifest.addTool("codex" as ToolId, "1.0.0", [trackedFile(SHARED_PATH, HASH_A)]); + manifest.addTool("gemini" as ToolId, "1.0.0", [trackedFile(SHARED_PATH, HASH_B)]); + + const hashes = new Set(ownersOf(manifest, SHARED_PATH).map((o) => o.hash)); + + expect(hashes).toEqual(new Set([HASH_A, HASH_B])); + }); + + it("carries no hash for a merge file, which tracks entries rather than bytes", () => { + const manifest = Manifest.create(); + manifest.addTool( + "vscode" as ToolId, + "1.0.0", + [], + [{ relativePath: ".vscode/settings.json", sectionKey: null, entries: {} }] + ); + + const owners = ownersOf(manifest, ".vscode/settings.json"); + + expect(owners).toHaveLength(1); + expect(owners[0]).toMatchObject({ kind: "merge", hash: null }); + }); + + it("drops an owner as soon as its claim is released", () => { + const manifest = Manifest.create(); + manifest.addTool("codex" as ToolId, "1.0.0", []); + manifest.addTool("gemini" as ToolId, "1.0.0", []); + manifest.addPlugin( + "codex" as ToolId, + pluginClaiming("aidd-context", { [SHARED_PATH]: HASH_A }) + ); + manifest.addPlugin( + "gemini" as ToolId, + pluginClaiming("aidd-context", { [SHARED_PATH]: HASH_A }) + ); + + manifest.removePlugin("codex" as ToolId, "aidd-context"); + + expect(ownersOf(manifest, SHARED_PATH).map((o) => o.toolId)).toEqual(["gemini"]); + }); +}); From 05d1ea69af520f86bc98167c2bb891b3d7085049 Mon Sep 17 00:00:00 2001 From: Thierry Kwiatkowski Date: Wed, 12 Aug 2026 17:03:17 +0200 Subject: [PATCH 12/23] fix(cli): let doctor and status see a co-owned tree Both reported on a surface they could not see. They walked tool-and-path pairs and each tool's own directory, so the shared skills tree, which belongs to no single tool and arrives through plugins, was either invisible or counted twice. - a co-owned file now yields one issue, not one per owner - owners that expect different bytes for one path are reported on their own: the file cannot satisfy both, and restoring it will not settle the disagreement - a directory claimed only through plugins no longer looks untracked - a shared tree abandoned by its last owner is reported orphaned, which the per-tool-directory check structurally could not do - status scans every directory a tool actually claims files in The golden command-matrix baseline did not move, so nothing was re-baselined. Mutation-checked against the previous source: 4 of the 6 new doctor cases fail there, the 2 survivors being negative assertions. Co-Authored-By: Claude Opus 5 --- .../2026_07_27-511-gemini-cli-tool-part-2.md | 9 +- .../doctor/doctor-layout-use-case.ts | 28 +++- .../doctor/doctor-merge-files-use-case.ts | 10 +- .../doctor/doctor-tracked-files-use-case.ts | 81 +++++++---- .../application/use-cases/status-use-case.ts | 21 ++- cli/src/domain/models/manifest.ts | 11 +- .../shared-tree-visibility.unit.test.ts | 132 ++++++++++++++++++ .../use-cases/status-use-case.unit.test.ts | 34 +++++ 8 files changed, 287 insertions(+), 39 deletions(-) create mode 100644 cli/tests/application/use-cases/doctor/shared-tree-visibility.unit.test.ts diff --git a/aidd_docs/tasks/2026_07/2026_07_27_gemini-cli-build-target/2026_07_27-511-gemini-cli-tool-part-2.md b/aidd_docs/tasks/2026_07/2026_07_27_gemini-cli-build-target/2026_07_27-511-gemini-cli-tool-part-2.md index 3a8ffc08c..2f2bf4710 100644 --- a/aidd_docs/tasks/2026_07/2026_07_27_gemini-cli-build-target/2026_07_27-511-gemini-cli-tool-part-2.md +++ b/aidd_docs/tasks/2026_07/2026_07_27_gemini-cli-build-target/2026_07_27-511-gemini-cli-tool-part-2.md @@ -160,10 +160,10 @@ flowchart TD #### Acceptance criteria -- [ ] One missing co-owned file produces exactly one issue -- [ ] An abandoned shared tree is reported as orphaned -- [ ] A hash divergence between two owners is reported, distinctly from a missing file -- [ ] Golden baseline stdout changes are reviewed and justified, never blind-updated +- [x] One missing co-owned file produces exactly one issue +- [x] An abandoned shared tree is reported as orphaned +- [x] A hash divergence between two owners is reported, distinctly from a missing file +- [x] Golden baseline stdout changes are reviewed and justified, never blind-updated ### Phase 4: Lock the rendering invariant @@ -198,6 +198,7 @@ flowchart TD - Phase 1: `application/use-cases/uninstall/shared-path-guard.ts` created as the single owner of "may this uninstall delete that file", spanning tool files, merge files and plugin files, keyed by a `(toolId, pluginName | null)` departing claim and returning a readonly path-to-owners map. Wired into all three deletion sites: the tool-file loop and `removeAllPluginFiles` in `uninstall-tools-use-case.ts` (its module-private `computeSharedPaths` deleted), and `deleteFiles` in `uninstall-plugin-use-case.ts`, which gains the `Logger` it needed to report a retention (single construction site, `uninstall-use-case.ts:35`). Every retained path emits one `warn` naming the surviving owners. New `tests/application/use-cases/uninstall/shared-path-guard.integration.test.ts`, 5 cases: co-owned file survives one owner leaving, solely-owned path still deleted, retention reported once and naming the owner, co-owned file deleted when the last owner goes, and co-owned file surviving a whole-tool uninstall. Mutation-checked: neutralizing `computeRetainedPaths` fails 3 of the 5, the two deletion assertions staying green as they should. Verified: `pnpm typecheck` (0 errors), `biome check` (clean, 2 pre-existing config infos from main's biome bump), full `pnpm test` 2195/2196 β€” the one failure is `auth status`, caused by an `AIDD_TOKEN` in the developer environment that the e2e sandbox does not scrub, unrelated to this work. - Phase 2: `Manifest.getPathOwners()` added, returning a readonly path-to-owners map derived on read. An owner carries its tool id, how it claims the path (`tool`, `merge` or `plugin`), the plugin name when one applies, and the hash β€” `null` for a merge file, which tracks entries rather than bytes, so a divergence between two owners of one path stays visible rather than collapsing into a single entry. No schema change: `MANIFEST_VERSION` stays 6 and no migration was added, verified by diffing the model. `computeRetainedPaths` now reads the accessor instead of walking tools, merge files and plugins itself, so the departing-claim match is the only logic left in the application layer. New `tests/domain/models/manifest-path-owners.unit.test.ts`, 8 cases: single owner, two tools on one path, a plugin owning in its own right, a tool and its own plugin on the same path, zero owners, divergent hashes, the merge file's null hash, and an owner disappearing when its claim is released. Verified: `pnpm typecheck` (0 errors), `biome check` (clean), full `pnpm test` 2203/2204, the one failure being the same environment-coupled `auth status` case. +- Phase 3: doctor and status now read the owners view instead of walking tools. `DoctorTrackedFilesUseCase` checks each path once rather than once per owner, so a co-owned file yields one missing issue and one modified issue instead of two, and a genuine disagreement between owners is reported on its own (`Owners disagree on `, severity error), since restoring the file cannot satisfy both. `DoctorMergeFilesUseCase` deduplicates only the missing-file issue, keys staying per-owner because each tool tracks the entries it wrote. `Manifest.getInstalledDirectories()` derives from every owner rather than tool files alone, so a directory claimed only through plugins stops looking untracked. `DoctorLayoutUseCase` gained a shared-tree orphan check: the existing one walks each registered tool's own directory and structurally cannot see `.agents/skills/`, which belongs to no single tool. `StatusUseCase` scans the tool's own directory plus every other top-level directory it actually claims files in. New `tests/application/use-cases/doctor/shared-tree-visibility.unit.test.ts` (6 cases) and one status case. Mutation-checked by stashing the source and re-running: 4 of the 6 doctor cases fail against the previous code, the 2 that survive being the negative assertions that must hold either way. The golden command-matrix baseline did not move, so nothing needed re-baselining and nothing was blind-updated. Verified: `pnpm typecheck` (0 errors), `biome check` (clean), unit and integration 2074/2074, e2e 129/130 with the same environment-coupled `auth status` failure. ## Validation flow demonstration diff --git a/cli/src/application/use-cases/doctor/doctor-layout-use-case.ts b/cli/src/application/use-cases/doctor/doctor-layout-use-case.ts index 7cc2d3b1e..5b9a1472f 100644 --- a/cli/src/application/use-cases/doctor/doctor-layout-use-case.ts +++ b/cli/src/application/use-cases/doctor/doctor-layout-use-case.ts @@ -1,3 +1,5 @@ +import { join } from "node:path"; +import { AGENTS_SKILLS_PREFIX } from "../../../domain/formats/flat-paths.js"; import type { DoctorIssue } from "../../../domain/models/doctor.js"; import type { Manifest } from "../../../domain/models/manifest.js"; import type { FileReader } from "../../../domain/ports/file-reader.js"; @@ -18,8 +20,32 @@ export class DoctorLayoutUseCase { async execute(options: DoctorLayoutOptions): Promise { const { manifest, projectRoot } = options; const orphanIssues = await this.checkOrphanedDirectories(manifest, projectRoot); + const sharedTreeIssues = await this.checkOrphanedSharedTree(manifest, projectRoot); const authIssues = await this.checkAuth(); - return [...orphanIssues, ...authIssues]; + return [...orphanIssues, ...sharedTreeIssues, ...authIssues]; + } + + /** + * The shared skills tree belongs to no single tool's directory, so the orphan check above + * cannot see it: it walks each registered tool's own directory. Left behind by the last + * owner's removal, it would sit on disk forever, unreported. + */ + private async checkOrphanedSharedTree( + manifest: Manifest, + projectRoot: string + ): Promise { + if (!(await this.fs.fileExists(join(projectRoot, AGENTS_SKILLS_PREFIX)))) return []; + const claimed = [...manifest.getPathOwners().keys()].some((path) => + path.startsWith(AGENTS_SKILLS_PREFIX) + ); + if (claimed) return []; + return [ + { + severity: "warning", + message: `Orphaned directory: ${AGENTS_SKILLS_PREFIX} (no installed tool claims it)`, + fix: "Remove the directory manually, or reinstall a tool that renders skills there.", + }, + ]; } private async checkOrphanedDirectories( diff --git a/cli/src/application/use-cases/doctor/doctor-merge-files-use-case.ts b/cli/src/application/use-cases/doctor/doctor-merge-files-use-case.ts index 42f442b4e..dc166990a 100644 --- a/cli/src/application/use-cases/doctor/doctor-merge-files-use-case.ts +++ b/cli/src/application/use-cases/doctor/doctor-merge-files-use-case.ts @@ -20,10 +20,13 @@ export class DoctorMergeFilesUseCase { async execute(options: DoctorMergeFilesOptions): Promise { const { manifest, projectRoot, allowedIds } = options; const issues: DoctorIssue[] = []; + // A merge file co-owned by two tools is still one file: report its absence once. Its keys + // stay per-owner, because each tool tracks only the entries it wrote. + const reportedMissing = new Set(); for (const toolId of manifest.getInstalledToolIds()) { if (allowedIds && !allowedIds.has(toolId)) continue; for (const mergeFile of manifest.getMergeFiles(toolId)) { - issues.push(...(await this.checkOneMergeFile(mergeFile, projectRoot))); + issues.push(...(await this.checkOneMergeFile(mergeFile, projectRoot, reportedMissing))); } } return issues; @@ -31,10 +34,13 @@ export class DoctorMergeFilesUseCase { private async checkOneMergeFile( mergeFile: MergeFileEntry, - projectRoot: string + projectRoot: string, + reportedMissing: Set ): Promise { const fullPath = join(projectRoot, mergeFile.relativePath); if (!(await this.fs.fileExists(fullPath))) { + if (reportedMissing.has(mergeFile.relativePath)) return []; + reportedMissing.add(mergeFile.relativePath); return [ { severity: "error", diff --git a/cli/src/application/use-cases/doctor/doctor-tracked-files-use-case.ts b/cli/src/application/use-cases/doctor/doctor-tracked-files-use-case.ts index 69e3a142b..ed6605f1d 100644 --- a/cli/src/application/use-cases/doctor/doctor-tracked-files-use-case.ts +++ b/cli/src/application/use-cases/doctor/doctor-tracked-files-use-case.ts @@ -1,6 +1,6 @@ import { join } from "node:path"; import type { DoctorIssue } from "../../../domain/models/doctor.js"; -import type { Manifest } from "../../../domain/models/manifest.js"; +import type { Manifest, PathOwner } from "../../../domain/models/manifest.js"; import type { ToolId } from "../../../domain/models/tool-ids.js"; import type { FileReader } from "../../../domain/ports/file-reader.js"; @@ -15,12 +15,30 @@ export class DoctorTrackedFilesUseCase { async execute(options: DoctorTrackedFilesOptions): Promise { const { manifest, projectRoot, allowedIds } = options; - const trackedFiles = this.collectTrackedFiles(manifest, allowedIds); - const missingIssues = await this.checkMissingFiles(trackedFiles, projectRoot); - const modifiedIssues = await this.checkModifiedFiles(manifest, projectRoot, allowedIds); + const owned = this.collectOwnedPaths(manifest, allowedIds); + const missingIssues = await this.checkMissingFiles([...owned.keys()], projectRoot); + const modifiedIssues = await this.checkModifiedFiles(owned, projectRoot); return [...missingIssues, ...modifiedIssues]; } + /** + * One entry per path rather than per tool-and-path pair. A co-owned path is one file on + * disk, so it deserves one issue, not one per owner. + */ + private collectOwnedPaths( + manifest: Manifest, + allowedIds: Set | null + ): Map { + const owned = new Map(); + for (const [relativePath, owners] of manifest.getPathOwners()) { + const relevant = owners.filter( + (owner) => owner.kind === "tool" && (allowedIds === null || allowedIds.has(owner.toolId)) + ); + if (relevant.length > 0) owned.set(relativePath, relevant); + } + return owned; + } + collectTrackedFiles( manifest: Manifest, allowedIds: Set | null @@ -35,16 +53,16 @@ export class DoctorTrackedFilesUseCase { } private async checkMissingFiles( - files: ReadonlyArray<{ relativePath: string; toolId: ToolId | null }>, + relativePaths: readonly string[], projectRoot: string ): Promise { const issues: DoctorIssue[] = []; - for (const file of files) { - const fullPath = join(projectRoot, file.relativePath); + for (const relativePath of relativePaths) { + const fullPath = join(projectRoot, relativePath); if (!(await this.fs.fileExists(fullPath))) { issues.push({ severity: "error", - message: `Missing tracked file: ${file.relativePath}`, + message: `Missing tracked file: ${relativePath}`, fix: `Restore the file or run \`aidd restore\` to reinstall tracked files.`, }); } @@ -53,26 +71,39 @@ export class DoctorTrackedFilesUseCase { } private async checkModifiedFiles( - manifest: Manifest, - projectRoot: string, - allowedIds: Set | null + owned: ReadonlyMap, + projectRoot: string ): Promise { const issues: DoctorIssue[] = []; - for (const toolId of manifest.getInstalledToolIds()) { - if (allowedIds && !allowedIds.has(toolId)) continue; - for (const file of manifest.getToolFiles(toolId)) { - const fullPath = join(projectRoot, file.relativePath); - if (!(await this.fs.fileExists(fullPath))) continue; - const diskHash = await this.fs.readFileHash(fullPath); - if (!diskHash.equals(file.hash)) { - issues.push({ - severity: "warning", - message: `Modified tracked file: ${file.relativePath}`, - fix: `Run \`aidd restore --force\` to revert to the framework version.`, - }); - } - } + for (const [relativePath, owners] of owned) { + const fullPath = join(projectRoot, relativePath); + if (!(await this.fs.fileExists(fullPath))) continue; + const divergence = this.checkDivergence(relativePath, owners); + if (divergence !== null) issues.push(divergence); + const diskHash = await this.fs.readFileHash(fullPath); + if (owners.some((owner) => owner.hash === diskHash.value)) continue; + issues.push({ + severity: "warning", + message: `Modified tracked file: ${relativePath}`, + fix: `Run \`aidd restore --force\` to revert to the framework version.`, + }); } return issues; } + + /** + * Two owners of one path expecting different bytes. Reported on its own rather than folded + * into the modified-file issue: the file cannot satisfy both, and restoring it will not fix + * the disagreement. + */ + private checkDivergence(relativePath: string, owners: readonly PathOwner[]): DoctorIssue | null { + const hashes = new Set(owners.map((owner) => owner.hash).filter((hash) => hash !== null)); + if (hashes.size < 2) return null; + const claimants = owners.map((owner) => owner.toolId).join(", "); + return { + severity: "error", + message: `Owners disagree on ${relativePath}: ${claimants} expect different content`, + fix: `Reinstall the tools that share this path so they render it identically.`, + }; + } } diff --git a/cli/src/application/use-cases/status-use-case.ts b/cli/src/application/use-cases/status-use-case.ts index fec4aa061..c02388c7a 100644 --- a/cli/src/application/use-cases/status-use-case.ts +++ b/cli/src/application/use-cases/status-use-case.ts @@ -111,12 +111,27 @@ export class StatusUseCase { const mergeFiles = manifest.getMergeFiles(toolId); const drifted = await this.checkTrackedFiles(trackedFiles, projectRoot); drifted.push(...(await this.checkMergeFiles(mergeFiles, projectRoot))); - const dir = getToolConfig(toolId).directory; - const trackedSet = manifest.getTrackedPathsInDirectory(dir); - drifted.push(...(await this.detectAddedFiles(dir, trackedSet, projectRoot))); + for (const dir of this.scannedDirectories(manifest, toolId)) { + const trackedSet = manifest.getTrackedPathsInDirectory(dir); + drifted.push(...(await this.detectAddedFiles(dir, trackedSet, projectRoot))); + } return { toolId, version, drifted }; } + /** + * The tool's own directory plus every other top-level directory it actually claims files in. + * A shared tree lives outside the tool directory, so scanning that alone left it invisible. + */ + private scannedDirectories(manifest: Manifest, toolId: ToolId): string[] { + const dirs = new Set([getToolConfig(toolId).directory]); + for (const [relativePath, owners] of manifest.getPathOwners()) { + if (!relativePath.includes("/")) continue; + if (!owners.some((owner) => owner.toolId === toolId)) continue; + dirs.add(`${relativePath.split("/")[0]}/`); + } + return [...dirs]; + } + private async detectAddedFiles( directory: string, trackedSet: Set, diff --git a/cli/src/domain/models/manifest.ts b/cli/src/domain/models/manifest.ts index c618a96fa..91ba4eb4f 100644 --- a/cli/src/domain/models/manifest.ts +++ b/cli/src/domain/models/manifest.ts @@ -426,12 +426,15 @@ export class Manifest { } } + /** + * Top-level directories AIDD tracks something in. Derived from every owner, not from a + * tool's own files alone: a shared tree is claimed through the plugin path, and reading + * only tool files made it look untracked. + */ getInstalledDirectories(): Set { const dirs = new Set(); - for (const entry of this._tools.values()) { - for (const file of entry.files) { - dirs.add(`${file.relativePath.split("/")[0]}/`); - } + for (const path of this.getPathOwners().keys()) { + dirs.add(`${path.split("/")[0]}/`); } return dirs; } diff --git a/cli/tests/application/use-cases/doctor/shared-tree-visibility.unit.test.ts b/cli/tests/application/use-cases/doctor/shared-tree-visibility.unit.test.ts new file mode 100644 index 000000000..326fa2f8e --- /dev/null +++ b/cli/tests/application/use-cases/doctor/shared-tree-visibility.unit.test.ts @@ -0,0 +1,132 @@ +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import "../../../../src/domain/tools/ai/codex.js"; +import "../../../../src/domain/tools/ai/gemini.js"; +import { FileHash, InstallationFile } from "../../../../src/domain/models/file.js"; +import { Manifest } from "../../../../src/domain/models/manifest.js"; +import { Plugin } from "../../../../src/domain/models/plugin.js"; +import type { ToolId } from "../../../../src/domain/tools/registry.js"; +import { buildDoctorUseCase, buildUnitDeps } from "../../../helpers/ports/build-unit-deps.js"; + +const PROJECT_ROOT = "/test-project"; +const SHARED_PATH = ".agents/skills/aidd-context/SKILL.md"; +const SHARED_CONTENT = "# shared skill\n"; +const OTHER_CONTENT = "# a different rendering\n"; + +type Deps = Awaited>; + +function trackedFile(relativePath: string, content: string, hash: string): InstallationFile { + return new InstallationFile({ relativePath, content, hash: new FileHash(hash) }); +} + +async function hashOf(deps: Deps, content: string): Promise { + const probe = join(PROJECT_ROOT, ".probe"); + await deps.fs.writeFile(probe, content); + const hash = await deps.fs.readFileHash(probe); + await deps.fs.deleteFile(probe); + return hash.value; +} + +/** Both tools track the same path, each as its own tool file, agreeing on the content. */ +async function seedAgreeingOwners(deps: Deps, secondHash?: string): Promise { + const manifest = Manifest.create(); + const agreed = await hashOf(deps, SHARED_CONTENT); + manifest.addTool("codex" as ToolId, "1.0.0", [trackedFile(SHARED_PATH, SHARED_CONTENT, agreed)]); + manifest.addTool("gemini" as ToolId, "1.0.0", [ + trackedFile(SHARED_PATH, SHARED_CONTENT, secondHash ?? agreed), + ]); + await deps.manifestRepo.save(manifest); +} + +async function runDoctor(deps: Deps) { + return buildDoctorUseCase(deps).execute({ projectRoot: PROJECT_ROOT }); +} + +describe("doctor sees the shared tree", () => { + it("reports one issue, not one per owner, when a co-owned file is missing", async () => { + const deps = await buildUnitDeps(PROJECT_ROOT); + await seedAgreeingOwners(deps); + + const report = await runDoctor(deps); + + const missing = report.issues.filter((i) => + i.message.includes(`Missing tracked file: ${SHARED_PATH}`) + ); + expect(missing).toHaveLength(1); + }); + + it("reports one modified issue, not one per owner, for a co-owned file", async () => { + const deps = await buildUnitDeps(PROJECT_ROOT); + await seedAgreeingOwners(deps); + await deps.fs.writeFile(join(PROJECT_ROOT, SHARED_PATH), "# edited by hand\n"); + + const report = await runDoctor(deps); + + const modified = report.issues.filter((i) => + i.message.includes(`Modified tracked file: ${SHARED_PATH}`) + ); + expect(modified).toHaveLength(1); + }); + + it("reports owners that expect different content, distinctly from a missing file", async () => { + const deps = await buildUnitDeps(PROJECT_ROOT); + await seedAgreeingOwners(deps, await hashOf(deps, OTHER_CONTENT)); + await deps.fs.writeFile(join(PROJECT_ROOT, SHARED_PATH), SHARED_CONTENT); + + const report = await runDoctor(deps); + + const divergence = report.issues.filter((i) => i.message.includes("Owners disagree on")); + expect(divergence).toHaveLength(1); + expect(divergence[0]?.message).toContain("codex"); + expect(divergence[0]?.message).toContain("gemini"); + expect(report.issues.some((i) => i.message.startsWith("Missing tracked file"))).toBe(false); + }); + + it("stays quiet about divergence when both owners expect the same bytes", async () => { + const deps = await buildUnitDeps(PROJECT_ROOT); + await seedAgreeingOwners(deps); + await deps.fs.writeFile(join(PROJECT_ROOT, SHARED_PATH), SHARED_CONTENT); + + const report = await runDoctor(deps); + + expect(report.issues.some((i) => i.message.includes("Owners disagree on"))).toBe(false); + }); + + it("reports a shared tree left on disk that no installed tool claims", async () => { + const deps = await buildUnitDeps(PROJECT_ROOT); + const manifest = Manifest.create(); + manifest.addTool("codex" as ToolId, "1.0.0", []); + await deps.manifestRepo.save(manifest); + await deps.fs.writeFile(join(PROJECT_ROOT, SHARED_PATH), SHARED_CONTENT); + + const report = await runDoctor(deps); + + expect( + report.issues.some((i) => i.message.includes("Orphaned directory: .agents/skills/")) + ).toBe(true); + }); + + it("leaves a claimed shared tree alone", async () => { + const deps = await buildUnitDeps(PROJECT_ROOT); + const manifest = Manifest.create(); + manifest.addTool("codex" as ToolId, "1.0.0", []); + manifest.addPlugin( + "codex" as ToolId, + Plugin.fromJSON({ + name: "aidd-context", + source: { kind: "local", path: "/fixtures/plugin" }, + version: "1.0.0", + strict: false, + files: { [SHARED_PATH]: await hashOf(deps, SHARED_CONTENT) }, + }) + ); + await deps.manifestRepo.save(manifest); + await deps.fs.writeFile(join(PROJECT_ROOT, SHARED_PATH), SHARED_CONTENT); + + const report = await runDoctor(deps); + + expect(report.issues.some((i) => i.message.includes("Orphaned directory: .agents/"))).toBe( + false + ); + }); +}); diff --git a/cli/tests/application/use-cases/status-use-case.unit.test.ts b/cli/tests/application/use-cases/status-use-case.unit.test.ts index 5ae579d12..deae361a0 100644 --- a/cli/tests/application/use-cases/status-use-case.unit.test.ts +++ b/cli/tests/application/use-cases/status-use-case.unit.test.ts @@ -8,7 +8,10 @@ import "../../../src/domain/tools/ide/vscode.js"; import { InitUseCase } from "../../../src/application/use-cases/init-use-case.js"; import { DetectPluginDriftUseCase } from "../../../src/application/use-cases/shared/detect-plugin-drift-use-case.js"; import { StatusUseCase } from "../../../src/application/use-cases/status-use-case.js"; +import { Manifest } from "../../../src/domain/models/manifest.js"; +import { Plugin } from "../../../src/domain/models/plugin.js"; import { compareSemver } from "../../../src/domain/models/semver.js"; +import type { ToolId } from "../../../src/domain/tools/registry.js"; import { buildUnitDeps } from "../../helpers/ports/build-unit-deps.js"; const PROJECT_ROOT = "/test-project"; @@ -30,6 +33,37 @@ describe("status", () => { expect(report.inSync).toBe(true); }); + it("scans the shared tree a tool claims outside its own directory", async () => { + const deps = await buildUnitDeps(PROJECT_ROOT); + await new InitUseCase(deps.fs, deps.manifestRepo).execute({ projectRoot: PROJECT_ROOT }); + const manifest = Manifest.create(); + manifest.addTool("codex" as ToolId, "1.0.0", []); + manifest.addPlugin( + "codex" as ToolId, + Plugin.fromJSON({ + name: "aidd-context", + source: { kind: "local", path: "/fixtures/plugin" }, + version: "1.0.0", + strict: false, + files: { ".agents/skills/aidd-context/SKILL.md": "0".repeat(32) }, + }) + ); + await deps.manifestRepo.save(manifest); + // Untracked, and outside .codex/ β€” invisible while only the tool's own directory was scanned. + await deps.fs.writeFile(`${PROJECT_ROOT}/.agents/skills/stray.md`, "# stray\n"); + + const useCase = new StatusUseCase( + deps.fs, + deps.manifestRepo, + deps.hasher, + new DetectPluginDriftUseCase(deps.fs) + ); + const report = await useCase.execute({ projectRoot: PROJECT_ROOT }); + + const added = report.tools.flatMap((t) => t.drifted).filter((d) => d.status === "added"); + expect(added.map((d) => d.relativePath)).toContain(".agents/skills/stray.md"); + }); + describe("compareSemver()", () => { it("orders lower major version as smaller", () => { expect(compareSemver("1.0.0", "2.0.0")).toBe(-1); From e89ca11777ab1ccd4ee8e41a6f96abc4a50a614c Mon Sep 17 00:00:00 2001 From: Thierry Kwiatkowski Date: Wed, 12 Aug 2026 17:06:18 +0200 Subject: [PATCH 13/23] test(cli): assert the co-owned skills tree stays byte-identical Two tools rendering different bytes to one shared path is the failure co-ownership cannot survive. The guards added earlier keep such a file alive; this keeps it from diverging in the first place. The golden suite now builds the codex and gemini flat cells and requires every shared skill path gemini writes to carry codex's exact hash, naming the first offending path and both hashes on failure. It also requires gemini to write at least one such file, so the assertion cannot pass by finding nothing to compare. Mutation-checked by flipping gemini's skill-name rewrite: the suite fails naming the diverging SKILL.md. Co-Authored-By: Claude Opus 5 --- .../2026_07_27-511-gemini-cli-tool-master.md | 6 ++-- .../2026_07_27-511-gemini-cli-tool-part-2.md | 9 ++++-- .../golden/framework-build-golden.e2e.test.ts | 32 +++++++++++++++++++ 3 files changed, 41 insertions(+), 6 deletions(-) diff --git a/aidd_docs/tasks/2026_07/2026_07_27_gemini-cli-build-target/2026_07_27-511-gemini-cli-tool-master.md b/aidd_docs/tasks/2026_07/2026_07_27_gemini-cli-build-target/2026_07_27-511-gemini-cli-tool-master.md index 61676f3fa..539cb946a 100644 --- a/aidd_docs/tasks/2026_07/2026_07_27_gemini-cli-build-target/2026_07_27-511-gemini-cli-tool-master.md +++ b/aidd_docs/tasks/2026_07/2026_07_27_gemini-cli-build-target/2026_07_27-511-gemini-cli-tool-master.md @@ -51,8 +51,8 @@ Neither appears in the issue or the brainstorm, and both gate the stated success | # | Plan | File | Status | Validated | | --- | ----------------------- | ------------------------------------------------ | ------- | --------- | | 1 | Build target | `./2026_07_27-511-gemini-cli-tool-part-1.md` | done | [x] | -| 2 | Shared tree safety | `./2026_07_27-511-gemini-cli-tool-part-2.md` | in-progress | [ ] | -| 3 | Registry citizen | `./2026_07_27-511-gemini-cli-tool-part-3.md` | blocked | [ ] | +| 2 | Shared tree safety | `./2026_07_27-511-gemini-cli-tool-part-2.md` | done | [x] | +| 3 | Registry citizen | `./2026_07_27-511-gemini-cli-tool-part-3.md` | pending | [ ] | | 4 | Content and docs | `./2026_07_27-511-gemini-cli-tool-part-4.md` | blocked | [ ] | @@ -85,7 +85,7 @@ This supersedes one brainstorm decision. The brainstorm chose tool-neutral skill 1. Complete Part 1, run its `success_condition` 2. [x] Checkpoint 1: gemini archive builds, 9 existing golden cells byte-identical 3. Unblock Part 2, run its `success_condition` -4. [ ] Checkpoint 2: shared-path deletion guarded, subset invariant green +4. [x] Checkpoint 2: shared-path deletion guarded, subset invariant green 5. Unblock Part 3, run its `success_condition` 6. [ ] Checkpoint 3: full command matrix green for gemini, smoke coverage gate met 7. Unblock Part 4, run its `success_condition` diff --git a/aidd_docs/tasks/2026_07/2026_07_27_gemini-cli-build-target/2026_07_27-511-gemini-cli-tool-part-2.md b/aidd_docs/tasks/2026_07/2026_07_27_gemini-cli-build-target/2026_07_27-511-gemini-cli-tool-part-2.md index 2f2bf4710..78b3ffb33 100644 --- a/aidd_docs/tasks/2026_07/2026_07_27_gemini-cli-build-target/2026_07_27-511-gemini-cli-tool-part-2.md +++ b/aidd_docs/tasks/2026_07/2026_07_27_gemini-cli-build-target/2026_07_27-511-gemini-cli-tool-part-2.md @@ -176,9 +176,9 @@ flowchart TD #### Acceptance criteria -- [ ] The invariant is asserted in the golden suite, not merely documented -- [ ] A deliberate one-byte divergence makes the suite fail with the offending path named -- [ ] `cd cli && pnpm typecheck && pnpm lint && pnpm test` exits 0 +- [x] The invariant is asserted in the golden suite, not merely documented +- [x] A deliberate one-byte divergence makes the suite fail with the offending path named +- [x] `cd cli && pnpm typecheck && pnpm lint && pnpm test` exits 0 ## Amendments @@ -192,6 +192,8 @@ flowchart TD πŸ€– `otherToolsOwnMergeFile` (`uninstall-tools-use-case.ts:214`) still reads the installed tool ids on its own. It answers a different question, whether a merge file may be deleted outright or must be stripped of this tool's entries, so it is not a second copy of the shared-path guard. Left alone in phase 1; a candidate for phase 2's accessor. +πŸ€– The flat build's skills writer ignores `ArtifactContract.transform` (`flat-build-strategy.ts:160-178`): a skill file's output bytes are decided by `path`, `rewriteSkillName` and the shared relative-link rewrite alone. Worth recording, because it is why the subset invariant holds by construction rather than by discipline β€” gemini and codex reach the same bytes through the same primitives β€” and because a first attempt to mutation-check phase 4 through `transform` was silently a no-op. The real check disables gemini's `rewriteSkillName`, which does move the bytes. + ## Log @@ -199,6 +201,7 @@ flowchart TD - Phase 1: `application/use-cases/uninstall/shared-path-guard.ts` created as the single owner of "may this uninstall delete that file", spanning tool files, merge files and plugin files, keyed by a `(toolId, pluginName | null)` departing claim and returning a readonly path-to-owners map. Wired into all three deletion sites: the tool-file loop and `removeAllPluginFiles` in `uninstall-tools-use-case.ts` (its module-private `computeSharedPaths` deleted), and `deleteFiles` in `uninstall-plugin-use-case.ts`, which gains the `Logger` it needed to report a retention (single construction site, `uninstall-use-case.ts:35`). Every retained path emits one `warn` naming the surviving owners. New `tests/application/use-cases/uninstall/shared-path-guard.integration.test.ts`, 5 cases: co-owned file survives one owner leaving, solely-owned path still deleted, retention reported once and naming the owner, co-owned file deleted when the last owner goes, and co-owned file surviving a whole-tool uninstall. Mutation-checked: neutralizing `computeRetainedPaths` fails 3 of the 5, the two deletion assertions staying green as they should. Verified: `pnpm typecheck` (0 errors), `biome check` (clean, 2 pre-existing config infos from main's biome bump), full `pnpm test` 2195/2196 β€” the one failure is `auth status`, caused by an `AIDD_TOKEN` in the developer environment that the e2e sandbox does not scrub, unrelated to this work. - Phase 2: `Manifest.getPathOwners()` added, returning a readonly path-to-owners map derived on read. An owner carries its tool id, how it claims the path (`tool`, `merge` or `plugin`), the plugin name when one applies, and the hash β€” `null` for a merge file, which tracks entries rather than bytes, so a divergence between two owners of one path stays visible rather than collapsing into a single entry. No schema change: `MANIFEST_VERSION` stays 6 and no migration was added, verified by diffing the model. `computeRetainedPaths` now reads the accessor instead of walking tools, merge files and plugins itself, so the departing-claim match is the only logic left in the application layer. New `tests/domain/models/manifest-path-owners.unit.test.ts`, 8 cases: single owner, two tools on one path, a plugin owning in its own right, a tool and its own plugin on the same path, zero owners, divergent hashes, the merge file's null hash, and an owner disappearing when its claim is released. Verified: `pnpm typecheck` (0 errors), `biome check` (clean), full `pnpm test` 2203/2204, the one failure being the same environment-coupled `auth status` case. - Phase 3: doctor and status now read the owners view instead of walking tools. `DoctorTrackedFilesUseCase` checks each path once rather than once per owner, so a co-owned file yields one missing issue and one modified issue instead of two, and a genuine disagreement between owners is reported on its own (`Owners disagree on `, severity error), since restoring the file cannot satisfy both. `DoctorMergeFilesUseCase` deduplicates only the missing-file issue, keys staying per-owner because each tool tracks the entries it wrote. `Manifest.getInstalledDirectories()` derives from every owner rather than tool files alone, so a directory claimed only through plugins stops looking untracked. `DoctorLayoutUseCase` gained a shared-tree orphan check: the existing one walks each registered tool's own directory and structurally cannot see `.agents/skills/`, which belongs to no single tool. `StatusUseCase` scans the tool's own directory plus every other top-level directory it actually claims files in. New `tests/application/use-cases/doctor/shared-tree-visibility.unit.test.ts` (6 cases) and one status case. Mutation-checked by stashing the source and re-running: 4 of the 6 doctor cases fail against the previous code, the 2 that survive being the negative assertions that must hold either way. The golden command-matrix baseline did not move, so nothing needed re-baselining and nothing was blind-updated. Verified: `pnpm typecheck` (0 errors), `biome check` (clean), unit and integration 2074/2074, e2e 129/130 with the same environment-coupled `auth status` failure. +- Phase 4: the co-ownership invariant is now asserted rather than assumed. `framework-build-golden.e2e.test.ts` gains a case that builds the codex and gemini flat cells and requires every `.agents/skills/**` path gemini writes to carry codex's exact hash, failing with the first offending path and both hashes rather than a bare inequality. Guarded against vacuity: the case also requires gemini to render at least one file under the shared tree. Mutation-checked by flipping gemini's `rewriteSkillName` to false, which fails the suite naming `.agents/skills/aidd-async-dev-01-setup/SKILL.md` and both divergent hashes. Verified: `pnpm typecheck` (0 errors), `biome check` (clean), full `pnpm test` 2211/2212, the one failure being `auth status`, whose exit code depends on an `AIDD_TOKEN` in the developer environment that the e2e sandbox does not scrub. ## Validation flow demonstration diff --git a/cli/tests/golden/framework-build-golden.e2e.test.ts b/cli/tests/golden/framework-build-golden.e2e.test.ts index 5cda0ed1b..3077759b7 100644 --- a/cli/tests/golden/framework-build-golden.e2e.test.ts +++ b/cli/tests/golden/framework-build-golden.e2e.test.ts @@ -54,6 +54,9 @@ const FLAT_TARGETS = ["claude", "cursor", "copilot", "codex", "opencode", "gemin */ const FROZEN_CELLS = new Set(["claude"]); +/** Tree codex and gemini both render into, and must render identically. */ +const SHARED_SKILLS_PREFIX = ".agents/skills/"; + async function hashDirectory(dir: string): Promise { const result: TargetSnapshot = {}; const entries = await readdir(dir, { recursive: true }); @@ -194,6 +197,35 @@ describe.concurrent("Framework build golden β€” 10-cell matrix", () => { } }); + /** + * Codex and gemini write the same shared skills tree. Two tools rendering different bytes + * to one path is the failure mode co-ownership cannot survive, so it is eliminated by + * construction and asserted here rather than left to convention. + */ + it("gemini's shared skills tree is a byte-identical subset of codex's", async () => { + const { projectDir, fakeHome, tempDir, cleanup } = await createTestEnv("fb-golden-subset"); + try { + const codex = await captureTarget("codex", true, projectDir, fakeHome, tempDir); + const gemini = await captureTarget("gemini", true, projectDir, fakeHome, tempDir); + const sharedPaths = Object.keys(gemini) + .filter((path) => path.startsWith(SHARED_SKILLS_PREFIX)) + .sort(); + + expect(sharedPaths.length, "gemini renders no skill under the shared tree").toBeGreaterThan( + 0 + ); + const diverging = sharedPaths.find((path) => codex[path] !== gemini[path]); + expect( + diverging, + diverging === undefined + ? "" + : `co-owned path rendered differently by codex and gemini: ${diverging} (codex ${codex[diverging] ?? "absent"}, gemini ${gemini[diverging]})` + ).toBeUndefined(); + } finally { + await cleanup(); + } + }); + it("all 10 cells are non-empty", async () => { const stored = JSON.parse(await readFile(SNAPSHOT_FILE, "utf-8")) as GoldenSnapshot; const expectedCells = [...MARKETPLACE_TARGETS, ...FLAT_TARGETS.map((t) => `${t}:flat`)]; From 4e4030fc8e7138da73bf78e1b590ccb875667d5f Mon Sep 17 00:00:00 2001 From: Thierry Kwiatkowski Date: Wed, 12 Aug 2026 17:21:10 +0200 Subject: [PATCH 14/23] refactor(cli): route plugin translation by capability, not by tool name The translator asked which tool it was dealing with twice: once to pick the built tree's layout, once to decide which files in a flat build belonged to the plugin. Both answers were already derivable. The layout comes from the plugins capability, which resolves to "flat" for flat tools and was simply never read. Ownership comes from the tool's own directory plus the shared .agents/ root, matching a plugin-namespaced segment anywhere below it rather than at a fixed depth: gemini's agents sit one level shallower than opencode's skills. The hooks-skip reason moves onto the capability too, so a tool explains its own limitation instead of borrowing opencode's. Gemini declares flat plugins, which it needs to materialize skills and agents at all. That put it back under a conformance guard demanding a marketplace probe, so the guard is rekeyed on PluginFormat membership: writing plugin content and reading a marketplace are different claims, and only the second needs a probe. Every opencode translator suite passes unmodified. Co-Authored-By: Claude Opus 5 --- .../2026_07_27-511-gemini-cli-tool-master.md | 2 +- .../2026_07_27-511-gemini-cli-tool-part-3.md | 15 +++++-- .../built-tree-materialization-translator.ts | 43 +++++++++++++++---- .../domain/capabilities/plugins-capability.ts | 9 ++++ cli/src/domain/formats/flat-paths.ts | 5 ++- .../models/plugin-content-translator.ts | 4 +- cli/src/domain/models/plugin-format.ts | 14 ++++++ .../domain/models/plugin-translation-skip.ts | 12 ++++++ cli/src/domain/tools/ai/gemini.ts | 12 ++++-- cli/src/domain/tools/ai/opencode.ts | 2 + .../tools/registry-conformance.unit.test.ts | 14 +++--- 11 files changed, 106 insertions(+), 26 deletions(-) diff --git a/aidd_docs/tasks/2026_07/2026_07_27_gemini-cli-build-target/2026_07_27-511-gemini-cli-tool-master.md b/aidd_docs/tasks/2026_07/2026_07_27_gemini-cli-build-target/2026_07_27-511-gemini-cli-tool-master.md index 539cb946a..9d334cd5a 100644 --- a/aidd_docs/tasks/2026_07/2026_07_27_gemini-cli-build-target/2026_07_27-511-gemini-cli-tool-master.md +++ b/aidd_docs/tasks/2026_07/2026_07_27_gemini-cli-build-target/2026_07_27-511-gemini-cli-tool-master.md @@ -52,7 +52,7 @@ Neither appears in the issue or the brainstorm, and both gate the stated success | --- | ----------------------- | ------------------------------------------------ | ------- | --------- | | 1 | Build target | `./2026_07_27-511-gemini-cli-tool-part-1.md` | done | [x] | | 2 | Shared tree safety | `./2026_07_27-511-gemini-cli-tool-part-2.md` | done | [x] | -| 3 | Registry citizen | `./2026_07_27-511-gemini-cli-tool-part-3.md` | pending | [ ] | +| 3 | Registry citizen | `./2026_07_27-511-gemini-cli-tool-part-3.md` | in-progress | [ ] | | 4 | Content and docs | `./2026_07_27-511-gemini-cli-tool-part-4.md` | blocked | [ ] | diff --git a/aidd_docs/tasks/2026_07/2026_07_27_gemini-cli-build-target/2026_07_27-511-gemini-cli-tool-part-3.md b/aidd_docs/tasks/2026_07/2026_07_27_gemini-cli-build-target/2026_07_27-511-gemini-cli-tool-part-3.md index 0d3599d75..04b9b53dd 100644 --- a/aidd_docs/tasks/2026_07/2026_07_27_gemini-cli-build-target/2026_07_27-511-gemini-cli-tool-part-3.md +++ b/aidd_docs/tasks/2026_07/2026_07_27_gemini-cli-build-target/2026_07_27-511-gemini-cli-tool-part-3.md @@ -138,9 +138,9 @@ flowchart TD #### Acceptance criteria -- [ ] No tool-name literal remains in the plugin translation path -- [ ] Every pre-existing opencode translator test passes without modification -- [ ] The hooks-skip message names the actual tool +- [x] No tool-name literal remains in the plugin translation path +- [x] Every pre-existing opencode translator test passes without modification +- [x] The hooks-skip message names the actual tool ### Phase 2: Route the MCP unmerge safely @@ -198,9 +198,18 @@ flowchart TD +πŸ€– The projection omits `cli/src/domain/tools/ai/gemini.ts`, but this part cannot meet its objective without it. Part 1 declared gemini's plugins capability `{ mode: "unsupported" }`, which is what keeps the install path from materializing any plugin content β€” and skills and agents reach a project as plugin content. Gemini now declares `{ mode: "flat", flatNamespacePrefix: "aidd-" }`, the opencode precedent this plan already names in its summary. + +πŸ€– That change collided with the marketplace-probe conformance guard, which part 2's repair had narrowed to exempt `mode: "unsupported"`. A flat gemini falls back under the requirement, and it cannot be satisfied: `PluginFormat` is a closed union of the five marketplace layouts aidd can *read*, and Gemini CLI has none. Adding gemini to it would make aidd advertise a format that does not exist, contradicting the master plan. Decided with the user: the guard now keys on `PluginFormat` membership rather than on the plugins mode, which is the invariant it was always reaching for β€” writing plugin content and ingesting a marketplace are different claims, and only the second needs a probe. `PLUGIN_FORMATS` was added beside the type as its runtime companion so the set can be iterated. Opencode keeps its requirement; gemini is out of scope of it by construction rather than by exemption. + +πŸ€– The hooks-skip reason became `FlatPluginsParams.hooksSkipReason`, declared by the tool, rather than a branch on the tool name. A tool-name branch would have satisfied "the message names the actual tool" while violating this phase's other acceptance criterion, that no tool-name literal survives in the translation path. Opencode declares its existing wording, so its message is unchanged and its tests pass untouched; gemini declares why its hooks cannot travel through the flat install path. + +πŸ€– Line references drifted: the translation-mode literal sits at `built-tree-materialization-translator.ts:62` and the ownership check at `:137`, not `:64` and `:120-125`. Both were found and replaced; the ownership rule now accepts the tool's own directory plus the shared `.agents/` root, with `AGENTS_SHARED_ROOT` extracted in `flat-paths.ts` beside the existing skills prefix. + ## Log +- Phase 1: both opencode literals removed from `built-tree-materialization-translator.ts`. The translation mode is read from `PluginsCapability.translationMode`, which already resolves to `"flat"` for flat tools and was simply never consulted. Plugin ownership inside a flat built tree is derived from the tool's own directory plus the shared `.agents/` root instead of a hardcoded `.opencode` segment, matching a plugin-namespaced segment anywhere below the root rather than at a fixed depth, since gemini's agents sit one level shallower than opencode's skills. `FlatPluginsParams` gained `hooksSkipReason` so the skip message comes from the tool rather than a branch. Gemini switched to `{ mode: "flat" }` and the conformance guard was rekeyed on `PluginFormat` (see Amendments). Verified: `pnpm typecheck` (0 errors), `biome check` (clean), unit and integration 2081/2081, e2e 130/131 with the same environment-coupled `auth status` failure, and the grep gate for a tool-name literal under `use-cases/plugin/` returns nothing. ## Validation flow demonstration diff --git a/cli/src/application/use-cases/plugin/translator/built-tree-materialization-translator.ts b/cli/src/application/use-cases/plugin/translator/built-tree-materialization-translator.ts index 0c4fadfad..3daa440d5 100644 --- a/cli/src/application/use-cases/plugin/translator/built-tree-materialization-translator.ts +++ b/cli/src/application/use-cases/plugin/translator/built-tree-materialization-translator.ts @@ -1,4 +1,6 @@ import { join } from "node:path"; +import type { PluginsCapability } from "../../../../domain/capabilities/plugins-capability.js"; +import { AGENTS_SHARED_ROOT } from "../../../../domain/formats/flat-paths.js"; import { InstallationFile } from "../../../../domain/models/file.js"; import type { Manifest } from "../../../../domain/models/manifest.js"; import { Plugin } from "../../../../domain/models/plugin.js"; @@ -10,6 +12,7 @@ import type { FileReader } from "../../../../domain/ports/file-reader.js"; import type { FileWriter } from "../../../../domain/ports/file-writer.js"; import type { Hasher } from "../../../../domain/ports/hasher.js"; import type { MarketplaceRegistry } from "../../../../domain/ports/marketplace-registry.js"; +import { getToolConfig, isAiTool } from "../../../../domain/tools/registry.js"; import type { EnsureBuiltMarketplaceUseCase } from "../../shared/ensure-built-marketplace-use-case.js"; import { isPluginFileAtDesiredState, resolvePluginBaseDir } from "../plugin-helpers.js"; import { ModeBFlatMaterializationTranslator } from "./mode-b-flat-materialization-translator.js"; @@ -59,7 +62,7 @@ export class BuiltTreeMaterializationTranslator implements PluginTranslator { previousMcpEntries ); } - const mode = toolId === "opencode" ? "flat" : "marketplace"; + const mode = this.translationModeFor(toolId); const { builtDir } = await this.ensureBuilt.execute({ projectRoot, marketplace: resolved, @@ -68,7 +71,7 @@ export class BuiltTreeMaterializationTranslator implements PluginTranslator { }); const files = mode === "flat" - ? await this.readFlatFiles(builtDir, dist.manifest.name) + ? await this.readFlatFiles(builtDir, dist.manifest.name, toolId) : await this.readBuiltFiles( join(builtDir, "plugins", dist.manifest.name), dist.manifest.name @@ -115,14 +118,30 @@ export class BuiltTreeMaterializationTranslator implements PluginTranslator { ); } + /** + * Which layout a tool's built tree uses, read from its plugins capability rather than + * matched against a tool name: a flat-only tool must not be a special case here, and + * adding one must not mean editing this file. + */ + private translationModeFor(toolId: AiToolId): "flat" | "marketplace" { + const config = getToolConfig(toolId); + if (!isAiTool(config)) return "marketplace"; + const { plugins } = config.capabilities as { plugins?: PluginsCapability }; + return plugins?.translationMode === "flat" ? "flat" : "marketplace"; + } + // Flat build emits the whole marketplace into one workspace, namespaced by - // .opencode/
/-/...; install copies only this plugin's files. - private async readFlatFiles(builtDir: string, name: string): Promise { + // /
/-/...; install copies only this plugin's files. + private async readFlatFiles( + builtDir: string, + name: string, + toolId: AiToolId + ): Promise { const absPaths = await this.fs.listFilesRecursive(builtDir); const files: InstallationFile[] = []; for (const abs of absPaths) { const rel = abs.slice(builtDir.length + 1); - if (!this.belongsToPlugin(rel, name)) continue; + if (!this.belongsToPlugin(rel, name, toolId)) continue; const content = await this.fs.readFile(abs); files.push( new InstallationFile({ relativePath: rel, content, hash: this.hasher.hash(content) }) @@ -131,11 +150,17 @@ export class BuiltTreeMaterializationTranslator implements PluginTranslator { return files; } - private belongsToPlugin(rel: string, name: string): boolean { + /** + * A flat build writes every plugin into one workspace, so install has to pick its own + * files back out. Ownership is the tool's own directory plus the shared `.agents/` tree, + * which belongs to no single tool, and a segment namespaced with the plugin's name. + */ + private belongsToPlugin(rel: string, name: string, toolId: AiToolId): boolean { const segments = rel.split("/"); - return ( - segments[0] === ".opencode" && segments.length >= 3 && segments[2].startsWith(`${name}-`) - ); + if (segments.length < 3) return false; + const root = `${segments[0]}/`; + if (root !== getToolConfig(toolId).directory && root !== AGENTS_SHARED_ROOT) return false; + return segments.slice(1).some((segment) => segment.startsWith(`${name}-`)); } private async findMarketplace(name: string, projectRoot: string) { diff --git a/cli/src/domain/capabilities/plugins-capability.ts b/cli/src/domain/capabilities/plugins-capability.ts index 4a2dc37a7..a1f6a65a0 100644 --- a/cli/src/domain/capabilities/plugins-capability.ts +++ b/cli/src/domain/capabilities/plugins-capability.ts @@ -83,6 +83,11 @@ export interface NativePluginsParams { export interface FlatPluginsParams { mode: "flat"; flatNamespacePrefix: string; + /** + * Why this tool cannot take a plugin's declarative hooks. Declared here so the skip + * message explains the tool it applies to, instead of borrowing another tool's reason. + */ + hooksSkipReason?: string; } export interface UnsupportedPluginsParams { @@ -96,6 +101,8 @@ export class PluginsCapability { readonly pluginsDir: string | null; readonly pluginManifestRelativePath: string | null; readonly flatNamespacePrefix: string | null; + /** Set only for flat tools that reject declarative hooks; null when hooks are accepted. */ + readonly hooksSkipReason: string | null; readonly acceptsHooks: boolean; readonly acceptsMcp: boolean; readonly mcpRelativePath: string; @@ -129,6 +136,7 @@ export class PluginsCapability { this.installScope = PluginsCapability.resolveInstallScope(params); PluginsCapability.validateUserScope(params); if (params.mode === "native") { + this.hooksSkipReason = null; this.pluginsDir = params.pluginsDir; this.pluginManifestRelativePath = params.pluginManifestRelativePath; this.flatNamespacePrefix = null; @@ -144,6 +152,7 @@ export class PluginsCapability { this.pluginsDir = null; this.pluginManifestRelativePath = null; this.flatNamespacePrefix = params.mode === "flat" ? params.flatNamespacePrefix : null; + this.hooksSkipReason = params.mode === "flat" ? (params.hooksSkipReason ?? null) : null; this.acceptsHooks = false; this.acceptsMcp = false; this.mcpRelativePath = DEFAULT_MCP_PATH; diff --git a/cli/src/domain/formats/flat-paths.ts b/cli/src/domain/formats/flat-paths.ts index 46164a6f2..a3e88401b 100644 --- a/cli/src/domain/formats/flat-paths.ts +++ b/cli/src/domain/formats/flat-paths.ts @@ -12,7 +12,10 @@ * also resolves to. Both tools reuse this exact prefix so their skills output is a * byte-identical subset of one shared tree, rather than two divergent renderings. */ -export const AGENTS_SKILLS_PREFIX = ".agents/skills/"; +/** Root of the cross-tool `.agents/` tree, which belongs to no single tool. */ +export const AGENTS_SHARED_ROOT = ".agents/"; + +export const AGENTS_SKILLS_PREFIX = `${AGENTS_SHARED_ROOT}skills/`; /** * Returns the flat-output path for an agent file. diff --git a/cli/src/domain/models/plugin-content-translator.ts b/cli/src/domain/models/plugin-content-translator.ts index 5d9ef376e..0af0be76e 100644 --- a/cli/src/domain/models/plugin-content-translator.ts +++ b/cli/src/domain/models/plugin-content-translator.ts @@ -14,7 +14,7 @@ import { isAiTool } from "../tools/registry.js"; import { InstallationFile } from "./file.js"; import type { PluginComponentFile, PluginDistribution } from "./plugin-distribution.js"; import { - OPENCODE_HOOKS_SKIP_REASON, + hooksSkipReasonFor, type PluginTranslationSkip, type ReadonlySkipList, } from "./plugin-translation-skip.js"; @@ -200,7 +200,7 @@ export class PluginContentTranslator { pluginName: dist.manifest.name, component: "hooks", toolId: tool.toolId, - reason: OPENCODE_HOOKS_SKIP_REASON, + reason: hooksSkipReasonFor(tool.toolId, tool.capabilities.plugins.hooksSkipReason), }; return [entry]; } diff --git a/cli/src/domain/models/plugin-format.ts b/cli/src/domain/models/plugin-format.ts index 77d8a569f..29bfe687e 100644 --- a/cli/src/domain/models/plugin-format.ts +++ b/cli/src/domain/models/plugin-format.ts @@ -1,5 +1,19 @@ +/** + * A marketplace layout aidd can read. This is about ingestion, not about which tools aidd + * writes to: a tool can materialize plugin content without its vendor having any plugin + * manager, in which case it is a target only and never appears here. + */ export type PluginFormat = "claude" | "cursor" | "codex" | "copilot" | "opencode"; +/** Runtime companion to PluginFormat, so the ingestible set can be iterated and asserted. */ +export const PLUGIN_FORMATS: readonly PluginFormat[] = [ + "claude", + "cursor", + "codex", + "copilot", + "opencode", +]; + export const PLUGIN_MANIFEST_PROBES: readonly { format: PluginFormat; relativePath: string }[] = [ { format: "claude", relativePath: ".claude-plugin/plugin.json" }, { format: "cursor", relativePath: ".cursor-plugin/plugin.json" }, diff --git a/cli/src/domain/models/plugin-translation-skip.ts b/cli/src/domain/models/plugin-translation-skip.ts index 79bef59df..3b1b6eb27 100644 --- a/cli/src/domain/models/plugin-translation-skip.ts +++ b/cli/src/domain/models/plugin-translation-skip.ts @@ -11,3 +11,15 @@ export type ReadonlySkipList = readonly PluginTranslationSkip[]; export const OPENCODE_HOOKS_SKIP_REASON = "OpenCode plugin runtime is JS modules; declarative hooks.json is not supported."; + +export const GEMINI_HOOKS_SKIP_REASON = + "Gemini hooks live in .gemini/settings.json under translated event names, which the flat plugin install path cannot express; use the framework build archive for hooks."; + +/** + * Why a tool skipped a plugin's declarative hooks. The tool's own capability supplies the + * explanation when it has one; otherwise the message still names the tool it applies to, + * rather than borrowing another tool's reason. + */ +export function hooksSkipReasonFor(toolId: AiToolId, declared: string | null): string { + return declared ?? `${toolId} does not consume declarative hooks.json in flat plugin mode.`; +} diff --git a/cli/src/domain/tools/ai/gemini.ts b/cli/src/domain/tools/ai/gemini.ts index 183ba5eab..9d3cdfa67 100644 --- a/cli/src/domain/tools/ai/gemini.ts +++ b/cli/src/domain/tools/ai/gemini.ts @@ -5,6 +5,7 @@ import { SkillsCapability } from "../../capabilities/skills-capability.js"; import { detectSectionKeyFromPrefixes } from "../../formats/command.js"; import { baseReverseRewriteContent, baseRewriteContent } from "../../formats/placeholders.js"; import { CONFIG_MCP } from "../../models/framework.js"; +import { GEMINI_HOOKS_SKIP_REASON } from "../../models/plugin-translation-skip.js"; import type { AiTool, HasAgents, @@ -65,9 +66,14 @@ export const gemini: AiTool = { mergeStrategy: "framework-prime", consumes: [CONFIG_MCP], }), - // Gemini CLI has no plugin-manager equivalent (no marketplace, no native activation). - // Plugin install/propagation for gemini is out of scope for this build target. - plugins: new PluginsCapability({ mode: "unsupported" }), + // Flat, like opencode: Gemini CLI has no plugin manager, so plugin content is + // materialized as files rather than registered. Gemini is a target aidd writes to, + // never a marketplace format aidd reads, so it is absent from PluginFormat. + plugins: new PluginsCapability({ + mode: "flat", + flatNamespacePrefix: "aidd-", + hooksSkipReason: GEMINI_HOOKS_SKIP_REASON, + }), }, rewriteContent(content: string, docsDir: string): string { diff --git a/cli/src/domain/tools/ai/opencode.ts b/cli/src/domain/tools/ai/opencode.ts index d8b15f2db..624963cfd 100644 --- a/cli/src/domain/tools/ai/opencode.ts +++ b/cli/src/domain/tools/ai/opencode.ts @@ -19,6 +19,7 @@ import { } from "../../formats/command.js"; import { baseReverseRewriteContent, baseRewriteContent } from "../../formats/placeholders.js"; import { CONFIG_MCP, CONFIG_OPENCODE } from "../../models/framework.js"; +import { OPENCODE_HOOKS_SKIP_REASON } from "../../models/plugin-translation-skip.js"; import type { AiTool, HasAgents, @@ -153,6 +154,7 @@ export const opencode: AiTool< plugins: new PluginsCapability({ mode: "flat", flatNamespacePrefix: "aidd-", + hooksSkipReason: OPENCODE_HOOKS_SKIP_REASON, }), }, diff --git a/cli/tests/domain/tools/registry-conformance.unit.test.ts b/cli/tests/domain/tools/registry-conformance.unit.test.ts index 77a0475ea..f2a739f25 100644 --- a/cli/tests/domain/tools/registry-conformance.unit.test.ts +++ b/cli/tests/domain/tools/registry-conformance.unit.test.ts @@ -7,10 +7,10 @@ import "../../../src/domain/tools/ai/copilot.js"; import "../../../src/domain/tools/ai/cursor.js"; import "../../../src/domain/tools/ai/gemini.js"; import "../../../src/domain/tools/ai/opencode.js"; -import type { PluginsCapability } from "../../../src/domain/capabilities/plugins-capability.js"; import { FRAMEWORK_BUILD_TARGET_MODES } from "../../../src/domain/models/framework-build.js"; import { MARKETPLACE_PROBES, + PLUGIN_FORMATS, PLUGIN_MANIFEST_PROBES, } from "../../../src/domain/models/plugin-format.js"; import { AI_TOOL_IDS } from "../../../src/domain/models/tool-ids.js"; @@ -92,14 +92,14 @@ describe("AiTool contract conformance", () => { ).toBe(true); }); - it("is ingestible when its plugins capability has a marketplace to detect", () => { - // "unsupported" declares the absence of a plugin system, so no probe could describe it. - // Requiring one would make aidd claim it detects a marketplace format that does not exist. - const { plugins } = tool.capabilities as { plugins?: PluginsCapability }; - if (!plugins || plugins.mode === "unsupported") return; + it("is detectable when aidd claims to read its marketplace format", () => { + // Writing plugin content and reading a marketplace are different claims. A tool aidd + // only writes to has no marketplace layout to probe for, and demanding one would make + // aidd advertise a format that does not exist. + if (!(PLUGIN_FORMATS as readonly string[]).includes(toolId)) return; expect( MARKETPLACE_PROBES.some((probe) => probe.format === toolId), - `${toolId} declares a "${plugins.mode}" plugins capability but has no MARKETPLACE_PROBES entry (domain/models/plugin-format.ts) β€” its marketplace would never be detected` + `${toolId} is a PluginFormat but has no MARKETPLACE_PROBES entry (domain/models/plugin-format.ts) β€” its marketplace would never be detected` ).toBe(true); }); }); From 2d756a1e03e87e32c80b3f54b4b25725b328d960 Mon Sep 17 00:00:00 2001 From: Thierry Kwiatkowski Date: Wed, 12 Aug 2026 17:38:24 +0200 Subject: [PATCH 15/23] fix(cli): merge plugin MCP servers under the key each tool declares Two tools now share the capability shape that routes plugin MCP merging, and they keep their servers under different keys: mcp in opencode.json, mcpServers in .gemini/settings.json. Both sides of the merge assumed opencode's, so installing or removing a plugin for gemini would have written a stray section into a user-owned settings file and left the real one untouched. The section key now travels with the merge, and the qualification helper returns it instead of a boolean, which is what forced callers to re-derive it from the tool name. A tool that qualifies but declares no section raises a typed error: defaulting there writes one tool's section into another tool's file. Collision messages name the config actually being written rather than always naming opencode.json. Mutation-checked by pinning the key back: 2 of 4 gemini cases fail. Co-Authored-By: Claude Opus 5 --- .../2026_07_27-511-gemini-cli-tool-part-3.md | 15 +- .../use-cases/plugin/plugin-helpers.ts | 23 ++- .../plugin/plugin-remove-use-case.ts | 12 +- .../mode-b-flat-materialization-translator.ts | 19 +-- cli/src/domain/errors.ts | 13 ++ cli/src/domain/formats/opencode-mcp-merge.ts | 75 ++++++---- .../plugin/flat-mcp-section-key.unit.test.ts | 60 ++++++++ ...move-plugin-gemini-mcp.integration.test.ts | 131 ++++++++++++++++++ .../formats/opencode-mcp-merge.unit.test.ts | 77 ++++++---- 9 files changed, 347 insertions(+), 78 deletions(-) create mode 100644 cli/tests/application/use-cases/plugin/flat-mcp-section-key.unit.test.ts create mode 100644 cli/tests/application/use-cases/plugin/translator/remove-plugin-gemini-mcp.integration.test.ts diff --git a/aidd_docs/tasks/2026_07/2026_07_27_gemini-cli-build-target/2026_07_27-511-gemini-cli-tool-part-3.md b/aidd_docs/tasks/2026_07/2026_07_27_gemini-cli-build-target/2026_07_27-511-gemini-cli-tool-part-3.md index 04b9b53dd..6fad46755 100644 --- a/aidd_docs/tasks/2026_07/2026_07_27_gemini-cli-build-target/2026_07_27-511-gemini-cli-tool-part-3.md +++ b/aidd_docs/tasks/2026_07/2026_07_27_gemini-cli-build-target/2026_07_27-511-gemini-cli-tool-part-3.md @@ -154,9 +154,9 @@ flowchart TD #### Acceptance criteria -- [ ] Removing a plugin leaves every user-authored key in the settings file intact -- [ ] An unrecognized capability shape raises a typed error, not a silent fallthrough -- [ ] Removal is idempotent: running it twice changes nothing the second time +- [x] Removing a plugin leaves every user-authored key in the settings file intact +- [x] An unrecognized capability shape raises a typed error, not a silent fallthrough +- [x] Removal is idempotent: running it twice changes nothing the second time ### Phase 3: Wire the command matrix @@ -194,6 +194,8 @@ flowchart TD - [ ] Agents installed by the install path are accepted by the strict frontmatter schema - [ ] Every install-versus-archive difference is either fixed or written down, none left implicit +πŸ€– `pnpm smoke` baseline, measured before phase 2: with the developer's `AIDD_TOKEN` set it reports 48 pass / 20 fail / coverage 86%, every failure cascading from one 401 on that token β€” `setup --source remote` cannot fetch the catalog, so all downstream tool and plugin assertions collapse. With the variable unset it falls back to `gh auth token` and reports **73 pass / 4 fail / coverage 100%**, all 37 leaf commands exercised. The 4 remaining failures are one stale case repeated over four corruption shapes: `setup --plugins recommended` now installs `aidd-dev`, so the fault injection that follows gets `Plugin 'aidd-dev' is already installed.` before any catalog read, and the corrupt-cache path it claims to test is never reached. The `recommended` flag comes from the published remote catalog, not from this repository, so this is data drift and not a regression; the case needs a plugin that is not recommended. Recorded, not fixed here. Worth noting separately that the script promises to SKIP remote sections without a token but FAILS on an invalid one. + ## Amendments @@ -206,10 +208,17 @@ flowchart TD πŸ€– Line references drifted: the translation-mode literal sits at `built-tree-materialization-translator.ts:62` and the ownership check at `:137`, not `:64` and `:120-125`. Both were found and replaced; the ownership rule now accepts the tool's own directory plus the shared `.agents/` root, with `AGENTS_SHARED_ROOT` extracted in `flat-paths.ts` beside the existing skills prefix. +πŸ€– Phase 2's projection names only `plugin-remove-use-case.ts`, but the same qualification drives the *merge* at install time (`mode-b-flat-materialization-translator.ts:108`), and `mergeOpencodeMcp` was hardcoded to opencode's `mcp` key exactly as the unmerge was. Leaving that half alone would have corrupted `.gemini/settings.json` on install rather than on removal, which is worse. Both sides are now parameterized by a `FlatMcpSection` carrying the JSON key and the config's name, the latter so a collision message names the file the user actually has to look at instead of always saying opencode.json. + +πŸ€– `qualifiesForOpencodeMcpMerge` became `flatMcpSectionKey`, returning the declared key or null instead of a boolean. A boolean forced every caller to re-derive the key, which is what made the tool name necessary in the first place. It throws `McpSectionUndeclaredError` when a tool qualifies for the merge but declares no `entrySection`: a default here writes one tool's section into another tool's user-owned file, so failing loudly is the only safe answer. + +πŸ€– `tests/domain/formats/opencode-mcp-merge.unit.test.ts` was updated, which phase 1's "passes without modification" bar does not cover β€” that bar is about the translator suites, and this is the renamed domain API itself. Only call names and one added argument changed; every assertion is untouched, so its regression value is intact. + ## Log - Phase 1: both opencode literals removed from `built-tree-materialization-translator.ts`. The translation mode is read from `PluginsCapability.translationMode`, which already resolves to `"flat"` for flat tools and was simply never consulted. Plugin ownership inside a flat built tree is derived from the tool's own directory plus the shared `.agents/` root instead of a hardcoded `.opencode` segment, matching a plugin-namespaced segment anywhere below the root rather than at a fixed depth, since gemini's agents sit one level shallower than opencode's skills. `FlatPluginsParams` gained `hooksSkipReason` so the skip message comes from the tool rather than a branch. Gemini switched to `{ mode: "flat" }` and the conformance guard was rekeyed on `PluginFormat` (see Amendments). Verified: `pnpm typecheck` (0 errors), `biome check` (clean), unit and integration 2081/2081, e2e 130/131 with the same environment-coupled `auth status` failure, and the grep gate for a tool-name literal under `use-cases/plugin/` returns nothing. +- Phase 2: MCP merging and unmerging are now routed by the tool's declared section key rather than by opencode's shape. `mergeFlatMcpSection` and `unmergeFlatMcpSection` take a `FlatMcpSection` (JSON key plus config name); `flatMcpSectionKey` replaces the boolean qualification and throws `McpSectionUndeclaredError` on a qualifying tool that declares no section. Both call sites, install-time merge and removal-time unmerge, pass the resolved output path as the config name so collision messages name the real file. New `remove-plugin-gemini-mcp.integration.test.ts` (4 cases: servers stripped from `mcpServers` with no stray `mcp` key created, every user key intact including `context.fileName` and `theme`, idempotence, manifest entry removed) and `flat-mcp-section-key.unit.test.ts` (5 cases covering the declared key, the three declines, and the throw). Mutation-checked by hardcoding the key back to `"mcp"`: 2 of the 4 gemini cases fail, the 2 survivors being the ones that assert user keys and manifest state, which a wrong section key does not disturb. Verified: `pnpm typecheck` (0 errors), `biome check` (clean), full `pnpm test` 2220/2221 with the same `auth status` environment failure. Smoke baseline measured separately before this phase: 73 pass, 4 fail, coverage 100%, the 4 being a stale fault-injection case unrelated to this work (see Amendments). ## Validation flow demonstration diff --git a/cli/src/application/use-cases/plugin/plugin-helpers.ts b/cli/src/application/use-cases/plugin/plugin-helpers.ts index 4213c2108..15cfda0cd 100644 --- a/cli/src/application/use-cases/plugin/plugin-helpers.ts +++ b/cli/src/application/use-cases/plugin/plugin-helpers.ts @@ -1,6 +1,7 @@ import { join } from "node:path"; import { McpCapability } from "../../../domain/capabilities/mcp-capability.js"; import type { PluginsCapability } from "../../../domain/capabilities/plugins-capability.js"; +import { McpSectionUndeclaredError } from "../../../domain/errors.js"; import type { InstallationFile } from "../../../domain/models/file.js"; import type { Manifest } from "../../../domain/models/manifest.js"; import type { Plugin } from "../../../domain/models/plugin.js"; @@ -42,13 +43,25 @@ export function resolvePluginBaseDir( return resolvePluginBaseDirForCapability(caps.plugins as PluginsCapability, projectRoot, homedir); } -export function qualifiesForOpencodeMcpMerge(caps: Record): boolean { - if (!("mcp" in caps)) return false; +/** + * The JSON key a flat tool keeps plugin-contributed MCP servers under, or null when the + * tool does not merge them at all. Read from the tool rather than assumed: opencode uses + * `mcp` in opencode.json, gemini `mcpServers` in .gemini/settings.json, and running one + * tool's unmerge against the other's file would leave a junk section behind. + * + * Throws rather than falling back when a tool qualifies but declares no section: a silent + * default here is a wrong write to a user-owned file. + */ +export function flatMcpSectionKey(caps: Record, toolId: string): string | null { + if (!("mcp" in caps)) return null; const mcp = caps.mcp; - if (!(mcp instanceof McpCapability)) return false; - if (mcp.params.mergeStrategy !== "framework-prime") return false; + if (!(mcp instanceof McpCapability)) return null; + if (mcp.params.mergeStrategy !== "framework-prime") return null; const plugins = caps.plugins as PluginsCapability; - return plugins.mode === "flat"; + if (plugins.mode !== "flat") return null; + const key = mcp.params.entrySection; + if (key === undefined) throw new McpSectionUndeclaredError(toolId); + return key; } export async function loadPluginManifest(manifestRepo: ManifestRepository): Promise { diff --git a/cli/src/application/use-cases/plugin/plugin-remove-use-case.ts b/cli/src/application/use-cases/plugin/plugin-remove-use-case.ts index 82b117a55..8d7bf3c69 100644 --- a/cli/src/application/use-cases/plugin/plugin-remove-use-case.ts +++ b/cli/src/application/use-cases/plugin/plugin-remove-use-case.ts @@ -2,7 +2,7 @@ import { homedir as nodeHomedir } from "node:os"; import { dirname, join } from "node:path"; import type { McpCapability } from "../../../domain/capabilities/mcp-capability.js"; import { PluginNotFoundError } from "../../../domain/errors.js"; -import { unmergeOpencodeMcp } from "../../../domain/formats/opencode-mcp-merge.js"; +import { unmergeFlatMcpSection } from "../../../domain/formats/opencode-mcp-merge.js"; import type { Manifest } from "../../../domain/models/manifest.js"; import type { Plugin } from "../../../domain/models/plugin.js"; import type { AiToolId } from "../../../domain/models/tool-ids.js"; @@ -11,8 +11,8 @@ import type { FileWriter } from "../../../domain/ports/file-writer.js"; import type { ManifestRepository } from "../../../domain/ports/manifest-repository.js"; import { getToolConfig, isAiTool } from "../../../domain/tools/registry.js"; import { + flatMcpSectionKey, loadPluginManifest, - qualifiesForOpencodeMcpMerge, resolvePluginBaseDir, resolvePluginToolIds, } from "./plugin-helpers.js"; @@ -67,13 +67,17 @@ export class PluginRemoveUseCase { const toolConfig = getToolConfig(toolId); if (!isAiTool(toolConfig)) return; const caps = toolConfig.capabilities as Record; - if (!qualifiesForOpencodeMcpMerge(caps)) return; + const sectionKey = flatMcpSectionKey(caps, toolId); + if (sectionKey === null) return; const mcpCap = caps.mcp as McpCapability; const outputRelPath = await mcpCap.resolveOutput(projectRoot, this.fs); const outputPath = join(projectRoot, outputRelPath); const existing = await this.readExistingJson(outputPath); if (existing === null) return; - const updated = unmergeOpencodeMcp(existing, plugin.mcpEntries); + const updated = unmergeFlatMcpSection(existing, plugin.mcpEntries, { + key: sectionKey, + configName: outputRelPath, + }); await this.fs.writeFile(outputPath, updated); } diff --git a/cli/src/application/use-cases/plugin/translator/mode-b-flat-materialization-translator.ts b/cli/src/application/use-cases/plugin/translator/mode-b-flat-materialization-translator.ts index 723947015..88e8671e0 100644 --- a/cli/src/application/use-cases/plugin/translator/mode-b-flat-materialization-translator.ts +++ b/cli/src/application/use-cases/plugin/translator/mode-b-flat-materialization-translator.ts @@ -2,7 +2,7 @@ import { join } from "node:path"; import type { McpCapability } from "../../../../domain/capabilities/mcp-capability.js"; import type { PluginsCapability } from "../../../../domain/capabilities/plugins-capability.js"; import { CursorProjectScopeUnsupportedError } from "../../../../domain/errors.js"; -import { mergeOpencodeMcp } from "../../../../domain/formats/opencode-mcp-merge.js"; +import { mergeFlatMcpSection } from "../../../../domain/formats/opencode-mcp-merge.js"; import type { InstallationFile } from "../../../../domain/models/file.js"; import type { Manifest } from "../../../../domain/models/manifest.js"; import { Plugin } from "../../../../domain/models/plugin.js"; @@ -19,7 +19,7 @@ import type { FileWriter } from "../../../../domain/ports/file-writer.js"; import type { Hasher } from "../../../../domain/ports/hasher.js"; import { getToolConfig, isAiTool } from "../../../../domain/tools/registry.js"; import { - qualifiesForOpencodeMcpMerge, + flatMcpSectionKey, resolvePluginBaseDirForCapability, writePluginFiles, } from "../plugin-helpers.js"; @@ -105,10 +105,11 @@ export class ModeBFlatMaterializationTranslator implements PluginTranslator { const toolConfig = getToolConfig(toolId); if (!isAiTool(toolConfig)) return { mcpEntries: new Map(), mcpSkips: [] }; const caps = toolConfig.capabilities as Record; - if (!qualifiesForOpencodeMcpMerge(caps) || dist.components.mcp.length === 0) { + const sectionKey = flatMcpSectionKey(caps, toolId); + if (sectionKey === null || dist.components.mcp.length === 0) { return { mcpEntries: new Map(), mcpSkips: [] }; } - return this.mergeOpencodeMcpEntries(dist, caps, projectRoot, previousMcpEntries, toolId); + return this.mergeMcpEntries(dist, caps, projectRoot, previousMcpEntries, toolId, sectionKey); } private async writeAndRegisterPlugin( @@ -134,12 +135,13 @@ export class ModeBFlatMaterializationTranslator implements PluginTranslator { manifest.addPlugin(toolId, plugin); } - private async mergeOpencodeMcpEntries( + private async mergeMcpEntries( dist: PluginDistribution, caps: Record, projectRoot: string, previousMcpEntries: ReadonlyMap, - toolId: AiToolId + toolId: AiToolId, + sectionKey: string ): Promise<{ mcpEntries: ReadonlyMap; mcpSkips: ReadonlySkipList }> { const mcpCap = caps.mcp as McpCapability; const outputRelPath = await mcpCap.resolveOutput(projectRoot, this.fs); @@ -147,11 +149,12 @@ export class ModeBFlatMaterializationTranslator implements PluginTranslator { const existingContent = await this.readExistingJson(outputPath); const rawMcp = dist.components.mcp[0].content; const transformed = mcpCap.transform(rawMcp); - const { mergedContent, contributedEntries, collisions } = mergeOpencodeMcp( + const { mergedContent, contributedEntries, collisions } = mergeFlatMcpSection( existingContent, transformed, previousMcpEntries, - this.hasher + this.hasher, + { key: sectionKey, configName: outputRelPath } ); if (contributedEntries.size > 0 || previousMcpEntries.size > 0) { await this.fs.writeFile(outputPath, mergedContent); diff --git a/cli/src/domain/errors.ts b/cli/src/domain/errors.ts index d84c9ffda..83e8b3e08 100644 --- a/cli/src/domain/errors.ts +++ b/cli/src/domain/errors.ts @@ -130,6 +130,19 @@ export class InvalidMcpServerConfigError extends Error { } } +/** + * A flat tool merges plugin MCP servers into a shared config but never said under which + * key. Guessing would write another tool's section into this tool's file. + */ +export class McpSectionUndeclaredError extends Error { + constructor(toolId: string) { + super( + `Tool '${toolId}' merges plugin MCP servers but its mcp capability declares no entrySection.` + ); + this.name = "McpSectionUndeclaredError"; + } +} + export class OpencodeDualConfigError extends Error { constructor() { super("Both opencode.json and opencode.jsonc exist. Remove one."); diff --git a/cli/src/domain/formats/opencode-mcp-merge.ts b/cli/src/domain/formats/opencode-mcp-merge.ts index f678275d4..90199b768 100644 --- a/cli/src/domain/formats/opencode-mcp-merge.ts +++ b/cli/src/domain/formats/opencode-mcp-merge.ts @@ -1,12 +1,21 @@ import type { Hasher } from "../ports/hasher.js"; import { stripJsonComments } from "./jsonc.js"; -interface OpencodeMcpSection { - mcp?: Record; +/** + * Where a flat tool keeps its MCP servers. Both the JSON key and the file name vary by + * tool (opencode uses `mcp` in opencode.json, gemini `mcpServers` in .gemini/settings.json), + * so neither may be assumed by the merge. + */ +export interface FlatMcpSection { + readonly key: string; + readonly configName: string; } -const MCP_COLLISION_REASON = - "server already exists in opencode.json (user-owned); plugin entry skipped"; +const OPENCODE_MCP_KEY = "mcp"; + +function collisionReason(configName: string): string { + return `server already exists in ${configName} (user-owned); plugin entry skipped`; +} /** * Merges incoming OpenCode-format MCP servers (already transformed via transformMcpToOpencode) @@ -20,20 +29,21 @@ const MCP_COLLISION_REASON = * Both `existingContent` and `incomingTransformed` must be valid JSON strings produced by * `JSON.stringify(_, null, 2)` (the same serialization as transformMcpToOpencode). */ -export function mergeOpencodeMcp( +export function mergeFlatMcpSection( existingContent: string | null, incomingTransformed: string, previousEntriesForThisPlugin: ReadonlyMap, - hasher: Hasher + hasher: Hasher, + section: FlatMcpSection ): { mergedContent: string; contributedEntries: ReadonlyMap; collisions: ReadonlyArray; } { - const { full, mcp } = parseExisting(existingContent); - const incoming = parseIncoming(incomingTransformed); - const cleaned = stripPreviousEntries(mcp, previousEntriesForThisPlugin); - return applyIncoming(full, cleaned, incoming, previousEntriesForThisPlugin, hasher); + const { full, servers } = parseExisting(existingContent, section.key); + const incoming = parseIncoming(incomingTransformed, section.key); + const cleaned = stripPreviousEntries(servers, previousEntriesForThisPlugin); + return applyIncoming(full, cleaned, incoming, previousEntriesForThisPlugin, hasher, section); } /** @@ -63,7 +73,7 @@ export function buildOpencodeFlatConfig( incoming: Record ): string { const base = JSON.parse(baseConfig) as Record; - const { full, mcp } = parseExisting(existing); + const { full, servers: mcp } = parseExisting(existing, OPENCODE_MCP_KEY); const userKeys = { ...full }; for (const key of Object.keys(base)) delete userKeys[key]; delete userKeys.mcp; @@ -78,36 +88,40 @@ export function buildOpencodeFlatConfig( * Removes servers previously contributed by a plugin from the opencode.json mcp section. * Keys not present in `entries` are preserved untouched. */ -export function unmergeOpencodeMcp( +export function unmergeFlatMcpSection( existingContent: string, - entries: ReadonlyMap + entries: ReadonlyMap, + section: FlatMcpSection ): string { - const parsed = JSON.parse(stripJsonComments(existingContent)) as OpencodeMcpSection; - const mcp = { ...(parsed.mcp ?? {}) }; + const parsed = JSON.parse(stripJsonComments(existingContent)) as Record; + const servers = { ...((parsed[section.key] as Record) ?? {}) }; for (const name of entries.keys()) { - delete mcp[name]; + delete servers[name]; } - return JSON.stringify({ ...parsed, mcp }, null, 2); + return JSON.stringify({ ...parsed, [section.key]: servers }, null, 2); } // ── Private helpers ────────────────────────────────────────────────────────── -function parseExisting(content: string | null): { +function parseExisting( + content: string | null, + sectionKey: string +): { full: Record; - mcp: Record; + servers: Record; } { - if (content === null) return { full: {}, mcp: {} }; - // opencode.json is user-owned and may be JSONC (comments / trailing commas). - const parsed = JSON.parse(stripJsonComments(content)) as OpencodeMcpSection; + if (content === null) return { full: {}, servers: {} }; + // The target config is user-owned and may be JSONC (comments / trailing commas). + const parsed = JSON.parse(stripJsonComments(content)) as Record; return { - full: parsed as Record, - mcp: (parsed.mcp as Record) ?? {}, + full: parsed, + servers: (parsed[sectionKey] as Record) ?? {}, }; } -function parseIncoming(transformed: string): Record { - const parsed = JSON.parse(transformed) as OpencodeMcpSection; - return (parsed.mcp as Record) ?? {}; +function parseIncoming(transformed: string, sectionKey: string): Record { + const parsed = JSON.parse(transformed) as Record; + return (parsed[sectionKey] as Record) ?? {}; } function stripPreviousEntries( @@ -126,7 +140,8 @@ function applyIncoming( cleanedMcp: Record, incoming: Record, previous: ReadonlyMap, - hasher: Hasher + hasher: Hasher, + section: FlatMcpSection ): { mergedContent: string; contributedEntries: ReadonlyMap; @@ -137,12 +152,12 @@ function applyIncoming( const collisions: string[] = []; for (const [name, server] of Object.entries(incoming)) { if (name in cleanedMcp && !previous.has(name)) { - collisions.push(`${name}: ${MCP_COLLISION_REASON}`); + collisions.push(`${name}: ${collisionReason(section.configName)}`); continue; } mcp[name] = server; contributed.set(name, hasher.hash(JSON.stringify(server)).value); } - const mergedContent = JSON.stringify({ ...full, mcp }, null, 2); + const mergedContent = JSON.stringify({ ...full, [section.key]: mcp }, null, 2); return { mergedContent, contributedEntries: contributed, collisions }; } diff --git a/cli/tests/application/use-cases/plugin/flat-mcp-section-key.unit.test.ts b/cli/tests/application/use-cases/plugin/flat-mcp-section-key.unit.test.ts new file mode 100644 index 000000000..4f5ea4e5d --- /dev/null +++ b/cli/tests/application/use-cases/plugin/flat-mcp-section-key.unit.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from "vitest"; +import { flatMcpSectionKey } from "../../../../src/application/use-cases/plugin/plugin-helpers.js"; +import { McpCapability } from "../../../../src/domain/capabilities/mcp-capability.js"; +import { PluginsCapability } from "../../../../src/domain/capabilities/plugins-capability.js"; +import { McpSectionUndeclaredError } from "../../../../src/domain/errors.js"; +import { CONFIG_MCP } from "../../../../src/domain/models/framework.js"; + +const FLAT_PLUGINS = new PluginsCapability({ mode: "flat", flatNamespacePrefix: "aidd-" }); + +function mcpCapability(entrySection?: string): McpCapability { + return new McpCapability({ + outputPath: ".tool/settings.json", + format: "json", + mergeStrategy: "framework-prime", + consumes: [CONFIG_MCP], + ...(entrySection === undefined ? {} : { entrySection }), + }); +} + +describe("flatMcpSectionKey", () => { + it("returns the key the tool declared", () => { + const caps = { mcp: mcpCapability("mcpServers"), plugins: FLAT_PLUGINS }; + + expect(flatMcpSectionKey(caps, "gemini")).toBe("mcpServers"); + }); + + it("declines a tool with no mcp capability", () => { + expect(flatMcpSectionKey({ plugins: FLAT_PLUGINS }, "some-tool")).toBeNull(); + }); + + it("declines a tool whose plugins are not flat", () => { + const caps = { + mcp: mcpCapability("mcpServers"), + plugins: new PluginsCapability({ mode: "unsupported" }), + }; + + expect(flatMcpSectionKey(caps, "some-tool")).toBeNull(); + }); + + it("declines a tool that does not merge under framework-prime", () => { + const caps = { + mcp: new McpCapability({ + outputPath: ".tool/settings.json", + format: "json", + entrySection: "mcpServers", + mergeStrategy: "user-prime", + consumes: [CONFIG_MCP], + }), + plugins: FLAT_PLUGINS, + }; + + expect(flatMcpSectionKey(caps, "some-tool")).toBeNull(); + }); + + it("throws rather than guessing when a qualifying tool declares no section", () => { + const caps = { mcp: mcpCapability(), plugins: FLAT_PLUGINS }; + + expect(() => flatMcpSectionKey(caps, "some-tool")).toThrow(McpSectionUndeclaredError); + }); +}); diff --git a/cli/tests/application/use-cases/plugin/translator/remove-plugin-gemini-mcp.integration.test.ts b/cli/tests/application/use-cases/plugin/translator/remove-plugin-gemini-mcp.integration.test.ts new file mode 100644 index 000000000..66d065e52 --- /dev/null +++ b/cli/tests/application/use-cases/plugin/translator/remove-plugin-gemini-mcp.integration.test.ts @@ -0,0 +1,131 @@ +/** + * Gemini plugin remove: unmerge MCP entries from the shared settings file. + * + * Gemini and opencode share the capability shape that routes MCP merging, but keep their + * servers under different keys β€” `mcpServers` in .gemini/settings.json against `mcp` in + * opencode.json. Running one tool's unmerge against the other's file is the failure this + * suite exists to catch, and .gemini/settings.json is user-owned besides. + */ +import "../../../../../src/domain/tools/ai/gemini.js"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { PluginRemoveUseCase } from "../../../../../src/application/use-cases/plugin/plugin-remove-use-case.js"; +import { ModeBFlatMaterializationTranslator } from "../../../../../src/application/use-cases/plugin/translator/mode-b-flat-materialization-translator.js"; +import { Manifest } from "../../../../../src/domain/models/manifest.js"; +import { PluginDistribution } from "../../../../../src/domain/models/plugin-distribution.js"; +import { DeterministicHasher } from "../../../../helpers/ports/deterministic-hasher.js"; +import { InMemoryFileAdapter } from "../../../../helpers/ports/in-memory-file-adapter.js"; +import { InMemoryManifestRepository } from "../../../../helpers/ports/in-memory-manifest-repository.js"; + +const PROJECT_ROOT = "/test-project"; +const STUB_HOME = "/tmp/test-home"; +const PLUGIN_NAME = "aidd-context"; +const SETTINGS = join(PROJECT_ROOT, ".gemini", "settings.json"); + +const USER_SERVER = { command: "node", args: ["./mine.js"] }; + +const MCP_CONTENT = JSON.stringify({ + mcpServers: { + "plugin-tool": { command: "node", args: ["./server.js"] }, + }, +}); + +/** Keys a real Gemini user already has in this file, which no plugin removal may touch. */ +const USER_SETTINGS = { + context: { fileName: ["AGENTS.md"] }, + theme: "GitHub", + mcpServers: { "user-server": USER_SERVER }, +}; + +function buildDist(): PluginDistribution { + return new PluginDistribution({ + manifest: { name: PLUGIN_NAME, version: "1.0.0" }, + format: "claude", + files: [{ relativePath: ".mcp.json", content: MCP_CONTENT }], + components: { + commands: [], + agents: [], + rules: [], + skills: [], + hooks: [], + mcp: [{ relativePath: ".mcp.json", content: MCP_CONTENT }], + }, + }); +} + +async function installThenRemove(fs: InMemoryFileAdapter, removeTwice = false) { + const hasher = new DeterministicHasher(); + const adapter = new ModeBFlatMaterializationTranslator(fs, hasher, () => STUB_HOME); + const manifest = Manifest.create(); + manifest.addTool("gemini", "test", []); + const manifestRepo = new InMemoryManifestRepository(manifest); + + await adapter.addPlugin( + buildDist(), + "gemini", + { kind: "local", path: "/plugin-source" }, + PROJECT_ROOT, + manifest, + undefined, + "docs" + ); + await manifestRepo.save(manifest); + + const removeUseCase = new PluginRemoveUseCase(fs, manifestRepo); + const run = () => + removeUseCase.execute({ + pluginName: PLUGIN_NAME, + toolIds: ["gemini"], + projectRoot: PROJECT_ROOT, + }); + await run(); + if (removeTwice) await run().catch(() => undefined); + return manifestRepo; +} + +function readSettings(fs: InMemoryFileAdapter): Promise { + return fs.readFile(SETTINGS); +} + +describe("remove gemini plugin: unmerge MCP entries", () => { + it("strips the plugin's servers from mcpServers, not from another tool's key", async () => { + const fs = new InMemoryFileAdapter({ [SETTINGS]: JSON.stringify(USER_SETTINGS, null, 2) }); + + await installThenRemove(fs); + + const parsed = JSON.parse(await readSettings(fs)) as Record; + const servers = parsed.mcpServers as Record; + expect(servers).not.toHaveProperty("plugin-tool"); + expect(parsed).not.toHaveProperty("mcp"); + }); + + it("leaves every user-authored key intact", async () => { + const fs = new InMemoryFileAdapter({ [SETTINGS]: JSON.stringify(USER_SETTINGS, null, 2) }); + + await installThenRemove(fs); + + const parsed = JSON.parse(await readSettings(fs)) as Record; + expect(parsed.theme).toBe("GitHub"); + expect(parsed.context).toEqual({ fileName: ["AGENTS.md"] }); + expect((parsed.mcpServers as Record)["user-server"]).toEqual(USER_SERVER); + }); + + it("is idempotent: a second removal changes nothing", async () => { + const fs = new InMemoryFileAdapter({ [SETTINGS]: JSON.stringify(USER_SETTINGS, null, 2) }); + + await installThenRemove(fs); + const afterFirst = await readSettings(fs); + await installThenRemove(fs, true); + + expect(await readSettings(fs)).toBe(afterFirst); + }); + + it("removes the plugin from the manifest", async () => { + const fs = new InMemoryFileAdapter({ [SETTINGS]: JSON.stringify(USER_SETTINGS, null, 2) }); + + const manifestRepo = await installThenRemove(fs); + + const loaded = await manifestRepo.load(); + expect((loaded?.getPlugins("gemini") ?? []).some((p) => p.name === PLUGIN_NAME)).toBe(false); + }); +}); diff --git a/cli/tests/domain/formats/opencode-mcp-merge.unit.test.ts b/cli/tests/domain/formats/opencode-mcp-merge.unit.test.ts index ef7f23317..13da32544 100644 --- a/cli/tests/domain/formats/opencode-mcp-merge.unit.test.ts +++ b/cli/tests/domain/formats/opencode-mcp-merge.unit.test.ts @@ -1,8 +1,8 @@ import { describe, expect, it } from "vitest"; import { buildOpencodeFlatConfig, - mergeOpencodeMcp, - unmergeOpencodeMcp, + mergeFlatMcpSection, + unmergeFlatMcpSection, } from "../../../src/domain/formats/opencode-mcp-merge.js"; import { DeterministicHasher } from "../../helpers/ports/deterministic-hasher.js"; @@ -20,13 +20,16 @@ function makeExisting(servers: Record): string { return JSON.stringify({ mcp: servers }, null, 2); } -describe("mergeOpencodeMcp", () => { +const OPENCODE_SECTION = { key: "mcp", configName: "opencode.json" }; + +describe("mergeFlatMcpSection", () => { it("merges into empty target", () => { - const { mergedContent, contributedEntries, collisions } = mergeOpencodeMcp( + const { mergedContent, contributedEntries, collisions } = mergeFlatMcpSection( null, makeIncoming({ "my-server": LOCAL_SERVER }), new Map(), - hasher + hasher, + OPENCODE_SECTION ); const parsed = JSON.parse(mergedContent) as { mcp: Record }; expect(parsed.mcp["my-server"]).toEqual(LOCAL_SERVER); @@ -35,11 +38,12 @@ describe("mergeOpencodeMcp", () => { }); it("preserves user-added servers not in incoming", () => { - const { mergedContent } = mergeOpencodeMcp( + const { mergedContent } = mergeFlatMcpSection( makeExisting({ "user-server": REMOTE_SERVER }), makeIncoming({ "plugin-server": LOCAL_SERVER }), new Map(), - hasher + hasher, + OPENCODE_SECTION ); const parsed = JSON.parse(mergedContent) as { mcp: Record }; expect(parsed.mcp["user-server"]).toEqual(REMOTE_SERVER); @@ -49,18 +53,30 @@ describe("mergeOpencodeMcp", () => { it("is idempotent: second merge with same version produces identical output", () => { const incoming = makeIncoming({ "plugin-server": LOCAL_SERVER }); const prev = new Map(); - const first = mergeOpencodeMcp(null, incoming, prev, hasher); + const first = mergeFlatMcpSection(null, incoming, prev, hasher, OPENCODE_SECTION); const secondPrev = first.contributedEntries; - const second = mergeOpencodeMcp(first.mergedContent, incoming, secondPrev, hasher); + const second = mergeFlatMcpSection( + first.mergedContent, + incoming, + secondPrev, + hasher, + OPENCODE_SECTION + ); expect(second.mergedContent).toBe(first.mergedContent); expect([...second.contributedEntries.keys()]).toEqual([...first.contributedEntries.keys()]); }); it("replace path: drops orphaned server from v1, adds new server from v2", () => { const incomingV1 = makeIncoming({ "server-a": LOCAL_SERVER, "server-b": REMOTE_SERVER }); - const v1 = mergeOpencodeMcp(null, incomingV1, new Map(), hasher); + const v1 = mergeFlatMcpSection(null, incomingV1, new Map(), hasher, OPENCODE_SECTION); const incomingV2 = makeIncoming({ "server-a": LOCAL_SERVER, "server-c": DISABLED_SERVER }); - const v2 = mergeOpencodeMcp(v1.mergedContent, incomingV2, v1.contributedEntries, hasher); + const v2 = mergeFlatMcpSection( + v1.mergedContent, + incomingV2, + v1.contributedEntries, + hasher, + OPENCODE_SECTION + ); const parsed = JSON.parse(v2.mergedContent) as { mcp: Record }; expect(parsed.mcp).toHaveProperty("server-a"); expect(parsed.mcp).toHaveProperty("server-c"); @@ -69,11 +85,12 @@ describe("mergeOpencodeMcp", () => { it("skips incoming server that collides with user-owned key (not in previous)", () => { const existing = makeExisting({ "user-server": REMOTE_SERVER }); - const { collisions, contributedEntries } = mergeOpencodeMcp( + const { collisions, contributedEntries } = mergeFlatMcpSection( existing, makeIncoming({ "user-server": LOCAL_SERVER }), new Map(), - hasher + hasher, + OPENCODE_SECTION ); expect(collisions.length).toBeGreaterThan(0); expect(collisions[0]).toContain("user-server"); @@ -82,13 +99,14 @@ describe("mergeOpencodeMcp", () => { it("replaces own server that was previously contributed (no collision)", () => { const incoming = makeIncoming({ "plugin-server": LOCAL_SERVER }); - const first = mergeOpencodeMcp(null, incoming, new Map(), hasher); + const first = mergeFlatMcpSection(null, incoming, new Map(), hasher, OPENCODE_SECTION); const incomingV2 = makeIncoming({ "plugin-server": DISABLED_SERVER }); - const { collisions, mergedContent } = mergeOpencodeMcp( + const { collisions, mergedContent } = mergeFlatMcpSection( first.mergedContent, incomingV2, first.contributedEntries, - hasher + hasher, + OPENCODE_SECTION ); expect(collisions).toHaveLength(0); const parsed = JSON.parse(mergedContent) as { mcp: Record }; @@ -96,22 +114,24 @@ describe("mergeOpencodeMcp", () => { }); it("preserves disabled state (enabled: false) from incoming", () => { - const { mergedContent } = mergeOpencodeMcp( + const { mergedContent } = mergeFlatMcpSection( null, makeIncoming({ "off-server": DISABLED_SERVER }), new Map(), - hasher + hasher, + OPENCODE_SECTION ); const parsed = JSON.parse(mergedContent) as { mcp: Record }; expect((parsed.mcp["off-server"] as { enabled: boolean }).enabled).toBe(false); }); it("returns hashes in contributedEntries for each contributed server", () => { - const { contributedEntries } = mergeOpencodeMcp( + const { contributedEntries } = mergeFlatMcpSection( null, makeIncoming({ alpha: LOCAL_SERVER, beta: REMOTE_SERVER }), new Map(), - hasher + hasher, + OPENCODE_SECTION ); expect(contributedEntries.has("alpha")).toBe(true); expect(contributedEntries.has("beta")).toBe(true); @@ -124,11 +144,12 @@ describe("mergeOpencodeMcp", () => { null, 2 ); - const { mergedContent } = mergeOpencodeMcp( + const { mergedContent } = mergeFlatMcpSection( frameworkDefault, makeIncoming({ "my-server": LOCAL_SERVER }), new Map(), - hasher + hasher, + OPENCODE_SECTION ); const parsed = JSON.parse(mergedContent) as { instructions: string[]; @@ -174,7 +195,7 @@ describe("tolerates a JSONC user-owned opencode.json", () => { it("unmerges from JSONC content without throwing", () => { const entries = new Map([["aidd-context__server", "hash"]]); - expect(() => unmergeOpencodeMcp(JSONC_EXISTING, entries)).not.toThrow(); + expect(() => unmergeFlatMcpSection(JSONC_EXISTING, entries, OPENCODE_SECTION)).not.toThrow(); }); }); @@ -222,11 +243,11 @@ describe("buildOpencodeFlatConfig", () => { }); }); -describe("unmergeOpencodeMcp", () => { +describe("unmergeFlatMcpSection", () => { it("removes only the tracked entries, preserving other servers", () => { const existing = makeExisting({ plugin: LOCAL_SERVER, user: REMOTE_SERVER }); const entries = new Map([["plugin", "somehash"]]); - const result = unmergeOpencodeMcp(existing, entries); + const result = unmergeFlatMcpSection(existing, entries, OPENCODE_SECTION); const parsed = JSON.parse(result) as { mcp: Record }; expect(parsed.mcp).not.toHaveProperty("plugin"); expect(parsed.mcp.user).toEqual(REMOTE_SERVER); @@ -234,7 +255,7 @@ describe("unmergeOpencodeMcp", () => { it("is a no-op when entries map is empty", () => { const existing = makeExisting({ server: LOCAL_SERVER }); - const result = unmergeOpencodeMcp(existing, new Map()); + const result = unmergeFlatMcpSection(existing, new Map(), OPENCODE_SECTION); const parsed = JSON.parse(result) as { mcp: Record }; expect(parsed.mcp.server).toEqual(LOCAL_SERVER); }); @@ -242,8 +263,8 @@ describe("unmergeOpencodeMcp", () => { it("does not fail when a tracked key is absent from existing", () => { const existing = makeExisting({ server: LOCAL_SERVER }); const entries = new Map([["ghost-server", "oldhash"]]); - expect(() => unmergeOpencodeMcp(existing, entries)).not.toThrow(); - const parsed = JSON.parse(unmergeOpencodeMcp(existing, entries)) as { + expect(() => unmergeFlatMcpSection(existing, entries, OPENCODE_SECTION)).not.toThrow(); + const parsed = JSON.parse(unmergeFlatMcpSection(existing, entries, OPENCODE_SECTION)) as { mcp: Record; }; expect(parsed.mcp.server).toEqual(LOCAL_SERVER); From c29cebe58b6b2f13fa92fa4497eaf0a728df4428 Mon Sep 17 00:00:00 2001 From: Thierry Kwiatkowski Date: Wed, 12 Aug 2026 18:09:49 +0200 Subject: [PATCH 16/23] test(cli): cover gemini across the plugin and command surface Gemini joins the smoke tool loop, the e2e install/uninstall matrix, the flat-target translation matrix, and three new integration suites: flat materialization, MCP merge, and built-tree materialization. Smoke goes from 73 to 78 passes at unchanged 100% coverage, so every per-tool ai leaf command now executes for gemini against the built binary. The flat materialization suite records something the plan did not know: install and the archive do not write to the same places. The archive namespaces skills as .agents/skills/-/, install nests them at .gemini/skills///. The suite asserts what install actually does and says it is not the archive's layout, leaving to the real-binary phase whether a skill is discovered at that depth. Restore gains no gemini config reference: gemini consumes only the already-listed config/mcp.json, its settings seed being a bundled asset rather than a framework-sourced file. Co-Authored-By: Claude Opus 5 --- .../2026_07_27-511-gemini-cli-tool-part-3.md | 15 +- cli/scripts/smoke-tools.sh | 4 +- ...gemini-materialization.integration.test.ts | 110 ++++++++++++ ...all-plugin-gemini-flat.integration.test.ts | 92 ++++++++++ ...tall-plugin-gemini-mcp.integration.test.ts | 159 ++++++++++++++++++ .../plugin-content-translator.unit.test.ts | 15 ++ cli/tests/e2e/command-matrix-ai.e2e.test.ts | 31 +++- 7 files changed, 420 insertions(+), 6 deletions(-) create mode 100644 cli/tests/application/use-cases/plugin/translator/built-tree-gemini-materialization.integration.test.ts create mode 100644 cli/tests/application/use-cases/plugin/translator/install-plugin-gemini-flat.integration.test.ts create mode 100644 cli/tests/application/use-cases/plugin/translator/install-plugin-gemini-mcp.integration.test.ts diff --git a/aidd_docs/tasks/2026_07/2026_07_27_gemini-cli-build-target/2026_07_27-511-gemini-cli-tool-part-3.md b/aidd_docs/tasks/2026_07/2026_07_27_gemini-cli-build-target/2026_07_27-511-gemini-cli-tool-part-3.md index 6fad46755..cd4a17981 100644 --- a/aidd_docs/tasks/2026_07/2026_07_27_gemini-cli-build-target/2026_07_27-511-gemini-cli-tool-part-3.md +++ b/aidd_docs/tasks/2026_07/2026_07_27_gemini-cli-build-target/2026_07_27-511-gemini-cli-tool-part-3.md @@ -172,9 +172,9 @@ flowchart TD #### Acceptance criteria -- [ ] Every `ai` leaf command runs for gemini against the built binary -- [ ] The smoke coverage gate is met -- [ ] Smoke ran in a fresh temporary directory with a sandboxed tool home, and the repository working tree is clean afterwards +- [x] Every `ai` leaf command runs for gemini against the built binary +- [x] The smoke coverage gate is met +- [x] Smoke ran in a fresh temporary directory with a sandboxed tool home, and the repository working tree is clean afterwards - [ ] `cd cli && pnpm typecheck && pnpm lint && pnpm test && pnpm smoke` exits 0 ### Phase 4: Verify install parity against the real tool @@ -214,11 +214,20 @@ flowchart TD πŸ€– `tests/domain/formats/opencode-mcp-merge.unit.test.ts` was updated, which phase 1's "passes without modification" bar does not cover β€” that bar is about the translator suites, and this is the renamed domain API itself. Only call names and one added argument changed; every assertion is untouched, so its regression value is intact. +πŸ€– The install path and the archive path do not write to the same places, and this was not known when the plan was written. `framework build` emits skills to `.agents/skills/-/SKILL.md` and agents to `.gemini/agents/-.md`; install emits `.gemini/skills///SKILL.md` and `.gemini/agents//.md` β€” under gemini's own directory, nested by plugin rather than namespaced by it, and one level deeper than the vendor's own layout. The master plan records `.agents/skills/` as an alias that takes precedence over `.gemini/skills/`, so the root itself is legitimate either way; the extra `/` level is the open question. Whether the real binary discovers a skill at that depth is exactly what phase 4 exists to settle, so the integration suite asserts today's real output and says plainly that it is not the archive's, rather than encoding a guess as an expectation. + +πŸ€– `cli/tests/domain/models/plugin-distribution-translate.unit.test.ts` does not exist. Flat-target translation coverage lives in `plugin-content-translator.unit.test.ts` beside the opencode block, and gemini's went there. + +πŸ€– No gemini entry was added to restore's `CONFIG_REFS`, and the plan's conditional is why: gemini's mcp capability consumes only `CONFIG_MCP`, whose `config/mcp.json` is already listed, and its settings seed is a bundled CLI asset (`assets/configs/gemini/settings.json`, reached through `CONFIG_ASSETS`) rather than a framework-sourced file under `config/`. Opencode's entry exists because it additionally consumes `CONFIG_OPENCODE`. Nothing to add. + +πŸ€– The phase's last acceptance criterion, that the whole `typecheck && lint && test && smoke` chain exits 0, is left unticked. Two failures block it and neither is gemini's: `auth status` in the test suite, and the four corrupt-cache smoke cases. Both are recorded above and in part 2's log as environment and data drift. Ticking it would be a false claim; fixing either is outside this part. + ## Log - Phase 1: both opencode literals removed from `built-tree-materialization-translator.ts`. The translation mode is read from `PluginsCapability.translationMode`, which already resolves to `"flat"` for flat tools and was simply never consulted. Plugin ownership inside a flat built tree is derived from the tool's own directory plus the shared `.agents/` root instead of a hardcoded `.opencode` segment, matching a plugin-namespaced segment anywhere below the root rather than at a fixed depth, since gemini's agents sit one level shallower than opencode's skills. `FlatPluginsParams` gained `hooksSkipReason` so the skip message comes from the tool rather than a branch. Gemini switched to `{ mode: "flat" }` and the conformance guard was rekeyed on `PluginFormat` (see Amendments). Verified: `pnpm typecheck` (0 errors), `biome check` (clean), unit and integration 2081/2081, e2e 130/131 with the same environment-coupled `auth status` failure, and the grep gate for a tool-name literal under `use-cases/plugin/` returns nothing. - Phase 2: MCP merging and unmerging are now routed by the tool's declared section key rather than by opencode's shape. `mergeFlatMcpSection` and `unmergeFlatMcpSection` take a `FlatMcpSection` (JSON key plus config name); `flatMcpSectionKey` replaces the boolean qualification and throws `McpSectionUndeclaredError` on a qualifying tool that declares no section. Both call sites, install-time merge and removal-time unmerge, pass the resolved output path as the config name so collision messages name the real file. New `remove-plugin-gemini-mcp.integration.test.ts` (4 cases: servers stripped from `mcpServers` with no stray `mcp` key created, every user key intact including `context.fileName` and `theme`, idempotence, manifest entry removed) and `flat-mcp-section-key.unit.test.ts` (5 cases covering the declared key, the three declines, and the throw). Mutation-checked by hardcoding the key back to `"mcp"`: 2 of the 4 gemini cases fail, the 2 survivors being the ones that assert user keys and manifest state, which a wrong section key does not disturb. Verified: `pnpm typecheck` (0 errors), `biome check` (clean), full `pnpm test` 2220/2221 with the same `auth status` environment failure. Smoke baseline measured separately before this phase: 73 pass, 4 fail, coverage 100%, the 4 being a stale fault-injection case unrelated to this work (see Amendments). +- Phase 3: gemini joins the command surface. `smoke-tools.sh` gains gemini in `AI_TOOLS` and its stale five-tool comment is refreshed; the run goes from 73 to 78 passes at unchanged 100% coverage, so every per-tool `ai` leaf command now executes for gemini against the built binary, and the repository working tree is clean afterwards. `command-matrix-ai.e2e.test.ts` gains the install and uninstall pair. `plugin-content-translator.unit.test.ts` gains a gemini flat-target block (agents, skills, and a guard that gemini writes nothing under another flat tool's directory). Three new integration suites: `built-tree-gemini-materialization` (2 cases, including a file correctly namespaced for the plugin but under another tool's root, which must be ignored), `install-plugin-gemini-flat` (3 cases, recording the real install layout and the tool-specific hooks-skip message), `install-plugin-gemini-mcp` (6 cases: merge under `mcpServers` with no stray `mcp` key, user keys preserved, manifest entries recorded, idempotence, orphan drop on version change, and a user collision naming the real config file). Restore left untouched, deliberately (see Amendments). Verified: `pnpm typecheck` (0 errors), `biome check` (clean), full `pnpm test` 2236/2237, smoke 78/4 at 100% coverage. ## Validation flow demonstration diff --git a/cli/scripts/smoke-tools.sh b/cli/scripts/smoke-tools.sh index 744701653..852769645 100755 --- a/cli/scripts/smoke-tools.sh +++ b/cli/scripts/smoke-tools.sh @@ -2,7 +2,7 @@ # Full-surface smoke against the REAL remote framework + real built binary. # # Goal: exercise EVERY leaf command in the CLI surface, with the per-tool -# commands looped over every AI tool (claude, cursor, copilot, codex, opencode) +# commands looped over every AI tool (claude, cursor, copilot, codex, opencode, gemini) # and IDE tool (vscode). Prints a measured command-coverage percentage. # # Born from a production crash a user hit on install: @@ -19,7 +19,7 @@ ROOT="$(cd "$(dirname "$0")/.." && pwd)" CLI="$ROOT/dist/cli.js" FRAMEWORK_FIXTURE="$ROOT/tests/fixtures/framework" -AI_TOOLS=(claude cursor copilot codex opencode) +AI_TOOLS=(claude cursor copilot codex opencode gemini) IDE_TOOLS=(vscode) # Canonical leaf-command surface. Coverage = exercised / total. diff --git a/cli/tests/application/use-cases/plugin/translator/built-tree-gemini-materialization.integration.test.ts b/cli/tests/application/use-cases/plugin/translator/built-tree-gemini-materialization.integration.test.ts new file mode 100644 index 000000000..2727ae13c --- /dev/null +++ b/cli/tests/application/use-cases/plugin/translator/built-tree-gemini-materialization.integration.test.ts @@ -0,0 +1,110 @@ +import "../../../../../src/domain/tools/ai/gemini.js"; +import { describe, expect, it } from "vitest"; +import { BuiltTreeMaterializationTranslator } from "../../../../../src/application/use-cases/plugin/translator/built-tree-materialization-translator.js"; +import { Manifest } from "../../../../../src/domain/models/manifest.js"; +import { Marketplace } from "../../../../../src/domain/models/marketplace.js"; +import { PluginDistribution } from "../../../../../src/domain/models/plugin-distribution.js"; +import { DeterministicHasher } from "../../../../helpers/ports/deterministic-hasher.js"; +import { fakeEnsureBuiltMarketplace } from "../../../../helpers/ports/fake-ensure-built-marketplace.js"; +import { InMemoryFileAdapter } from "../../../../helpers/ports/in-memory-file-adapter.js"; +import { InMemoryMarketplaceRegistry } from "../../../../helpers/ports/in-memory-marketplace-registry.js"; + +const PROJECT_ROOT = "/proj"; +const BUILT = "/built/gemini"; + +function dist(): PluginDistribution { + return new PluginDistribution({ + manifest: { name: "aidd-vcs", version: "1.0.0" }, + format: "claude", + files: [], + components: { commands: [], agents: [], rules: [], skills: [], hooks: [], mcp: [] }, + }); +} + +async function makeRegistry(): Promise { + const registry = new InMemoryMarketplaceRegistry(); + await registry.save( + PROJECT_ROOT, + Marketplace.create({ + name: "aidd-framework", + source: { kind: "local", path: "/src/framework" }, + scope: "project", + addedAt: "2026-01-01T00:00:00Z", + }) + ); + return registry; +} + +function buildTranslator( + fs: InMemoryFileAdapter, + registry: InMemoryMarketplaceRegistry +): BuiltTreeMaterializationTranslator { + return new BuiltTreeMaterializationTranslator( + fs, + new DeterministicHasher(), + () => "/home/u", + fakeEnsureBuiltMarketplace(), + registry + ); +} + +describe("BuiltTreeMaterializationTranslator β€” gemini (integration)", () => { + /** + * Gemini spans two roots: its own .gemini/ and the shared .agents/ tree, and its agents sit + * one level shallower than opencode's skills. Both are why ownership could not stay a fixed + * segment at a fixed depth under a hardcoded directory. + */ + it("takes the flat branch and copies this plugin's files from both roots", async () => { + const fs = new InMemoryFileAdapter(); + const skill = "Load [assets/x.md](../assets/x.md)"; + fs.setFile(`${BUILT}/.agents/skills/aidd-vcs-01-commit/SKILL.md`, skill); + fs.setFile(`${BUILT}/.gemini/agents/aidd-vcs-helper.md`, "agent body"); + // Another plugin's file, and a build sentinel: neither belongs to this install. + fs.setFile(`${BUILT}/.agents/skills/aidd-dev-00-sdlc/SKILL.md`, "OTHER PLUGIN"); + fs.setFile(`${BUILT}/.build-version`, "5.0.0:1.0.0"); + + const manifest = Manifest.create(); + manifest.addTool("gemini", "test", []); + const translator = buildTranslator(fs, await makeRegistry()); + + await translator.addPlugin( + dist(), + "gemini", + { kind: "local", path: "/plugin-source" }, + PROJECT_ROOT, + manifest, + "aidd-framework", + "docs" + ); + + expect(fs.getFile(`${PROJECT_ROOT}/.agents/skills/aidd-vcs-01-commit/SKILL.md`)).toBe(skill); + expect(fs.getFile(`${PROJECT_ROOT}/.gemini/agents/aidd-vcs-helper.md`)).toBe("agent body"); + expect(fs.has(`${PROJECT_ROOT}/.agents/skills/aidd-dev-00-sdlc/SKILL.md`)).toBe(false); + expect(fs.has(`${PROJECT_ROOT}/.build-version`)).toBe(false); + expect(manifest.getPlugins("gemini").find((p) => p.name === "aidd-vcs")?.files.size).toBe(2); + }); + + it("ignores a file under a root gemini does not own", async () => { + const fs = new InMemoryFileAdapter(); + fs.setFile(`${BUILT}/.agents/skills/aidd-vcs-01-commit/SKILL.md`, "mine"); + // Correctly namespaced for this plugin, but under another tool's directory. + fs.setFile(`${BUILT}/.opencode/skills/aidd-vcs-01-commit/SKILL.md`, "not gemini's"); + + const manifest = Manifest.create(); + manifest.addTool("gemini", "test", []); + const translator = buildTranslator(fs, await makeRegistry()); + + await translator.addPlugin( + dist(), + "gemini", + { kind: "local", path: "/plugin-source" }, + PROJECT_ROOT, + manifest, + "aidd-framework", + "docs" + ); + + expect(fs.has(`${PROJECT_ROOT}/.opencode/skills/aidd-vcs-01-commit/SKILL.md`)).toBe(false); + expect(manifest.getPlugins("gemini").find((p) => p.name === "aidd-vcs")?.files.size).toBe(1); + }); +}); diff --git a/cli/tests/application/use-cases/plugin/translator/install-plugin-gemini-flat.integration.test.ts b/cli/tests/application/use-cases/plugin/translator/install-plugin-gemini-flat.integration.test.ts new file mode 100644 index 000000000..56136100f --- /dev/null +++ b/cli/tests/application/use-cases/plugin/translator/install-plugin-gemini-flat.integration.test.ts @@ -0,0 +1,92 @@ +/** + * Gemini plugin install through the flat translator. + * + * Records the install path's real output, which is NOT the archive's: `framework build` + * writes skills to `.agents/skills/-/` and agents to + * `.gemini/agents/-.md`, while install writes both under `.gemini/`, nested + * by plugin rather than namespaced by it. Whether the real binary discovers the nested shape + * is an empirical question, deliberately left to this part's phase 4 rather than assumed here. + */ +import "../../../../../src/domain/tools/ai/gemini.js"; +import { describe, expect, it } from "vitest"; +import { ModeBFlatMaterializationTranslator } from "../../../../../src/application/use-cases/plugin/translator/mode-b-flat-materialization-translator.js"; +import { Manifest } from "../../../../../src/domain/models/manifest.js"; +import { PluginDistribution } from "../../../../../src/domain/models/plugin-distribution.js"; +import { DeterministicHasher } from "../../../../helpers/ports/deterministic-hasher.js"; +import { InMemoryFileAdapter } from "../../../../helpers/ports/in-memory-file-adapter.js"; + +const PROJECT_ROOT = "/test-project"; +const STUB_HOME = "/tmp/test-home"; +const PLUGIN_NAME = "aidd-context"; + +const SKILL = { + relativePath: "skills/01-brainstorm/SKILL.md", + content: "---\nname: 01-brainstorm\n---\n# Brainstorm", +}; +const AGENT = { relativePath: "agents/coach.md", content: "---\nname: coach\n---\n# Coach" }; +const HOOK = { relativePath: "hooks/hooks.json", content: '{"SessionStart":[]}' }; + +function buildDist(withHooks = false): PluginDistribution { + return new PluginDistribution({ + manifest: { name: PLUGIN_NAME, version: "1.0.0" }, + format: "claude", + files: withHooks ? [SKILL, AGENT, HOOK] : [SKILL, AGENT], + components: { + commands: [], + agents: [AGENT], + rules: [], + skills: [SKILL], + hooks: withHooks ? [HOOK] : [], + mcp: [], + }, + }); +} + +async function install(withHooks = false) { + const fs = new InMemoryFileAdapter(); + const adapter = new ModeBFlatMaterializationTranslator(fs, new DeterministicHasher(), () => + String(STUB_HOME) + ); + const manifest = Manifest.create(); + manifest.addTool("gemini", "test", []); + const result = await adapter.addPlugin( + buildDist(withHooks), + "gemini", + { kind: "local", path: "/plugin-source" }, + PROJECT_ROOT, + manifest, + undefined, + "docs" + ); + return { fs, manifest, result }; +} + +describe("install gemini plugin via the flat translator", () => { + it("materializes skills and agents under the project root", async () => { + const { fs } = await install(); + + expect(fs.has(`${PROJECT_ROOT}/.gemini/skills/${PLUGIN_NAME}/01-brainstorm/SKILL.md`)).toBe( + true + ); + expect(fs.has(`${PROJECT_ROOT}/.gemini/agents/${PLUGIN_NAME}/coach.md`)).toBe(true); + }); + + it("tracks exactly the files it wrote in the manifest", async () => { + const { manifest } = await install(); + + const plugin = manifest.getPlugins("gemini").find((p) => p.name === PLUGIN_NAME); + expect([...(plugin?.files.keys() ?? [])].sort()).toEqual([ + `.gemini/agents/${PLUGIN_NAME}/coach.md`, + `.gemini/skills/${PLUGIN_NAME}/01-brainstorm/SKILL.md`, + ]); + }); + + it("skips declarative hooks and says why, naming gemini rather than another tool", async () => { + const { result } = await install(true); + + const hookSkip = result.skipped.find((s) => s.component === "hooks"); + expect(hookSkip?.toolId).toBe("gemini"); + expect(hookSkip?.reason).toContain(".gemini/settings.json"); + expect(hookSkip?.reason).not.toContain("OpenCode"); + }); +}); diff --git a/cli/tests/application/use-cases/plugin/translator/install-plugin-gemini-mcp.integration.test.ts b/cli/tests/application/use-cases/plugin/translator/install-plugin-gemini-mcp.integration.test.ts new file mode 100644 index 000000000..6d7e99256 --- /dev/null +++ b/cli/tests/application/use-cases/plugin/translator/install-plugin-gemini-mcp.integration.test.ts @@ -0,0 +1,159 @@ +/** + * Gemini plugin install: MCP merge into the shared settings file. + * + * `.gemini/settings.json` is not a file aidd owns β€” a real Gemini user already has one, and + * three writers touch it (settings seed, MCP, hooks). So the merge has to land under + * `mcpServers` specifically, and leave everything else exactly as it found it. + */ +import "../../../../../src/domain/tools/ai/gemini.js"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { ModeBFlatMaterializationTranslator } from "../../../../../src/application/use-cases/plugin/translator/mode-b-flat-materialization-translator.js"; +import { Manifest } from "../../../../../src/domain/models/manifest.js"; +import { PluginDistribution } from "../../../../../src/domain/models/plugin-distribution.js"; +import { DeterministicHasher } from "../../../../helpers/ports/deterministic-hasher.js"; +import { InMemoryFileAdapter } from "../../../../helpers/ports/in-memory-file-adapter.js"; + +const PROJECT_ROOT = "/test-project"; +const STUB_HOME = "/tmp/test-home"; +const PLUGIN_NAME = "aidd-context"; +const SETTINGS = join(PROJECT_ROOT, ".gemini", "settings.json"); + +const MCP_CONTENT = JSON.stringify({ + mcpServers: { + "local-tool": { command: "node", args: ["./server.js"] }, + "remote-tool": { url: "https://example.com/mcp" }, + }, +}); + +const MCP_CONTENT_V2 = JSON.stringify({ + mcpServers: { "remote-tool": { url: "https://example.com/v2" } }, +}); + +/** What a Gemini user already has before aidd ever writes here. */ +const USER_SETTINGS = { + context: { fileName: ["AGENTS.md"] }, + theme: "GitHub", + mcpServers: { "user-server": { command: "node", args: ["./mine.js"] } }, +}; + +function buildDist(mcpContent = MCP_CONTENT): PluginDistribution { + return new PluginDistribution({ + manifest: { name: PLUGIN_NAME, version: "1.0.0" }, + format: "claude", + files: [{ relativePath: ".mcp.json", content: mcpContent }], + components: { + commands: [], + agents: [], + rules: [], + skills: [], + hooks: [], + mcp: [{ relativePath: ".mcp.json", content: mcpContent }], + }, + }); +} + +function buildAdapter(seed: Record = {}) { + const fs = new InMemoryFileAdapter(seed); + const adapter = new ModeBFlatMaterializationTranslator(fs, new DeterministicHasher(), () => + String(STUB_HOME) + ); + return { fs, adapter }; +} + +async function addPlugin( + adapter: ModeBFlatMaterializationTranslator, + manifest: Manifest, + dist: PluginDistribution, + previous: ReadonlyMap = new Map() +) { + return adapter.addPlugin( + dist, + "gemini", + { kind: "local", path: "/plugin-source" }, + PROJECT_ROOT, + manifest, + undefined, + "docs", + previous + ); +} + +function freshManifest(): Manifest { + const manifest = Manifest.create(); + manifest.addTool("gemini", "test", []); + return manifest; +} + +describe("install gemini plugin with MCP", () => { + it("merges servers under mcpServers, creating no section of its own", async () => { + const { fs, adapter } = buildAdapter(); + + await addPlugin(adapter, freshManifest(), buildDist()); + + const parsed = JSON.parse(await fs.readFile(SETTINGS)) as Record; + expect(Object.keys(parsed.mcpServers as object).sort()).toEqual(["local-tool", "remote-tool"]); + expect(parsed).not.toHaveProperty("mcp"); + }); + + it("leaves the user's own settings keys untouched", async () => { + const { fs, adapter } = buildAdapter({ [SETTINGS]: JSON.stringify(USER_SETTINGS, null, 2) }); + + await addPlugin(adapter, freshManifest(), buildDist()); + + const parsed = JSON.parse(await fs.readFile(SETTINGS)) as Record; + expect(parsed.theme).toBe("GitHub"); + expect(parsed.context).toEqual({ fileName: ["AGENTS.md"] }); + expect(parsed.mcpServers).toHaveProperty("user-server"); + }); + + it("records the contributed servers in the manifest", async () => { + const { adapter } = buildAdapter(); + const manifest = freshManifest(); + + await addPlugin(adapter, manifest, buildDist()); + + const plugin = manifest.getPlugins("gemini").find((p) => p.name === PLUGIN_NAME); + expect([...(plugin?.mcpEntries.keys() ?? [])].sort()).toEqual(["local-tool", "remote-tool"]); + }); + + it("is idempotent: reinstalling the same version leaves the file byte-equal", async () => { + const { fs, adapter } = buildAdapter({ [SETTINGS]: JSON.stringify(USER_SETTINGS, null, 2) }); + const manifest = freshManifest(); + const first = await addPlugin(adapter, manifest, buildDist()); + const afterFirst = await fs.readFile(SETTINGS); + + const plugin = manifest.getPlugins("gemini").find((p) => p.name === PLUGIN_NAME); + manifest.removePlugin("gemini", PLUGIN_NAME); + await addPlugin(adapter, manifest, buildDist(), plugin?.mcpEntries ?? new Map()); + + expect(first.skipped).toEqual([]); + expect(await fs.readFile(SETTINGS)).toBe(afterFirst); + }); + + it("drops servers the plugin no longer ships when its version changes", async () => { + const { fs, adapter } = buildAdapter(); + const manifest = freshManifest(); + await addPlugin(adapter, manifest, buildDist()); + const previous = manifest.getPlugins("gemini")[0]?.mcpEntries ?? new Map(); + manifest.removePlugin("gemini", PLUGIN_NAME); + + await addPlugin(adapter, manifest, buildDist(MCP_CONTENT_V2), previous); + + const servers = (JSON.parse(await fs.readFile(SETTINGS)) as Record) + .mcpServers as Record; + expect(servers).not.toHaveProperty("local-tool"); + expect(servers).toHaveProperty("remote-tool"); + }); + + it("skips a server whose name a user already owns, and says which file", async () => { + const seeded = { mcpServers: { "local-tool": { command: "node", args: ["./user.js"] } } }; + const { adapter } = buildAdapter({ [SETTINGS]: JSON.stringify(seeded, null, 2) }); + + const result = await addPlugin(adapter, freshManifest(), buildDist()); + + const collision = result.skipped.find((s) => s.component === "mcp"); + expect(collision?.reason).toContain("local-tool"); + expect(collision?.reason).toContain(".gemini/settings.json"); + }); +}); diff --git a/cli/tests/domain/models/plugin-content-translator.unit.test.ts b/cli/tests/domain/models/plugin-content-translator.unit.test.ts index 43f774288..f5e1d4381 100644 --- a/cli/tests/domain/models/plugin-content-translator.unit.test.ts +++ b/cli/tests/domain/models/plugin-content-translator.unit.test.ts @@ -9,6 +9,7 @@ import { claude } from "../../../src/domain/tools/ai/claude.js"; import { codex } from "../../../src/domain/tools/ai/codex.js"; import { copilot } from "../../../src/domain/tools/ai/copilot.js"; import { cursor } from "../../../src/domain/tools/ai/cursor.js"; +import { gemini } from "../../../src/domain/tools/ai/gemini.js"; import { opencode } from "../../../src/domain/tools/ai/opencode.js"; import { vscodeToolConfig } from "../../../src/domain/tools/ide/vscode.js"; import type { ToolConfig } from "../../../src/domain/tools/registry.js"; @@ -235,6 +236,20 @@ describe("PluginContentTranslator.translate()", () => { }); }); +describe("gemini target (flat mode)", () => { + it("emits agents under .gemini/agents/sample-plugin/", () => { + expect(pathsFor(gemini)).toContain(".gemini/agents/sample-plugin/reviewer.md"); + }); + + it("emits skills under .gemini/skills/sample-plugin/", () => { + expect(pathsFor(gemini)).toContain(".gemini/skills/sample-plugin/hello/SKILL.md"); + }); + + it("emits nothing under another flat tool's directory", () => { + expect(pathsFor(gemini).some((p) => p.startsWith(".opencode/"))).toBe(false); + }); +}); + describe("cross-format matrix (source Γ— target)", () => { const sourceFormats = [ { format: "claude" as const, manifestPath: ".claude-plugin/plugin.json" }, diff --git a/cli/tests/e2e/command-matrix-ai.e2e.test.ts b/cli/tests/e2e/command-matrix-ai.e2e.test.ts index 4037b2962..d1119d21e 100644 --- a/cli/tests/e2e/command-matrix-ai.e2e.test.ts +++ b/cli/tests/e2e/command-matrix-ai.e2e.test.ts @@ -42,7 +42,7 @@ async function seedWithVscode(projectDir: string, fakeHome: string): Promise { +describe.concurrent("Command Matrix: AI install/uninstall (copilot, codex, opencode, gemini)", () => { it("ai install copilot exits 0 and reports installed", async () => { const { projectDir, fakeHome, cleanup } = await createTestEnv("ai-copilot-install"); try { @@ -147,6 +147,35 @@ describe.concurrent("Command Matrix: AI install/uninstall (copilot, codex, openc } }); + it("ai install gemini exits 0 and reports installed", async () => { + const { projectDir, fakeHome, cleanup } = await createTestEnv("ai-gemini-install"); + try { + await seedManifest(projectDir); + const { stdout, exitCode } = await runCli(["ai", "install", "gemini"], projectDir, fakeHome); + expect(exitCode).toBe(0); + expect(stdout).toContain("gemini"); + } finally { + await cleanup(); + } + }); + + it("ai uninstall gemini exits 0", async () => { + const { projectDir, fakeHome, cleanup } = await createTestEnv("ai-gemini-uninstall"); + try { + await seedManifest(projectDir); + await runCli(["ai", "install", "gemini"], projectDir, fakeHome); + const { stdout, exitCode } = await runCli( + ["ai", "uninstall", "gemini"], + projectDir, + fakeHome + ); + expect(exitCode).toBe(0); + expect(stdout).toContain("gemini"); + } finally { + await cleanup(); + } + }); + it("ai install vscode exits 1 β€” cross-category rejection", async () => { const { projectDir, fakeHome, cleanup } = await createTestEnv("ai-cross-category"); try { From ed36066243ff5865d93233015bd3ca8b37d069cf Mon Sep 17 00:00:00 2001 From: Thierry Kwiatkowski Date: Wed, 12 Aug 2026 18:12:23 +0200 Subject: [PATCH 17/23] docs(plan): record gemini install parity measured against the real binary Marketplace-sourced install reaches parity with the archive: 88 skills discovered and enabled, 3 agents loaded with no AgentLoadError. Two gaps found, both recorded rather than fixed. The raw local-path install fallback writes skills nested one level deeper than the vendor's layout, and the binary discovers zero of them; the cause is that flat skill translation never consults the capability's install path, unlike agents and rules, which predates gemini and would move every flat tool's output. And .gemini/settings.json is never written by any install path, so hooks and the AGENTS.md context wiring are absent. Also corrects the previous phase's claim that install and the archive diverge: only the fallback path does. Co-Authored-By: Claude Opus 5 --- .../2026_07_27-511-gemini-cli-tool-part-3.md | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/aidd_docs/tasks/2026_07/2026_07_27_gemini-cli-build-target/2026_07_27-511-gemini-cli-tool-part-3.md b/aidd_docs/tasks/2026_07/2026_07_27_gemini-cli-build-target/2026_07_27-511-gemini-cli-tool-part-3.md index cd4a17981..4b05b2ef0 100644 --- a/aidd_docs/tasks/2026_07/2026_07_27_gemini-cli-build-target/2026_07_27-511-gemini-cli-tool-part-3.md +++ b/aidd_docs/tasks/2026_07/2026_07_27_gemini-cli-build-target/2026_07_27-511-gemini-cli-tool-part-3.md @@ -190,9 +190,9 @@ flowchart TD #### Acceptance criteria -- [ ] Skills installed by the install path are discovered by the real binary -- [ ] Agents installed by the install path are accepted by the strict frontmatter schema -- [ ] Every install-versus-archive difference is either fixed or written down, none left implicit +- [x] Skills installed by the install path are discovered by the real binary +- [x] Agents installed by the install path are accepted by the strict frontmatter schema +- [x] Every install-versus-archive difference is either fixed or written down, none left implicit πŸ€– `pnpm smoke` baseline, measured before phase 2: with the developer's `AIDD_TOKEN` set it reports 48 pass / 20 fail / coverage 86%, every failure cascading from one 401 on that token β€” `setup --source remote` cannot fetch the catalog, so all downstream tool and plugin assertions collapse. With the variable unset it falls back to `gh auth token` and reports **73 pass / 4 fail / coverage 100%**, all 37 leaf commands exercised. The 4 remaining failures are one stale case repeated over four corruption shapes: `setup --plugins recommended` now installs `aidd-dev`, so the fault injection that follows gets `Plugin 'aidd-dev' is already installed.` before any catalog read, and the corrupt-cache path it claims to test is never reached. The `recommended` flag comes from the published remote catalog, not from this repository, so this is data drift and not a regression; the case needs a plugin that is not recommended. Recorded, not fixed here. Worth noting separately that the script promises to SKIP remote sections without a token but FAILS on an invalid one. @@ -222,12 +222,19 @@ flowchart TD πŸ€– The phase's last acceptance criterion, that the whole `typecheck && lint && test && smoke` chain exits 0, is left unticked. Two failures block it and neither is gemini's: `auth status` in the test suite, and the four corrupt-cache smoke cases. Both are recorded above and in part 2's log as environment and data drift. Ticking it would be a false claim; fixing either is outside this part. +πŸ€– Phase 3's amendment above overstates the install-versus-archive divergence, and phase 4's evidence corrects it. There are two install paths, not one. A marketplace-sourced install goes through `BuiltTreeMaterializationTranslator`, which copies the built tree verbatim and therefore reproduces the archive exactly. Only the raw local-path fallback, `ModeBFlatMaterializationTranslator`, produces the nested `.gemini/skills///` shape. The integration suite exercises the fallback, which is what it records. + +πŸ€– The nested fallback shape is not a cosmetic divergence: it is broken. Installed into a trusted project against Gemini CLI 0.52.0, `gemini skills list --all` discovers **zero** skills from `.gemini/skills/aidd-vcs//SKILL.md`, where the same binary discovers 88 from the marketplace install's `.agents/skills/-/`. The cause is in `plugin-content-translator.ts`: `translateSkill` returns the file's plugin-relative path untouched, so the flat writer prefixes `/skills//`, and `SkillsCapability.buildInstallPath` β€” which gemini declares as `.agents/skills/aidd-/SKILL.md` β€” is never consulted. `translateMarkdown` does consult it for agents and rules; skills are the exception. This predates gemini and applies to every flat tool, so fixing it moves codex's and opencode's flat install output too. Left unfixed and escalated rather than changed unilaterally at the end of this part. + +πŸ€– `.gemini/settings.json` is never written by the install path at all. `aidd setup --ai gemini` reports "Installed gemini (0 files)": gemini's `AiTool` declares no `settings` capability, so `buildConfigFiles` has nothing to write, and the built-tree translator correctly skips the file because it is a shared merged target rather than plugin-namespaced content. The consequences are measured, not inferred: `Hook registry initialized with 0 hook entries`, and no `context.fileName`, so `AGENTS.md` is never read as context. Part 1 already recorded that closing this needs a `SettingsCapability` change β€” it only supports generic merge-strategy enums or static content, not the idempotent `context.fileName` array union β€” so it stays a known flat-mode install limitation, to be documented for users in part 4. + ## Log - Phase 1: both opencode literals removed from `built-tree-materialization-translator.ts`. The translation mode is read from `PluginsCapability.translationMode`, which already resolves to `"flat"` for flat tools and was simply never consulted. Plugin ownership inside a flat built tree is derived from the tool's own directory plus the shared `.agents/` root instead of a hardcoded `.opencode` segment, matching a plugin-namespaced segment anywhere below the root rather than at a fixed depth, since gemini's agents sit one level shallower than opencode's skills. `FlatPluginsParams` gained `hooksSkipReason` so the skip message comes from the tool rather than a branch. Gemini switched to `{ mode: "flat" }` and the conformance guard was rekeyed on `PluginFormat` (see Amendments). Verified: `pnpm typecheck` (0 errors), `biome check` (clean), unit and integration 2081/2081, e2e 130/131 with the same environment-coupled `auth status` failure, and the grep gate for a tool-name literal under `use-cases/plugin/` returns nothing. - Phase 2: MCP merging and unmerging are now routed by the tool's declared section key rather than by opencode's shape. `mergeFlatMcpSection` and `unmergeFlatMcpSection` take a `FlatMcpSection` (JSON key plus config name); `flatMcpSectionKey` replaces the boolean qualification and throws `McpSectionUndeclaredError` on a qualifying tool that declares no section. Both call sites, install-time merge and removal-time unmerge, pass the resolved output path as the config name so collision messages name the real file. New `remove-plugin-gemini-mcp.integration.test.ts` (4 cases: servers stripped from `mcpServers` with no stray `mcp` key created, every user key intact including `context.fileName` and `theme`, idempotence, manifest entry removed) and `flat-mcp-section-key.unit.test.ts` (5 cases covering the declared key, the three declines, and the throw). Mutation-checked by hardcoding the key back to `"mcp"`: 2 of the 4 gemini cases fail, the 2 survivors being the ones that assert user keys and manifest state, which a wrong section key does not disturb. Verified: `pnpm typecheck` (0 errors), `biome check` (clean), full `pnpm test` 2220/2221 with the same `auth status` environment failure. Smoke baseline measured separately before this phase: 73 pass, 4 fail, coverage 100%, the 4 being a stale fault-injection case unrelated to this work (see Amendments). - Phase 3: gemini joins the command surface. `smoke-tools.sh` gains gemini in `AI_TOOLS` and its stale five-tool comment is refreshed; the run goes from 73 to 78 passes at unchanged 100% coverage, so every per-tool `ai` leaf command now executes for gemini against the built binary, and the repository working tree is clean afterwards. `command-matrix-ai.e2e.test.ts` gains the install and uninstall pair. `plugin-content-translator.unit.test.ts` gains a gemini flat-target block (agents, skills, and a guard that gemini writes nothing under another flat tool's directory). Three new integration suites: `built-tree-gemini-materialization` (2 cases, including a file correctly namespaced for the plugin but under another tool's root, which must be ignored), `install-plugin-gemini-flat` (3 cases, recording the real install layout and the tool-specific hooks-skip message), `install-plugin-gemini-mcp` (6 cases: merge under `mcpServers` with no stray `mcp` key, user keys preserved, manifest entries recorded, idempotence, orphan drop on version change, and a user collision naming the real config file). Restore left untouched, deliberately (see Amendments). Verified: `pnpm typecheck` (0 errors), `biome check` (clean), full `pnpm test` 2236/2237, smoke 78/4 at 100% coverage. +- Phase 4: install parity measured against the real `gemini` 0.52.0 binary, in a fresh temporary project with a sandboxed home and folder trust disabled. Marketplace-sourced install reaches full parity with the archive: 364 files, skills at `.agents/skills/-/` with all 88 discovered and `[Enabled]`, agents at `.gemini/agents/-.md` with `[AgentRegistry] Loaded with 3 agents.` and no `AgentLoadError`. Two gaps found and recorded rather than fixed, both escalated: the raw local-path install fallback writes a nested skills shape the binary discovers nothing from, and `.gemini/settings.json` is never written by any install path, leaving hooks and the `AGENTS.md` context wiring absent. See Amendments for the causes and the reason each is left alone. ## Validation flow demonstration From 5a3c64be6a63590516dd74b18e8ea832f6f9b81c Mon Sep 17 00:00:00 2001 From: Thierry Kwiatkowski Date: Wed, 12 Aug 2026 19:07:38 +0200 Subject: [PATCH 18/23] docs(aidd-context): teach the framework's tables about gemini The memory hook rejected `gemini` outright, and eleven reference tables enumerated five tools. Both now know the sixth. Every edit is a row added beside an unchanged claude row, except two hook tables that gain a Gemini column and so rewrite every row without changing a single claude cell. Hook event names come from the mapping shipped in `gemini hooks migrate`, read out of the 0.52.0 bundle: Gemini renames most moments and folds turn stop and subagent stop onto one event. The golden snapshot is red from here until the re-baseline phase, which is what that phase exists for. Co-Authored-By: Claude Opus 5 --- .../2026_07_27-511-gemini-cli-tool-part-4.md | 13 ++++-- plugins/aidd-context/hooks/update_memory.js | 1 + .../00-onboard/references/state/detection.md | 1 + .../02-project-memory/references/tools.md | 1 + .../references/tool-detect.md | 1 + .../references/tool-write.md | 2 + .../05-rule-generate/references/tool-paths.md | 3 ++ .../references/tool-paths.md | 5 ++- .../references/tool-paths.md | 2 + .../08-hook-generate/references/tool-paths.md | 42 +++++++++++-------- .../10-learn/references/sync-arguments.md | 1 + .../11-explore/references/ai-mapping.md | 4 ++ .../skills/11-explore/scripts/list-rules.mjs | 4 +- 13 files changed, 56 insertions(+), 24 deletions(-) diff --git a/aidd_docs/tasks/2026_07/2026_07_27_gemini-cli-build-target/2026_07_27-511-gemini-cli-tool-part-4.md b/aidd_docs/tasks/2026_07/2026_07_27_gemini-cli-build-target/2026_07_27-511-gemini-cli-tool-part-4.md index 6582a72b5..e97d5ecc6 100644 --- a/aidd_docs/tasks/2026_07/2026_07_27_gemini-cli-build-target/2026_07_27-511-gemini-cli-tool-part-4.md +++ b/aidd_docs/tasks/2026_07/2026_07_27_gemini-cli-build-target/2026_07_27-511-gemini-cli-tool-part-4.md @@ -140,10 +140,10 @@ flowchart TD #### Acceptance criteria -- [ ] The memory hook writes the project memory block into `AGENTS.md` for gemini -- [ ] The hook and its mirror reference file agree -- [ ] The diff shows only added lines in the multi-tool tables, no modified claude row -- [ ] The rules-listing script reports gemini rules when a gemini rules directory exists +- [x] The memory hook writes the project memory block into `AGENTS.md` for gemini +- [x] The hook and its mirror reference file agree +- [x] The diff shows only added lines in the multi-tool tables, no modified claude row +- [x] The rules-listing script reports gemini rules when a gemini rules directory exists ### Phase 2: Publish the prerequisites and the mapping @@ -203,9 +203,14 @@ flowchart TD +πŸ€– The additive-only criterion needed reading rather than counting. Two tables in `08-hook-generate/references/tool-paths.md` gain a Gemini *column*, which rewrites every row in the diff: 18 lines removed, 24 added. No Claude cell changed value β€” each row simply grew one column β€” so the intent of the rule holds even though `git diff` shows deletions. The only other removals are the sentence "Agents are supported on all five tools", now six, and one doc-comment line in the rules script. Every other file is pure insertion. + +πŸ€– Two limits found in part 3's phase 4 belong in this part's user-facing documentation, and neither was known when this plan was written. The install path never writes `.gemini/settings.json`, so an installed gemini has no hooks and no `AGENTS.md` context wiring β€” only the archive carries those. And a plugin installed from a raw local path (no marketplace) lands skills at a nesting depth the real binary discovers nothing from. Both are recorded in phase 2's documentation tasks rather than left to the reader to discover. + ## Log +- Phase 1: gemini added to the memory hook's tool-to-context-file map (`AGENTS.md`) and to eleven reference tables across `aidd-context`: onboarding detection, project-memory tools, skill-generate detection and write targets plus its strict frontmatter row, rule-generate (unsupported, with the AGENTS.md fallback), agent-generate (path, strict-schema frontmatter, detection), command-generate (unsupported, steer to a skill), hook-generate (support row, a full event column, the settings file shape, and the trust-gated scope), learn's sync arguments, explore's four mapping tables, and the rules-listing script. Hook event names are the verified `GEMINI_HOOK_EVENT_MAP` read from the 0.52.0 bundle, including the two Claude moments that both fold onto `AfterAgent`. Verified: `node update_memory.js gemini` exits 0 and writes the memory block into `AGENTS.md`, where it previously exited 1 with `unknown tool gemini`; `node --check` passes on the rules script. The golden snapshot is knowingly red from here until phase 3 re-baselines it, which is this part's whole point. ## Validation flow demonstration diff --git a/plugins/aidd-context/hooks/update_memory.js b/plugins/aidd-context/hooks/update_memory.js index f6db0c9e2..8d0bcfc85 100644 --- a/plugins/aidd-context/hooks/update_memory.js +++ b/plugins/aidd-context/hooks/update_memory.js @@ -52,6 +52,7 @@ const TOOL_FILES = { cursor: "AGENTS.md", opencode: "AGENTS.md", copilot: ".github/copilot-instructions.md", + gemini: "AGENTS.md", }; // ── Helpers ─────────────────────────────────────────────────────── diff --git a/plugins/aidd-context/skills/00-onboard/references/state/detection.md b/plugins/aidd-context/skills/00-onboard/references/state/detection.md index f66011153..6e7cda8d2 100644 --- a/plugins/aidd-context/skills/00-onboard/references/state/detection.md +++ b/plugins/aidd-context/skills/00-onboard/references/state/detection.md @@ -11,6 +11,7 @@ A tool is used when its own dir exists, or when a file only that tool reads exis | cursor | `.cursor/` or `.cursorrules` | `AGENTS.md` | | opencode | `.opencode/` | `AGENTS.md` | | copilot | `.github/copilot-instructions.md` or `.github/{instructions,agents,skills,prompts}/` | `.github/copilot-instructions.md` | +| gemini | `.gemini/` | `AGENTS.md` | - Detected tools only. An unused optional tool is omitted, never crossed. - No tool detected at all: the row reads `none yet`, uncrossed. The memory row's `❌` already carries the gap. diff --git a/plugins/aidd-context/skills/02-project-memory/references/tools.md b/plugins/aidd-context/skills/02-project-memory/references/tools.md index c0c470c12..00bd77678 100644 --- a/plugins/aidd-context/skills/02-project-memory/references/tools.md +++ b/plugins/aidd-context/skills/02-project-memory/references/tools.md @@ -9,6 +9,7 @@ The AI tools a project can use. | cursor | `.cursor/` or `.cursorrules` | `AGENTS.md` | | opencode | `.opencode/` | `AGENTS.md` | | copilot | `.github/copilot-instructions.md` or `.github/{instructions,agents,skills,prompts}/` | `.github/copilot-instructions.md` | +| gemini | `.gemini/` | `AGENTS.md` | - A shared `AGENTS.md` is a wiring target, never a detection signal. - Tools sharing a context file wire it once; the block serves them all. diff --git a/plugins/aidd-context/skills/04-skill-generate/references/tool-detect.md b/plugins/aidd-context/skills/04-skill-generate/references/tool-detect.md index 286dbc7a3..bf8dbe494 100644 --- a/plugins/aidd-context/skills/04-skill-generate/references/tool-detect.md +++ b/plugins/aidd-context/skills/04-skill-generate/references/tool-detect.md @@ -9,5 +9,6 @@ Which AI tools a project has installed. | OpenCode | `.opencode/` | | GitHub Copilot | `.github/copilot-instructions.md` | | Codex CLI | `.codex/` | +| Gemini CLI | `.gemini/` | A bare `AGENTS.md` means Cursor, OpenCode, or Codex. Several signals can coexist. diff --git a/plugins/aidd-context/skills/04-skill-generate/references/tool-write.md b/plugins/aidd-context/skills/04-skill-generate/references/tool-write.md index 2863f4ccd..95549d624 100644 --- a/plugins/aidd-context/skills/04-skill-generate/references/tool-write.md +++ b/plugins/aidd-context/skills/04-skill-generate/references/tool-write.md @@ -11,6 +11,7 @@ Where a skill tree is written, per tool. | OpenCode | `.opencode/skills//` | | GitHub Copilot | `.github/skills//` | | Codex CLI | `.agents/skills//` | +| Gemini CLI | `.agents/skills//` | | Plugin source | `plugins//skills//` | ## Frontmatter @@ -23,3 +24,4 @@ Emit `description` always, `name` only where listed, drop the rest. | Cursor, GitHub Copilot | `name`, `description`, opt `allowed-tools` | | OpenCode | `description`, opt `permission` map | | Codex CLI | `name`, `description` (strips the rest) | +| Gemini CLI | `name`, `description` (strict schema, rejects the rest) | diff --git a/plugins/aidd-context/skills/05-rule-generate/references/tool-paths.md b/plugins/aidd-context/skills/05-rule-generate/references/tool-paths.md index 8684504e2..44ba99339 100644 --- a/plugins/aidd-context/skills/05-rule-generate/references/tool-paths.md +++ b/plugins/aidd-context/skills/05-rule-generate/references/tool-paths.md @@ -11,6 +11,7 @@ The per-tool rules path and write targets. Rule slice only, nothing about skills | GitHub Copilot | `.github/instructions/-.instructions.md` | yes (flat) | | OpenCode | - | no | | Codex CLI | - | no | +| Gemini CLI | - | no | `` is the file name `#-slug` from `rule-authoring.md` (e.g. `2-python-fstrings`). `` is that slug with its leading category digit dropped (`python-fstrings`). `` is the folder `-`, the zero-padded category index plus the category name from the taxonomy, e.g. `01-standards`. `` is that same two-digit index. @@ -19,6 +20,7 @@ Copilot is flat: no category folder. Its file is `-`, e.g. `2-python-f When a tool does not support rules, skip it and say what to do instead: - **OpenCode**: no rules surface. Add the convention to AGENTS.md, or list its path under `instructions:` in opencode.json. - **Codex CLI**: rules are skipped at install. Keep the convention in AGENTS.md. +- **Gemini CLI**: no rules surface. Keep the convention in AGENTS.md, which Gemini reads once `context.fileName` names it. ## Scope frontmatter per tool @@ -39,6 +41,7 @@ A multi-glob `paths` becomes a comma-joined string for Cursor and Copilot, or th | `.claude/` or `CLAUDE.md` | Claude Code | | `.cursor/` | Cursor | | `.github/copilot-instructions.md` | GitHub Copilot | +| `.gemini/` | Gemini CLI | ## Write targets diff --git a/plugins/aidd-context/skills/06-agent-generate/references/tool-paths.md b/plugins/aidd-context/skills/06-agent-generate/references/tool-paths.md index 5ca7a1250..2141e1c43 100644 --- a/plugins/aidd-context/skills/06-agent-generate/references/tool-paths.md +++ b/plugins/aidd-context/skills/06-agent-generate/references/tool-paths.md @@ -11,8 +11,9 @@ The per-tool agent path and the gate every run executes before writing. Agent sl | OpenCode | `.opencode/agents/.md` | markdown + frontmatter | | GitHub Copilot | `.github/agents/.agent.md` | markdown + frontmatter | | Codex CLI | `.codex/agents/.toml` | TOML (converted) | +| Gemini CLI | `.gemini/agents/.md` | markdown + frontmatter | -Agents are supported on all five tools. +Agents are supported on all six tools. ## Frontmatter per tool @@ -25,6 +26,7 @@ The canonical agent carries `name`, `description`, `model`. Emit those a row acc | OpenCode | `name`, `description`, `model`, optional `temperature`, `permission` | | GitHub Copilot | `name`, `description`, `model`, optional `tools` | | Codex CLI | `name`, `description` (drops `model`) | +| Gemini CLI | `name`, `description` only; its schema is strict and rejects any other key | ## Codex TOML conversion @@ -43,6 +45,7 @@ Codex agents are TOML, not markdown. Convert: | `.opencode/` | OpenCode | | `.codex/` | Codex CLI | | `.github/copilot-instructions.md` | GitHub Copilot | +| `.gemini/` | Gemini CLI | | `AGENTS.md` | Cursor, OpenCode, or Codex (list all) | ## Write targets diff --git a/plugins/aidd-context/skills/07-command-generate/references/tool-paths.md b/plugins/aidd-context/skills/07-command-generate/references/tool-paths.md index 663455b07..7822cc3ca 100644 --- a/plugins/aidd-context/skills/07-command-generate/references/tool-paths.md +++ b/plugins/aidd-context/skills/07-command-generate/references/tool-paths.md @@ -11,10 +11,12 @@ The per-tool command path and write targets. Command slice only, nothing about s | OpenCode | `.opencode/commands//.md` | yes | | GitHub Copilot | `.github/prompts/.prompt.md` | yes (flat) | | Codex CLI | - | no | +| Gemini CLI | - | no | `` is whatever the user chose: a flat folder, a namespace, or an opt-in `_/` from the taxonomy. Copilot is flat: no subfolder, so fold any location prefix into the filename. - **Codex CLI**: no custom slash commands, only built-ins. Skip it. Suggest a skill if a reusable workflow is needed. +- **Gemini CLI**: same. Skip it and suggest a skill, which Gemini does discover. ## Frontmatter per tool diff --git a/plugins/aidd-context/skills/08-hook-generate/references/tool-paths.md b/plugins/aidd-context/skills/08-hook-generate/references/tool-paths.md index 3ecd702ba..8d4a307a3 100644 --- a/plugins/aidd-context/skills/08-hook-generate/references/tool-paths.md +++ b/plugins/aidd-context/skills/08-hook-generate/references/tool-paths.md @@ -11,6 +11,7 @@ Per-tool hook support, event names, file formats, and scopes. Hook slice only: n | Cursor | yes | JSON config + script. | | GitHub Copilot | yes | JSON config + script. Also reads Claude's `.claude/` config. | | OpenCode | no | Hooks are JS/TS plugin modules, not config. Skip with the reason below. | +| Gemini CLI | yes | JSON config + script, in `.gemini/settings.json`. Its own event names, see below. | **OpenCode skip reason.** OpenCode hooks are code, not a config entry plus a script. Point the user to write a plugin under `.opencode/plugins/` (project) or `~/.config/opencode/plugins/` (user), per `https://opencode.ai/docs/plugins`. This skill does not generate it. @@ -18,16 +19,18 @@ Per-tool hook support, event names, file formats, and scopes. Hook slice only: n Each tool names the same moment differently and supports a different subset. Core moments, with the canonical event name per tool. A `-` means the tool does not expose that moment. -| Moment | Claude Code | Codex CLI | Cursor | GitHub Copilot | -| ------------------ | ------------------ | --------------- | -------------------- | ------------------ | -| session start | `SessionStart` | `SessionStart` | `sessionStart` | `SessionStart` | -| prompt submitted | `UserPromptSubmit` | `UserPromptSubmit` | `beforeSubmitPrompt` | `UserPromptSubmit` | -| before a tool runs | `PreToolUse` | `PreToolUse` | `preToolUse` | `PreToolUse` | -| after a tool runs | `PostToolUse` | `PostToolUse` | `postToolUse` | `PostToolUse` | -| before compaction | `PreCompact` | `PreCompact` | `preCompact` | `PreCompact` | -| subagent stop | `SubagentStop` | `SubagentStop` | `subagentStop` | `SubagentStop` | -| turn stop | `Stop` | `Stop` | `stop` | `Stop` | -| session end | `SessionEnd` | - | `sessionEnd` | `SessionEnd` | +| Moment | Claude Code | Codex CLI | Cursor | GitHub Copilot | Gemini CLI | +| ------------------ | ------------------ | --------------- | -------------------- | ------------------ | --------------- | +| session start | `SessionStart` | `SessionStart` | `sessionStart` | `SessionStart` | `SessionStart` | +| prompt submitted | `UserPromptSubmit` | `UserPromptSubmit` | `beforeSubmitPrompt` | `UserPromptSubmit` | `BeforeAgent` | +| before a tool runs | `PreToolUse` | `PreToolUse` | `preToolUse` | `PreToolUse` | `BeforeTool` | +| after a tool runs | `PostToolUse` | `PostToolUse` | `postToolUse` | `PostToolUse` | `AfterTool` | +| before compaction | `PreCompact` | `PreCompact` | `preCompact` | `PreCompact` | `PreCompress` | +| subagent stop | `SubagentStop` | `SubagentStop` | `subagentStop` | `SubagentStop` | `AfterAgent` | +| turn stop | `Stop` | `Stop` | `stop` | `Stop` | `AfterAgent` | +| session end | `SessionEnd` | - | `sessionEnd` | `SessionEnd` | `SessionEnd` | + +Gemini folds turn stop and subagent stop onto one event, so two Claude moments map to `AfterAgent`. Its names come from the mapping table shipped in `gemini hooks migrate`, read out of the 0.52.0 bundle, not from prose documentation. Each tool exposes more moments than these. For the full list, read the tool's docs: Claude `https://code.claude.com/docs/en/hooks`, Codex `https://developers.openai.com/codex/hooks`, Cursor `https://cursor.com/docs/hooks`, Copilot `https://docs.github.com/en/copilot/reference/hooks-configuration`. Confirm a moment exists before wiring it. Copilot also accepts the camelCase names (`sessionStart`, `preToolUse`). @@ -39,6 +42,7 @@ Each tool exposes more moments than these. For the full list, read the tool's do | Codex CLI | `~/.codex/hooks.json` or `[hooks]` in `config.toml` | same entry shape as Claude. | | Cursor | `.cursor/hooks.json` | `{ "version": 1, "hooks": { "": [ { "command": "..." } ] } }` | | GitHub Copilot | `.github/hooks/*.json` or a `hooks` block in `.github/copilot/settings.json` | `{ "version": 1, "hooks": { "": [ { "type": "command", "command": "..." } ] } }` | +| Gemini CLI | `hooks` block in `.gemini/settings.json` | same entry shape as Claude, under Gemini's own event names. | A Claude `settings.json` and a plugin or standalone `hooks/hooks.json` both wrap the event map under a top-level `hooks` key, so the file is `{ "hooks": { "": [ ... ] } }`. A Codex `config.toml` uses a `[hooks]` table instead. @@ -46,14 +50,16 @@ A Claude `settings.json` and a plugin or standalone `hooks/hooks.json` both wrap Ask the user which scope, then write the matching file. A `-` means the tool has no such scope. -| Scope | Claude Code | Codex CLI | Cursor | GitHub Copilot | -| ---------------- | ------------------------------ | -------------------------- | ---------------------------- | ------------------------ | -| user / global | `~/.claude/settings.json` | `~/.codex/` (`hooks.json` or `config.toml`) | `~/.cursor/hooks.json` | `~/.copilot/hooks/` | -| project, shared | `.claude/settings.json` | `/.codex/` (trust-gated) | `.cursor/hooks.json` | `.github/hooks/` | -| project, local | `.claude/settings.local.json` | - | - | - | -| component / agent | skill or agent frontmatter | - | - | `.agent.md` frontmatter | -| plugin | plugin `hooks/hooks.json` | plugin `hooks.json` | - | - | -| enterprise / team | managed policy settings | managed policy | team or enterprise path | `policy.d/` or registry | +| Scope | Claude Code | Codex CLI | Cursor | GitHub Copilot | Gemini CLI | +| ---------------- | ------------------------------ | -------------------------- | ---------------------------- | ------------------------ | ----------------------------- | +| user / global | `~/.claude/settings.json` | `~/.codex/` (`hooks.json` or `config.toml`) | `~/.cursor/hooks.json` | `~/.copilot/hooks/` | `~/.gemini/settings.json` | +| project, shared | `.claude/settings.json` | `/.codex/` (trust-gated) | `.cursor/hooks.json` | `.github/hooks/` | `.gemini/settings.json` (trust-gated) | +| project, local | `.claude/settings.local.json` | - | - | - | - | +| component / agent | skill or agent frontmatter | - | - | `.agent.md` frontmatter | - | +| plugin | plugin `hooks/hooks.json` | plugin `hooks.json` | - | - | - | +| enterprise / team | managed policy settings | managed policy | team or enterprise path | `policy.d/` or registry | - | + +Gemini disables project hooks entirely in an untrusted folder, printing `Project hooks disabled because the folder is not trusted`. Trust the folder, or the project scope silently does nothing. Never pick a scope silently. State the resolved file and confirm it. diff --git a/plugins/aidd-context/skills/10-learn/references/sync-arguments.md b/plugins/aidd-context/skills/10-learn/references/sync-arguments.md index 5c3bc2e9a..0f17c5ea0 100644 --- a/plugins/aidd-context/skills/10-learn/references/sync-arguments.md +++ b/plugins/aidd-context/skills/10-learn/references/sync-arguments.md @@ -9,6 +9,7 @@ Arguments accepted by `hooks/update_memory.js`. The hook maps tool names to cont | `cursor` | `AGENTS.md` | | `opencode` | `AGENTS.md` | | `copilot` | `.github/copilot-instructions.md` | +| `gemini` | `AGENTS.md` | Rules: diff --git a/plugins/aidd-context/skills/11-explore/references/ai-mapping.md b/plugins/aidd-context/skills/11-explore/references/ai-mapping.md index 388337fb1..da6a44958 100644 --- a/plugins/aidd-context/skills/11-explore/references/ai-mapping.md +++ b/plugins/aidd-context/skills/11-explore/references/ai-mapping.md @@ -15,6 +15,7 @@ A tool is present only when one of its own mapped surfaces below holds a file. A | OpenCode | `.opencode/agents/` | `.opencode/commands/` | **Not supported** (fold into AGENTS.md) | `.opencode/skills/` | `AGENTS.md` | | GitHub Copilot | `.github/agents/*.agent.md` | `.github/prompts/*.prompt.md` | `.github/instructions/*.instructions.md` | `.github/skills/` | `.github/copilot-instructions.md` | | Codex CLI | `.codex/agents/{name}.toml` | **Not supported** | Not supported | `.agents/skills/aidd-{name}/SKILL.md` | `AGENTS.md` | +| Gemini CLI | `.gemini/agents/{name}.md` | **Not supported** | Not supported | `.agents/skills/aidd-{name}/SKILL.md` | `AGENTS.md` (via `context.fileName`) | ## AI quick map - hooks, plugins @@ -25,6 +26,7 @@ A tool is present only when one of its own mapped surfaces below holds a file. A | OpenCode | JS/TS module under `.opencode/plugins/` (parse as JS, not JSON) | Not supported | | GitHub Copilot | `.github/hooks/*.json` (workspace), `~/.copilot/hooks` (user), `/hooks.json` or `/hooks/hooks.json` (plugin) | `plugin.json` at plugin root | | Codex CLI | `.codex/hooks.json` (project / user) OR `[hooks]` table in `.codex/config.toml` | `.codex-plugin/plugin.json` | +| Gemini CLI | `hooks` key in `.gemini/settings.json`, under Gemini's own event names | Not supported | ## MCP config per tool @@ -35,6 +37,7 @@ A tool is present only when one of its own mapped surfaces below holds a file. A | OpenCode | `opencode.json` | `mcp` | | GitHub Copilot | `.vscode/mcp.json` (VS Code); `~/.copilot/mcp-config.json` (CLI) | `servers` (VS Code); `mcpServers` (CLI) | | Codex CLI | `.codex/config.toml` | `[mcp_servers.*]` | +| Gemini CLI | `.gemini/settings.json` | `mcpServers` | ## Path layout per tool @@ -57,3 +60,4 @@ Where to scan when enumerating installed plugins (not the plugin manifest path i | OpenCode | `~/.config/opencode/plugins/` (global JS/TS modules); `~/.cache/opencode/node_modules/` (npm-installed); project `.opencode/plugins/` | | GitHub Copilot | macOS: `~/Library/Application Support/Code/agentPlugins/` ; Linux: `~/.config/Code/agentPlugins/` ; Windows: `%APPDATA%\Code\agentPlugins\` ; CLI-installed: `~/.copilot/installed-plugins/` | | Codex CLI | `~/.codex/plugins/cache/$MARKETPLACE/$PLUGIN/$VERSION/` | +| Gemini CLI | No plugin manager. Content is materialized as files in the project, never registered. | diff --git a/plugins/aidd-context/skills/11-explore/scripts/list-rules.mjs b/plugins/aidd-context/skills/11-explore/scripts/list-rules.mjs index 37f269f09..f5eae26b2 100755 --- a/plugins/aidd-context/skills/11-explore/scripts/list-rules.mjs +++ b/plugins/aidd-context/skills/11-explore/scripts/list-rules.mjs @@ -10,12 +10,13 @@ * - Cursor: .cursor/rules/**\/*.mdc * - GitHub Copilot: .github/instructions/**\/*.instructions.md * - OpenCode: .opencode/rules/**\/*.md (no frontmatter; name from filename) + * - Gemini CLI: .gemini/rules/**\/*.md (no native rules surface; scanned only if a project made one) * - Codex CLI: rules not supported, skipped * * Frontmatter shapes differ per tool. The script normalises every entry to: * { tool, path, name, description, paths } * - * - tool : claude | cursor | copilot | opencode + * - tool : claude | cursor | copilot | opencode | gemini * - path : path relative to --root (defaults to cwd) * - name : derived from the filename (without extension) * - description : frontmatter `description` (Cursor, Copilot) or empty for OpenCode/Claude when absent @@ -34,6 +35,7 @@ const TOOL_TARGETS = [ { tool: 'cursor', dir: '.cursor/rules', ext: '.mdc' }, { tool: 'copilot', dir: '.github/instructions', ext: '.instructions.md' }, { tool: 'opencode', dir: '.opencode/rules', ext: '.md' }, + { tool: 'gemini', dir: '.gemini/rules', ext: '.md' }, ]; function parseArgs(args) { From fe9e18b18cc3aabf634abea011ff23dac9c4ddb4 Mon Sep 17 00:00:00 2001 From: Thierry Kwiatkowski Date: Wed, 12 Aug 2026 19:09:17 +0200 Subject: [PATCH 19/23] docs: publish gemini support and the two constraints that gate it Gemini moves to supported flat in the compatibility table, with an install block that carries both gating constraints inside it rather than in a general prerequisites section a reader skips: the 0.28.0 minimum, below which no skill is ever discovered, and the folder-trust step, without which Gemini lists nothing and explains nothing. The block also steers users to the archive rather than `aidd ai install gemini`, because the install path writes no .gemini/settings.json and so carries neither hooks nor the AGENTS.md context wiring. Every version claim states how it was established: the minimum is derived from the CLI's own source, since the vendor publishes none, and 0.52.0 is what was actually verified. Five targets and nine build cells become six and ten across the CLI docs, the architecture rule and the project memory. Co-Authored-By: Claude Opus 5 --- README.md | 24 ++++++++++++++++--- .../2026_07_27-511-gemini-cli-tool-part-4.md | 13 ++++++---- .../rules/00-architecture/0-hexagonal.md | 2 +- cli/ARCHITECTURE.md | 2 +- cli/README.md | 6 +++-- cli/aidd_docs/memory/architecture.md | 3 ++- cli/aidd_docs/memory/codebase-map.md | 2 +- cli/aidd_docs/memory/project-brief.md | 4 ++-- docs/MAINTAINERS.md | 2 +- 9 files changed, 42 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index 499a36d18..1c2176e4a 100644 --- a/README.md +++ b/README.md @@ -35,7 +35,7 @@ Why not just write your own commands? β†’ [FAQ](docs/FAQ.md#-why-aidd-instead-of ## βœ… Prerequisites -- **An AI coding tool** β€” Claude Code (native), or Cursor / Copilot / Codex / OpenCode (see [Compatibility](#-compatibility)). +- **An AI coding tool** β€” Claude Code (native), or Cursor / Copilot / Codex / OpenCode / Gemini (see [Compatibility](#-compatibility)). - **[Node](https://nodejs.org)** on your `PATH` β€” for the plugins that ship hooks ([what they do](docs/ARCHITECTURE.md#-bundled-hooks)). ## πŸ”Œ Compatibility @@ -47,11 +47,12 @@ Why not just write your own commands? β†’ [FAQ](docs/FAQ.md#-why-aidd-instead-of | **GitHub Copilot** | βœ… Supported | Marketplace Β· Flat | | **Codex** | βœ… Supported | Marketplace Β· Flat | | **OpenCode** | βœ… Supported | Flat | -| **Gemini Β· Mistral** | 🚧 In progress | β€” | +| **Gemini** | βœ… Supported | Flat | +| **Mistral** | 🚧 In progress | β€” | **Marketplace** = installed and updated through your tool's plugin manager. **Flat** = files copied directly into your project, no plugin manager involved. Install steps per tool β†’ [Other tools](#other-tools). -**Gemini CLI**: the flat build target is implemented, but end-to-end validation on Gemini 3 Pro models is currently blocked by an upstream `gemini-cli` bug unrelated to AIDD β€” [google-gemini/gemini-cli#14437](https://github.com/google-gemini/gemini-cli/issues/14437) (missing `thought_signature` on chained tool calls). Use a Gemini 2.5 model to test in the meantime. +**Gemini CLI** requires **0.28.0 or newer** (version derived from the CLI's own source, not published by the vendor; verified against 0.52.0). Below it, the `.agents/skills/` location does not exist and no skill is ever discovered. Gemini also disables project skills, agents and hooks in an **untrusted folder** without explaining why β€” trust the folder when prompted, or set `security.folderTrust.enabled` to `false`. End-to-end validation on Gemini 3 Pro models is blocked by an upstream `gemini-cli` bug unrelated to AIDD β€” [google-gemini/gemini-cli#14437](https://github.com/google-gemini/gemini-cli/issues/14437) (missing `thought_signature` on chained tool calls); use a Gemini 2.5 model in the meantime. ## πŸ“¦ Install @@ -176,6 +177,23 @@ codex plugin add aidd-context@aidd-framework # per plugin +
+Gemini CLI β€” Flat only + +**Requires Gemini CLI 0.28.0 or newer.** Below that, `.agents/skills/` does not exist and no skill is ever discovered. (Minimum derived from the CLI's own source β€” the vendor publishes none. Verified against 0.52.0.) + +1. Unzip the `gemini-flat` archive into your project root β†’ `.agents/skills/`, `.gemini/agents/`, `.gemini/settings.json`. +2. **Trust the folder.** In an untrusted folder Gemini silently lists zero skills and disables project agents and hooks. Accept the trust prompt when Gemini starts, or set `security.folderTrust.enabled` to `false` in your user `settings.json`. +3. Run `gemini skills list --all` β€” every `aidd-*` skill should appear as `Enabled`. + +`aidd-orchestrator` is not part of this archive: it is built on Claude Code's plugin activation and GitHub Action, which have no Gemini equivalent. + +Prefer the archive over `aidd ai install gemini`: the install path does not write `.gemini/settings.json`, so an installed-only setup has no hooks and does not load `AGENTS.md` as context. + +[Docs](https://google-gemini.github.io/gemini-cli/) + +
+ ## πŸš€ Quick start Three ways in β€” pick one: diff --git a/aidd_docs/tasks/2026_07/2026_07_27_gemini-cli-build-target/2026_07_27-511-gemini-cli-tool-part-4.md b/aidd_docs/tasks/2026_07/2026_07_27_gemini-cli-build-target/2026_07_27-511-gemini-cli-tool-part-4.md index e97d5ecc6..86157fe14 100644 --- a/aidd_docs/tasks/2026_07/2026_07_27_gemini-cli-build-target/2026_07_27-511-gemini-cli-tool-part-4.md +++ b/aidd_docs/tasks/2026_07/2026_07_27_gemini-cli-build-target/2026_07_27-511-gemini-cli-tool-part-4.md @@ -160,10 +160,10 @@ flowchart TD #### Acceptance criteria -- [ ] No document still claims five supported tools or nine build cells -- [ ] The minimum version and the trust step appear in the gemini install block itself -- [ ] Every version claim states how it was established -- [ ] The documented surface mapping matches what the code emits, table by table +- [x] No document still claims five supported tools or nine build cells +- [x] The minimum version and the trust step appear in the gemini install block itself +- [x] Every version claim states how it was established +- [x] The documented surface mapping matches what the code emits, table by table ### Phase 3: Re-baseline the golden snapshot deliberately @@ -207,10 +207,15 @@ flowchart TD πŸ€– Two limits found in part 3's phase 4 belong in this part's user-facing documentation, and neither was known when this plan was written. The install path never writes `.gemini/settings.json`, so an installed gemini has no hooks and no `AGENTS.md` context wiring β€” only the archive carries those. And a plugin installed from a raw local path (no marketplace) lands skills at a nesting depth the real binary discovers nothing from. Both are recorded in phase 2's documentation tasks rather than left to the reader to discover. +πŸ€– `aidd_docs/memory/architecture.md` and `codebase-map.md` at the framework level enumerate no tools at all, so nothing there needed changing. Only the CLI-level memory files did. + +πŸ€– The README already carried a Gemini footnote this plan predates: an upstream `gemini-cli` bug (google-gemini/gemini-cli#14437) blocking end-to-end validation on Gemini 3 Pro models. It is kept and folded into the expanded footnote rather than replaced, and `Gemini Β· Mistral` was split so Mistral keeps its in-progress status while Gemini moves to supported flat. + ## Log - Phase 1: gemini added to the memory hook's tool-to-context-file map (`AGENTS.md`) and to eleven reference tables across `aidd-context`: onboarding detection, project-memory tools, skill-generate detection and write targets plus its strict frontmatter row, rule-generate (unsupported, with the AGENTS.md fallback), agent-generate (path, strict-schema frontmatter, detection), command-generate (unsupported, steer to a skill), hook-generate (support row, a full event column, the settings file shape, and the trust-gated scope), learn's sync arguments, explore's four mapping tables, and the rules-listing script. Hook event names are the verified `GEMINI_HOOK_EVENT_MAP` read from the 0.52.0 bundle, including the two Claude moments that both fold onto `AfterAgent`. Verified: `node update_memory.js gemini` exits 0 and writes the memory block into `AGENTS.md`, where it previously exited 1 with `unknown tool gemini`; `node --check` passes on the rules script. The golden snapshot is knowingly red from here until phase 3 re-baselines it, which is this part's whole point. +- Phase 2: Gemini moves to supported flat in the README compatibility table, with an install block that carries both gating constraints inside it β€” the 0.28.0 minimum (labelled source-derived, verified against 0.52.0) and the folder-trust step, since an untrusted folder yields an empty skill list with no explanation. The block also states that `aidd-orchestrator` is absent from the archive, and steers users to the archive over `aidd ai install gemini` because the install path writes no `.gemini/settings.json` and so has neither hooks nor the `AGENTS.md` context wiring β€” the gap part 3's phase 4 measured. `cli/README.md` gains gemini in the `--target` list, the flat-only note, the per-tool layout matrix and the settings-file table; `cli/ARCHITECTURE.md` goes from five targets and nine cells to six and ten; `docs/MAINTAINERS.md` from nine archives to ten; the hexagonal rule and four CLI memory files list the sixth tool. ## Validation flow demonstration diff --git a/cli/.claude/rules/00-architecture/0-hexagonal.md b/cli/.claude/rules/00-architecture/0-hexagonal.md index 4c54e8762..f318b9cfe 100644 --- a/cli/.claude/rules/00-architecture/0-hexagonal.md +++ b/cli/.claude/rules/00-architecture/0-hexagonal.md @@ -13,7 +13,7 @@ paths: - `domain/capabilities/` β€” capability classes (agents, commands, hooks, mcp, plugins, rules, settings, skills) - `domain/tools/contracts.ts` β€” `AiTool`, `Has*` interfaces, `IdeToolConfig` - `domain/tools/registry.ts` β€” tool registry, `ToolConfig` union, guards -- `domain/tools/ai/` β€” AI tool definitions (claude, cursor, copilot, opencode, codex) +- `domain/tools/ai/` β€” AI tool definitions (claude, cursor, copilot, opencode, codex, gemini) - `domain/tools/ide/` β€” IDE tool definitions (vscode) - `application/use-cases/` β€” orchestrators, sub-use-cases in subdirs (`install/`, `update/`, `sync/`, `auth/`, `shared/`) - `application/commands/` β€” CLI wiring only diff --git a/cli/ARCHITECTURE.md b/cli/ARCHITECTURE.md index 67b8492b6..fa7f84679 100644 --- a/cli/ARCHITECTURE.md +++ b/cli/ARCHITECTURE.md @@ -72,7 +72,7 @@ Memory ownership (CLAUDE.md, AGENTS.md, copilot-instructions.md) is delegated to ## Framework Build (author-side) -`aidd framework build` translates a Claude-format framework source into a target-native distribution. Five targets (`claude`, `cursor`, `copilot`, `codex`, `opencode`) Γ— two modes (`marketplace`, `flat`); `opencode` is flat-only, so 9 build cells. The orchestrators (`MarketplaceBuildStrategy`, `FlatBuildStrategy`) read a per-tool `ToolBuildContract` β€” no per-tool branching. **Scope:** skills, agents, mcp, and hooks are emitted; `rules` and `commands` are currently out of scope (warn + skip per plugin). See `README.md` β†’ `aidd framework build` for the per-tool layout matrix. +`aidd framework build` translates a Claude-format framework source into a target-native distribution. Six targets (`claude`, `cursor`, `copilot`, `codex`, `opencode`, `gemini`) Γ— two modes (`marketplace`, `flat`); `opencode` and `gemini` are flat-only, so 10 build cells. The orchestrators (`MarketplaceBuildStrategy`, `FlatBuildStrategy`) read a per-tool `ToolBuildContract` β€” no per-tool branching. **Scope:** skills, agents, mcp, and hooks are emitted; `rules` and `commands` are currently out of scope (warn + skip per plugin). See `README.md` β†’ `aidd framework build` for the per-tool layout matrix. ## Dependency Wiring diff --git a/cli/README.md b/cli/README.md index b6b221937..3b68d2863 100644 --- a/cli/README.md +++ b/cli/README.md @@ -405,6 +405,7 @@ Marketplace registration and plugin enable state are written to per-tool setting | GitHub Copilot | `.github/copilot/settings.json` | | Codex | `.codex/config.json` | | OpenCode | `opencode.json` (project root) | +| Gemini | `.gemini/settings.json` | > **GitHub Copilot β€” workspace recommendations only.** Per [VS Code docs](https://code.visualstudio.com/docs/copilot/customization/agent-plugins), `.github/copilot/settings.json` registers marketplaces as **team recommendations**, not auto-activated. On first chat in the workspace VS Code shows a notification β€” the user must accept it (or filter Extensions by `@agentPlugins @recommended` and enable manually) before plugins load. To skip the per-project click, add the marketplace to the user-level setting `chat.plugins.marketplaces` (application-scoped, not writable from workspace). See [End-to-end: distribute a framework to Copilot](#end-to-end-distribute-a-framework-to-copilot-marketplace) for the full flow. @@ -423,7 +424,7 @@ aidd framework build \ | Flag | Required | Description | |---|---|---| | `--source` | yes | Path to a framework root with `plugins//.claude-plugin/plugin.json` entries | -| `--target` | yes | `claude`, `cursor`, `copilot`, `codex`, or `opencode` | +| `--target` | yes | `claude`, `cursor`, `copilot`, `codex`, `opencode`, or `gemini` | | `--out` | yes | Output directory. Marketplace mode: dist root (auto-wiped + recreated). Flat mode: the project root to materialize into | | `--flat` | no | Materialize directly into a project workspace, bypassing the marketplace layer | | `--force` | no | Overwrite existing files at canonical paths. **Flat mode only** (rejected without `--flat`) | @@ -435,7 +436,7 @@ aidd framework build \ #### Per-tool / per-mode matrix -`opencode` is **flat-only** (no native marketplace). The other four support both modes. +`opencode` and `gemini` are **flat-only** (neither vendor has a plugin manager). The other four support both modes. | Target | Marketplace layout (`/`) | Plugin-root token | Flat layout (`/`) | |---|---|---|---| @@ -444,6 +445,7 @@ aidd framework build \ | `copilot` | `.plugin/marketplace.json` Β· `plugins//.plugin/plugin.json` (OpenPlugin spec) Β· `agents/*.md` | `${PLUGIN_ROOT}` | `.github/` (+ `.vscode/`) | | `codex` | `.claude-plugin/marketplace.json` Β· `plugins//.codex-plugin/plugin.json` Β· `codex-agents/*.toml` | `${PLUGIN_ROOT}` | `.codex/` | | `opencode` | β€” (flat-only) | β€” | `.opencode/` (+ `opencode.json` for MCP) | +| `gemini` | β€” (flat-only) | β€” | `.agents/skills/` (shared with `codex`) Β· `.gemini/agents/` Β· `.gemini/hooks/` (scripts) Β· `.gemini/settings.json` for MCP, hooks and `context.fileName` | Copilot uses the [OpenPlugin spec](https://github.com/vercel/open-plugin-spec) (`.plugin/plugin.json`, `${PLUGIN_ROOT}`) β€” the only layout where Copilot's editor + CLI resolve the plugin-root token at runtime. Codex requires the manifest `skills` field as a **string** (`"./skills"`), and project subagents (`.codex/agents/*.toml`) load only when the project is **trusted**. diff --git a/cli/aidd_docs/memory/architecture.md b/cli/aidd_docs/memory/architecture.md index a38f8710e..6148b496a 100644 --- a/cli/aidd_docs/memory/architecture.md +++ b/cli/aidd_docs/memory/architecture.md @@ -66,7 +66,7 @@ PluginInstallFromMarketplaceUseCase β†’ MarketplaceRegistry + PluginFetcher (git **Framework build** (`aidd framework build --target `): ``` FrameworkBuildUseCase β†’ BuildOutputStrategy (MarketplaceBuildStrategy | FlatBuildStrategy, reading per-tool ToolBuildContract) -β†’ tool-native plugin tree (author-side distribution; all 5 targets shipped β€” claude/cursor/copilot/codex marketplace+flat, opencode flat-only) +β†’ tool-native plugin tree (author-side distribution; all 6 targets shipped β€” claude/cursor/copilot/codex marketplace+flat, opencode and gemini flat-only) ``` Author-side, not user-side: translates the Claude-format framework into a tool-native marketplace dist (Mode A) or flat workspace materialization (Mode B `--flat`). @@ -89,6 +89,7 @@ loads** plugins (verified live against each tool's real CLI/IDE, not inferred): | Claude | `.claude/settings.json` (`extraKnownMarketplaces` + `enabledPlugins`) β€” read natively, no CLI step | the settings file | | Cursor | materialized to `~/.cursor/plugins/local//` (user-scope) β€” auto-discovered as "Local" plugins | the plugin files | | OpenCode | flat files `.opencode/skills/`, `.opencode/agents/` β€” auto-discovered | the flat files | +| Gemini | flat files `.agents/skills/` (shared with codex), `.gemini/agents/` β€” auto-discovered when the folder is trusted | the flat files | | Codex | **native CLI activation** (`codex plugin add`) into user-global `~/.codex/` + cache | drives the CLI | | Copilot | **native CLI activation** (`copilot plugin install`) into user-global `~/.copilot/` | drives the CLI + a recommendations file | diff --git a/cli/aidd_docs/memory/codebase-map.md b/cli/aidd_docs/memory/codebase-map.md index a392abd33..cad985ca6 100644 --- a/cli/aidd_docs/memory/codebase-map.md +++ b/cli/aidd_docs/memory/codebase-map.md @@ -30,7 +30,7 @@ src/ β”‚ └── tools/ β”‚ β”œβ”€β”€ contracts.ts # AiTool, Has* interfaces, IdeToolConfig, UserFileSectionKey β”‚ β”œβ”€β”€ registry.ts # ToolConfig union, isAiTool(), registerTool(), getToolConfig(), hasToolSignals() -β”‚ β”œβ”€β”€ ai/ # one file per AI tool (claude, cursor, copilot, opencode, codex) +β”‚ β”œβ”€β”€ ai/ # one file per AI tool (claude, cursor, copilot, opencode, codex, gemini) β”‚ └── ide/ # one file per IDE tool (vscode) └── infrastructure/ β”œβ”€β”€ adapters/ # port implementations β€” one adapter per port (incl. auth-reader, auth-storage, http-client) diff --git a/cli/aidd_docs/memory/project-brief.md b/cli/aidd_docs/memory/project-brief.md index 20a7b2c9c..97a6bd527 100644 --- a/cli/aidd_docs/memory/project-brief.md +++ b/cli/aidd_docs/memory/project-brief.md @@ -27,7 +27,7 @@ | Framework | Canonical set of agents, commands, rules, skills, templates | | Distribution | Tool-specific generated output (files rewritten per tool conventions) | | Manifest | `.aidd/manifest.json` β€” hash-based tracking of every installed file | -| ToolConfig | Per-tool configuration: output paths, frontmatter conversion, merge rules. Tools: `claude` β†’ `.claude/`, `cursor` β†’ `.cursor/`, `copilot` β†’ `.github/`, `opencode` β†’ `.opencode/`, `codex` β†’ `.codex/` | +| ToolConfig | Per-tool configuration: output paths, frontmatter conversion, merge rules. Tools: `claude` β†’ `.claude/`, `cursor` β†’ `.cursor/`, `copilot` β†’ `.github/`, `opencode` β†’ `.opencode/`, `codex` β†’ `.codex/`, `gemini` β†’ `.gemini/` (skills to the shared `.agents/skills/`) | | Plugin | Capability files (agents, commands, hooks, mcp, rules, skills) distributed per AI tool format via marketplace catalogs | | Drift | Installed file modified locally vs. what was written at install time | | Init | Bootstrap: CLI writes `.aidd/manifest.json` (+ `.aidd/cache` gitignore). The `aidd_docs/` memory bank is scaffolded by the `aidd-context` project-init skill, not the CLI binary | @@ -40,7 +40,7 @@ |---|---| | `aidd setup --source remote\|local [--path ] [--release ] [--ai ] [--ide ] [--plugins ] [--no-default-marketplace] [--yes]` | Initialize project: marketplace + tools + plugins (`--ai all` / `--ide all` for everything) | -### AI tools (claude, cursor, copilot, codex, opencode) +### AI tools (claude, cursor, copilot, codex, opencode, gemini) | Command | Purpose | |---|---| | `aidd ai install [--force]` | Install AI tool runtime config | diff --git a/docs/MAINTAINERS.md b/docs/MAINTAINERS.md index dc1692190..fbea288e3 100644 --- a/docs/MAINTAINERS.md +++ b/docs/MAINTAINERS.md @@ -68,7 +68,7 @@ release-please opens/updates a `chore: release main` PR on each push to `main`. 3. CI tags each bumped package, creates the GitHub Releases, and attaches the bundles: - `aidd-framework-marketplace-X.Y.Z.zip` (`.claude-plugin/` + `plugins/`) - `-vX.Y.Z.zip` - - `aidd-framework---X.Y.Z.zip` - per-tool distributions (9 archives: 4 marketplace claude/cursor/copilot/codex + 5 flat incl. opencode), produced by the `build-per-tool` matrix job in `ci.yml` via `aidd-cli framework build`. **Pinned** to a specific `@ai-driven-dev/cli` version - bump it deliberately when adopting CLI build changes. + - `aidd-framework---X.Y.Z.zip` - per-tool distributions (10 archives: 4 marketplace claude/cursor/copilot/codex + 6 flat incl. opencode and gemini), produced by the `build-per-tool` matrix job in `ci.yml` via `aidd-cli framework build`. **Pinned** to a specific `@ai-driven-dev/cli` version - bump it deliberately when adopting CLI build changes. Versions live in `.release-please-manifest.json`. Forcing a version / pre-release: `release-as` in `release-please-config.json` (remove it after the release ships). From 67fc914d9c13e2558a9d74e36ef2f9f97ccbb0ad Mon Sep 17 00:00:00 2001 From: Thierry Kwiatkowski Date: Wed, 12 Aug 2026 19:12:11 +0200 Subject: [PATCH 20/23] docs(plan): record that no golden re-baseline was needed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The plan expected this part's content edits to move the golden snapshot. They do not: the suite builds from a committed fixture copy of the plugins, not from the live tree, so regenerating produces a byte- identical file and the subset invariant still holds. The fixture is frozen on purpose β€” that is what makes the golden a test of build behaviour rather than of plugin content β€” so it is left alone. Co-Authored-By: Claude Opus 5 --- .../2026_07_27-511-gemini-cli-tool-part-4.md | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/aidd_docs/tasks/2026_07/2026_07_27_gemini-cli-build-target/2026_07_27-511-gemini-cli-tool-part-4.md b/aidd_docs/tasks/2026_07/2026_07_27_gemini-cli-build-target/2026_07_27-511-gemini-cli-tool-part-4.md index 86157fe14..7bc4967eb 100644 --- a/aidd_docs/tasks/2026_07/2026_07_27_gemini-cli-build-target/2026_07_27-511-gemini-cli-tool-part-4.md +++ b/aidd_docs/tasks/2026_07/2026_07_27_gemini-cli-build-target/2026_07_27-511-gemini-cli-tool-part-4.md @@ -178,9 +178,9 @@ flowchart TD #### Acceptance criteria -- [ ] Every path in the re-baseline diff traces to a reference file edited in phase 1 -- [ ] The re-baseline is documented in the golden suite header with its reason -- [ ] The shared-tree subset invariant still passes +- [x] Every path in the re-baseline diff traces to a reference file edited in phase 1 β€” vacuously: the diff is empty, see Amendments +- [ ] The re-baseline is documented in the golden suite header with its reason β€” no re-baseline pass occurred, so there is nothing to record there +- [x] The shared-tree subset invariant still passes - [ ] `cd cli && pnpm typecheck && pnpm lint && pnpm test` exits 0 ### Phase 4: Correct the record @@ -211,11 +211,14 @@ flowchart TD πŸ€– The README already carried a Gemini footnote this plan predates: an upstream `gemini-cli` bug (google-gemini/gemini-cli#14437) blocking end-to-end validation on Gemini 3 Pro models. It is kept and folded into the expanded footnote rather than replaced, and `Gemini Β· Mistral` was split so Mistral keeps its in-progress status while Gemini moves to supported flat. +πŸ€– There is no golden re-baseline to perform, and the plan's central premise for this phase is wrong. The golden suite builds from `cli/tests/fixtures/framework-real`, a committed 211-file copy of a subset of the plugins, not from the live `plugins/` tree. Regenerating with `UPDATE_GOLDEN=1` after all of phase 1's content edits produces an empty diff, and the suite stays green including part 2's subset invariant. The fixture is deliberately frozen β€” that is what makes the golden a test of build behaviour rather than of plugin content β€” so refreshing it from source on every documentation edit would defeat its purpose. Left as it is. The consequence to record is narrow but real: the fixture's copy of `update_memory.js` no longer matches the source, and any future check that the two agree would now fail. + ## Log - Phase 1: gemini added to the memory hook's tool-to-context-file map (`AGENTS.md`) and to eleven reference tables across `aidd-context`: onboarding detection, project-memory tools, skill-generate detection and write targets plus its strict frontmatter row, rule-generate (unsupported, with the AGENTS.md fallback), agent-generate (path, strict-schema frontmatter, detection), command-generate (unsupported, steer to a skill), hook-generate (support row, a full event column, the settings file shape, and the trust-gated scope), learn's sync arguments, explore's four mapping tables, and the rules-listing script. Hook event names are the verified `GEMINI_HOOK_EVENT_MAP` read from the 0.52.0 bundle, including the two Claude moments that both fold onto `AfterAgent`. Verified: `node update_memory.js gemini` exits 0 and writes the memory block into `AGENTS.md`, where it previously exited 1 with `unknown tool gemini`; `node --check` passes on the rules script. The golden snapshot is knowingly red from here until phase 3 re-baselines it, which is this part's whole point. - Phase 2: Gemini moves to supported flat in the README compatibility table, with an install block that carries both gating constraints inside it β€” the 0.28.0 minimum (labelled source-derived, verified against 0.52.0) and the folder-trust step, since an untrusted folder yields an empty skill list with no explanation. The block also states that `aidd-orchestrator` is absent from the archive, and steers users to the archive over `aidd ai install gemini` because the install path writes no `.gemini/settings.json` and so has neither hooks nor the `AGENTS.md` context wiring β€” the gap part 3's phase 4 measured. `cli/README.md` gains gemini in the `--target` list, the flat-only note, the per-tool layout matrix and the settings-file table; `cli/ARCHITECTURE.md` goes from five targets and nine cells to six and ten; `docs/MAINTAINERS.md` from nine archives to ten; the hexagonal rule and four CLI memory files list the sixth tool. +- Phase 3: no re-baseline was needed or performed. `UPDATE_GOLDEN=1` regenerated the snapshot after phase 1's twelve content edits and produced a byte-identical file, because the golden builds from the frozen `framework-real` fixture rather than from `plugins/` (see Amendments). Golden suite green, 6 tests, subset invariant included. Full validation: `pnpm typecheck` (0 errors), `biome check` (clean), `pnpm test` 2236/2237 with the same environment-coupled `auth status` failure. ## Validation flow demonstration From af0d1a7a734ffb23e50207b4cbeb97cb1a89b886 Mon Sep 17 00:00:00 2001 From: Thierry Kwiatkowski Date: Wed, 12 Aug 2026 19:13:14 +0200 Subject: [PATCH 21/23] docs(plan): record that the sync pair matrix cannot be extended The matrix documents `plugin sync` and `ai sync` runs from May against v4.1.0-beta.11. Neither command exists in the CLI today, so the ten gemini pairs cannot be run, and writing PASS beside them would be inventing results. The document stays as the accurate record of what was true when it was written. Co-Authored-By: Claude Opus 5 --- .../2026_07_27-511-gemini-cli-tool-part-4.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/aidd_docs/tasks/2026_07/2026_07_27_gemini-cli-build-target/2026_07_27-511-gemini-cli-tool-part-4.md b/aidd_docs/tasks/2026_07/2026_07_27_gemini-cli-build-target/2026_07_27-511-gemini-cli-tool-part-4.md index 7bc4967eb..2b931888e 100644 --- a/aidd_docs/tasks/2026_07/2026_07_27_gemini-cli-build-target/2026_07_27-511-gemini-cli-tool-part-4.md +++ b/aidd_docs/tasks/2026_07/2026_07_27_gemini-cli-build-target/2026_07_27-511-gemini-cli-tool-part-4.md @@ -197,7 +197,7 @@ flowchart TD - [ ] The issue no longer says agents have no known equivalent, that hooks need investigation, or that Gemini reads `AGENTS.md` by default - [ ] The issue records the two prerequisites and the excluded plugin -- [ ] The manual matrix covers all thirty pairs +- [ ] The manual matrix covers all thirty pairs β€” impossible, the commands it exercises no longer exist; see Amendments ## Amendments @@ -213,6 +213,8 @@ flowchart TD πŸ€– There is no golden re-baseline to perform, and the plan's central premise for this phase is wrong. The golden suite builds from `cli/tests/fixtures/framework-real`, a committed 211-file copy of a subset of the plugins, not from the live `plugins/` tree. Regenerating with `UPDATE_GOLDEN=1` after all of phase 1's content edits produces an empty diff, and the suite stays green including part 2's subset invariant. The fixture is deliberately frozen β€” that is what makes the golden a test of build behaviour rather than of plugin content β€” so refreshing it from source on every documentation edit would defeat its purpose. Left as it is. The consequence to record is narrow but real: the fixture's copy of `update_memory.js` no longer matches the source, and any future check that the two agree would now fail. +πŸ€– The manual pair matrix cannot be extended, because the mechanism it records was removed. `cli/aidd_docs/tasks/2026_05/2026_05_06-cli-v5-cleanup-sync-matrix.md` β€” the projection places it at the framework level, it is under `cli/` β€” is a dated record of a manual run from 2026-05-06 against binary v4.1.0-beta.11, exercising `plugin sync --source --target ` and `ai sync`. Neither command exists in the CLI today: `plugin --help` lists only list, install and doctor, `ai --help` has no sync, and nothing registers one in `application/commands/`. Only `sync-conflict-resolver-use-case.ts` survives, serving update. The master plan already recorded "there is no sync command" when dismissing a brainstorm risk, without carrying that conclusion into this task. Adding ten gemini rows would mean writing PASS beside runs that cannot be performed, so the document is left untouched: it remains an accurate record of what was true in May. + ## Log From 84c27fc37c3597a53223e877e306d1c08f2cca21 Mon Sep 17 00:00:00 2001 From: Thierry Kwiatkowski Date: Wed, 12 Aug 2026 22:00:36 +0200 Subject: [PATCH 22/23] docs(plan): close part 4 and the gemini master plan Issue #511 now carries the verified mapping, the two prerequisites that silently produce an empty result, and the install-path limitation. The original mapping is preserved and superseded rather than overwritten. All four parts are done. Two checkpoints remained unticked and both are now met: the command matrix is green for gemini with the smoke coverage gate held at 100%, and a Gemini-only project consumes the archive end to end against the real 0.52.0 binary. Co-Authored-By: Claude Opus 5 --- .../2026_07_27-511-gemini-cli-tool-master.md | 8 ++++---- .../2026_07_27-511-gemini-cli-tool-part-4.md | 5 +++-- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/aidd_docs/tasks/2026_07/2026_07_27_gemini-cli-build-target/2026_07_27-511-gemini-cli-tool-master.md b/aidd_docs/tasks/2026_07/2026_07_27_gemini-cli-build-target/2026_07_27-511-gemini-cli-tool-master.md index 9d334cd5a..7416770f3 100644 --- a/aidd_docs/tasks/2026_07/2026_07_27_gemini-cli-build-target/2026_07_27-511-gemini-cli-tool-master.md +++ b/aidd_docs/tasks/2026_07/2026_07_27_gemini-cli-build-target/2026_07_27-511-gemini-cli-tool-master.md @@ -52,8 +52,8 @@ Neither appears in the issue or the brainstorm, and both gate the stated success | --- | ----------------------- | ------------------------------------------------ | ------- | --------- | | 1 | Build target | `./2026_07_27-511-gemini-cli-tool-part-1.md` | done | [x] | | 2 | Shared tree safety | `./2026_07_27-511-gemini-cli-tool-part-2.md` | done | [x] | -| 3 | Registry citizen | `./2026_07_27-511-gemini-cli-tool-part-3.md` | in-progress | [ ] | -| 4 | Content and docs | `./2026_07_27-511-gemini-cli-tool-part-4.md` | blocked | [ ] | +| 3 | Registry citizen | `./2026_07_27-511-gemini-cli-tool-part-3.md` | done | [x] | +| 4 | Content and docs | `./2026_07_27-511-gemini-cli-tool-part-4.md` | done | [x] | @@ -87,9 +87,9 @@ This supersedes one brainstorm decision. The brainstorm chose tool-neutral skill 3. Unblock Part 2, run its `success_condition` 4. [x] Checkpoint 2: shared-path deletion guarded, subset invariant green 5. Unblock Part 3, run its `success_condition` -6. [ ] Checkpoint 3: full command matrix green for gemini, smoke coverage gate met +6. [x] Checkpoint 3: full command matrix green for gemini, smoke coverage gate met 7. Unblock Part 4, run its `success_condition` -8. [ ] Final: a Gemini-only project consumes the archive end to end, verified against the real `gemini` binary +8. [x] Final: a Gemini-only project consumes the archive end to end, verified against the real `gemini` binary ## Confidence assessment diff --git a/aidd_docs/tasks/2026_07/2026_07_27_gemini-cli-build-target/2026_07_27-511-gemini-cli-tool-part-4.md b/aidd_docs/tasks/2026_07/2026_07_27_gemini-cli-build-target/2026_07_27-511-gemini-cli-tool-part-4.md index 2b931888e..6a05d7612 100644 --- a/aidd_docs/tasks/2026_07/2026_07_27_gemini-cli-build-target/2026_07_27-511-gemini-cli-tool-part-4.md +++ b/aidd_docs/tasks/2026_07/2026_07_27_gemini-cli-build-target/2026_07_27-511-gemini-cli-tool-part-4.md @@ -195,8 +195,8 @@ flowchart TD #### Acceptance criteria -- [ ] The issue no longer says agents have no known equivalent, that hooks need investigation, or that Gemini reads `AGENTS.md` by default -- [ ] The issue records the two prerequisites and the excluded plugin +- [x] The issue no longer says agents have no known equivalent, that hooks need investigation, or that Gemini reads `AGENTS.md` by default +- [x] The issue records the two prerequisites and the excluded plugin - [ ] The manual matrix covers all thirty pairs β€” impossible, the commands it exercises no longer exist; see Amendments ## Amendments @@ -221,6 +221,7 @@ flowchart TD - Phase 1: gemini added to the memory hook's tool-to-context-file map (`AGENTS.md`) and to eleven reference tables across `aidd-context`: onboarding detection, project-memory tools, skill-generate detection and write targets plus its strict frontmatter row, rule-generate (unsupported, with the AGENTS.md fallback), agent-generate (path, strict-schema frontmatter, detection), command-generate (unsupported, steer to a skill), hook-generate (support row, a full event column, the settings file shape, and the trust-gated scope), learn's sync arguments, explore's four mapping tables, and the rules-listing script. Hook event names are the verified `GEMINI_HOOK_EVENT_MAP` read from the 0.52.0 bundle, including the two Claude moments that both fold onto `AfterAgent`. Verified: `node update_memory.js gemini` exits 0 and writes the memory block into `AGENTS.md`, where it previously exited 1 with `unknown tool gemini`; `node --check` passes on the rules script. The golden snapshot is knowingly red from here until phase 3 re-baselines it, which is this part's whole point. - Phase 2: Gemini moves to supported flat in the README compatibility table, with an install block that carries both gating constraints inside it β€” the 0.28.0 minimum (labelled source-derived, verified against 0.52.0) and the folder-trust step, since an untrusted folder yields an empty skill list with no explanation. The block also states that `aidd-orchestrator` is absent from the archive, and steers users to the archive over `aidd ai install gemini` because the install path writes no `.gemini/settings.json` and so has neither hooks nor the `AGENTS.md` context wiring β€” the gap part 3's phase 4 measured. `cli/README.md` gains gemini in the `--target` list, the flat-only note, the per-tool layout matrix and the settings-file table; `cli/ARCHITECTURE.md` goes from five targets and nine cells to six and ten; `docs/MAINTAINERS.md` from nine archives to ten; the hexagonal rule and four CLI memory files list the sixth tool. - Phase 3: no re-baseline was needed or performed. `UPDATE_GOLDEN=1` regenerated the snapshot after phase 1's twelve content edits and produced a byte-identical file, because the golden builds from the frozen `framework-real` fixture rather than from `plugins/` (see Amendments). Golden suite green, 6 tests, subset invariant included. Full validation: `pnpm typecheck` (0 errors), `biome check` (clean), `pnpm test` 2236/2237 with the same environment-coupled `auth status` failure. +- Phase 4: issue #511's body updated. The original mapping is kept verbatim and explicitly superseded rather than overwritten, so the thread still shows what was assumed against what was measured. The added section carries the verified six-row mapping with the evidence for each row, the three named corrections (agents have an equivalent, hooks need no investigation, `AGENTS.md` is not read by default), the two silent-failure prerequisites, why the ticket stopped being purely additive, and the install-path limitation part 3 measured. Verified after posting: the four new sections are present and the original "to be confirmed" mapping still is too. The pair matrix was left alone, see Amendments. ## Validation flow demonstration From 92f0d10fdbb326e39d26a7b90424c731d26c0311 Mon Sep 17 00:00:00 2001 From: Thierry Kwiatkowski Date: Wed, 12 Aug 2026 22:03:22 +0200 Subject: [PATCH 23/23] docs(plan): part 4's success condition passes on a clean environment The lone auth status failure came from an expired AIDD_TOKEN in the developer's shell, which the e2e sandbox redirects HOME around but does not scrub. Without it the suite is 2237/2237. Part 3's chain still does not exit 0: it also runs smoke, whose four corrupt-cache cases fail on published-catalog drift, not on the environment. Co-Authored-By: Claude Opus 5 --- .../2026_07_27-511-gemini-cli-tool-part-4.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/aidd_docs/tasks/2026_07/2026_07_27_gemini-cli-build-target/2026_07_27-511-gemini-cli-tool-part-4.md b/aidd_docs/tasks/2026_07/2026_07_27_gemini-cli-build-target/2026_07_27-511-gemini-cli-tool-part-4.md index 6a05d7612..320f3b40b 100644 --- a/aidd_docs/tasks/2026_07/2026_07_27_gemini-cli-build-target/2026_07_27-511-gemini-cli-tool-part-4.md +++ b/aidd_docs/tasks/2026_07/2026_07_27_gemini-cli-build-target/2026_07_27-511-gemini-cli-tool-part-4.md @@ -181,7 +181,7 @@ flowchart TD - [x] Every path in the re-baseline diff traces to a reference file edited in phase 1 β€” vacuously: the diff is empty, see Amendments - [ ] The re-baseline is documented in the golden suite header with its reason β€” no re-baseline pass occurred, so there is nothing to record there - [x] The shared-tree subset invariant still passes -- [ ] `cd cli && pnpm typecheck && pnpm lint && pnpm test` exits 0 +- [x] `cd cli && pnpm typecheck && pnpm lint && pnpm test` exits 0 β€” 2237/2237 in an environment without a stale `AIDD_TOKEN`; see Amendments ### Phase 4: Correct the record @@ -215,6 +215,8 @@ flowchart TD πŸ€– The manual pair matrix cannot be extended, because the mechanism it records was removed. `cli/aidd_docs/tasks/2026_05/2026_05_06-cli-v5-cleanup-sync-matrix.md` β€” the projection places it at the framework level, it is under `cli/` β€” is a dated record of a manual run from 2026-05-06 against binary v4.1.0-beta.11, exercising `plugin sync --source --target ` and `ai sync`. Neither command exists in the CLI today: `plugin --help` lists only list, install and doctor, `ai --help` has no sync, and nothing registers one in `application/commands/`. Only `sync-conflict-resolver-use-case.ts` survives, serving update. The master plan already recorded "there is no sync command" when dismissing a brainstorm risk, without carrying that conclusion into this task. Adding ten gemini rows would mean writing PASS beside runs that cannot be performed, so the document is left untouched: it remains an accurate record of what was true in May. +πŸ€– This part's `success_condition` does pass. The single `auth status` failure seen throughout is caused by an expired `AIDD_TOKEN` in the developer's shell, which the e2e sandbox redirects `HOME` around but does not scrub. With `env -u AIDD_TOKEN`, the full suite is 2237/2237. CI has no such variable, so the chain exits 0 there. Part 3's own chain still does not, because it additionally runs `pnpm smoke`, whose four corrupt-cache cases fail on published-catalog drift rather than on anything in the environment. + ## Log