moving
Some checks failed
Installer Smoke / installer-smoke (push) Has been cancelled

This commit is contained in:
Oleg Maslov
2026-09-02 10:10:29 +02:00
commit 0c3e2ead3b
3841 changed files with 970576 additions and 0 deletions

View File

@@ -0,0 +1,577 @@
# Pluggable Tool-Adapter Registry Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Collapse the 7 hardcoded external tools (duplicated across 8 structures) into one derived `BUILTIN_TOOL_MANIFESTS` source of truth + an agent registry, and let a self-hosted operator add a PATH-based CLI runtime via `~/.waggle/adapters/*.json` (zod-validated, no `require()`).
**Architecture:** Declarative `ToolManifest` data lives in `@waggle/shared` (the web bundle + sidecar both read it); the cohort/name/pointer consts derive from it. The agent owns behavior: a `tool-registry.ts` that merges built-in manifests with loaded third-party ones, a `tool-manifest-loader.ts` (declarative, data-only), and a `detectInstalledTools` loop driven by the registry. The 3 desktop candidate-path resolvers stay built-in code (escape hatch); third-party adapters are PATH-only.
**Tech Stack:** TypeScript, zod, Vitest. No new runtime deps.
## Global Constraints
- **`SUPPORTED_TOOLS` stays the `as const` literal anchor** — `ToolId` (the union) is unchanged → zero blast radius on the ~5 `Record<ToolId,…>` consumers.
- **Derived consts must equal today's values** for the 7 built-ins (regression-lock).
- **Third-party adapters: `detect.kind:'path'` only.** `'candidates'` is built-in-code-only (the escape hatch).
- **Loader is data-only:** zod + safe-string refinement (no `; | & $ \` ( )`, no `..`, no path separators in `binaryName`); never `require()`/eval; never throws into detection; missing dir → `[]`.
- **Built-in ids win** over any third-party manifest claiming the same id.
- **Registry = pure data (no functions);** the candidate resolvers stay in `tool-detection.ts` keyed by id (no `registry → detection` cycle: loader ← registry ← detection).
- **Gates:** `tsc` 0 (shared/agent/server/web); existing `tool-detection`/`tool-launcher`/`tools-routes*`/`LauncherApp` suites stay green; new units RED→GREEN.
- **Fast-follow (NOT in v1):** applying a third-party `promptArgTemplate` (the prompt-arg path is web-side `apps/web/src/lib/launcher-prompt-args.ts`; the field is captured but unwired).
## Built-in manifest data (the canonical 7 — used verbatim in Task 1)
| id | displayName | launchable | hookCapable | hookPointer | detect |
|---|---|---|---|---|---|
| claude-code | Claude Code | yes | yes | `.claude/hive-mind-install.json` | `path` binary `claude` |
| claude-desktop | Claude Desktop | yes | **no** | `.config/Claude/hive-mind-install.json` | `candidates` |
| cursor | Cursor | yes | yes | `.cursor/hive-mind-install.json` | `candidates` |
| codex | Codex CLI | yes | yes | `.codex/hive-mind-install.json` | `path` binary `codex` |
| codex-desktop | Codex Desktop | yes | yes | `.codex/hive-mind-install.json` | `candidates` |
| hermes | Hermes Agent | yes | yes | `.hermes/hive-mind-install.json` | `path` binary `hermes` |
| openclaw | OpenClaw | yes | yes | `.openclaw/hive-mind-install.json` | `path` binary `openclaw` |
---
### Task 1: `shared` — `ToolManifest` + `BUILTIN_TOOL_MANIFESTS` + derived consts
**Files:**
- Modify: `packages/shared/src/tool-detection.ts`
- Test: `packages/shared/tests/tool-manifests.test.ts` (new; if `packages/shared/tests` doesn't exist, place under the shared package's test glob — check `packages/shared` for an existing `*.test.ts` to mirror the location)
**Interfaces:**
- Produces: `ToolDetectSpec`, `ToolManifest`, `BUILTIN_TOOL_MANIFESTS: readonly ToolManifest[]`, and **re-derived** `TOOL_DISPLAY_NAMES` / `LAUNCH_COHORT`. `SUPPORTED_TOOLS` / `ToolId` unchanged.
- [ ] **Step 1: Write the failing test**`packages/shared/tests/tool-manifests.test.ts`:
```ts
import { describe, it, expect } from 'vitest';
import {
BUILTIN_TOOL_MANIFESTS, SUPPORTED_TOOLS, LAUNCH_COHORT, TOOL_DISPLAY_NAMES,
} from '../src/tool-detection.js';
describe('BUILTIN_TOOL_MANIFESTS', () => {
it('has one manifest per supported tool, ids matching SUPPORTED_TOOLS', () => {
expect(BUILTIN_TOOL_MANIFESTS.map((m) => m.id).sort()).toEqual([...SUPPORTED_TOOLS].sort());
});
it('marks every built-in as builtin:true and launchable', () => {
for (const m of BUILTIN_TOOL_MANIFESTS) { expect(m.builtin).toBe(true); expect(m.launchable).toBe(true); }
});
it('claude-desktop is the only non-hook-capable tool', () => {
const nonHook = BUILTIN_TOOL_MANIFESTS.filter((m) => !m.hookCapable).map((m) => m.id);
expect(nonHook).toEqual(['claude-desktop']);
});
it('derives TOOL_DISPLAY_NAMES + LAUNCH_COHORT from the manifests (unchanged values)', () => {
expect(TOOL_DISPLAY_NAMES['claude-code']).toBe('Claude Code');
expect(TOOL_DISPLAY_NAMES['codex']).toBe('Codex CLI');
expect([...LAUNCH_COHORT].sort()).toEqual([...SUPPORTED_TOOLS].sort());
});
it('claude-code detects by PATH binary "claude" (not its id)', () => {
const cc = BUILTIN_TOOL_MANIFESTS.find((m) => m.id === 'claude-code')!;
expect(cc.detect).toEqual({ kind: 'path', binaryName: 'claude' });
});
});
```
- [ ] **Step 2: Run test to verify it fails**
Run: `npx vitest run packages/shared/tests/tool-manifests.test.ts`
Expected: FAIL — `BUILTIN_TOOL_MANIFESTS` not exported.
- [ ] **Step 3: Implement** in `packages/shared/src/tool-detection.ts` — keep `SUPPORTED_TOOLS`/`ToolId`/`DetectedTool`/`ToolDetectionResult` exactly as-is. Add the types + manifests, and REPLACE the hand-authored `LAUNCH_COHORT` and `TOOL_DISPLAY_NAMES` with derived versions:
```ts
export type ToolDetectSpec =
| { kind: 'path'; binaryName: string }
| { kind: 'candidates' };
export interface ToolManifest {
id: string;
displayName: string;
launchable: boolean;
hookCapable: boolean;
hookPointer: string;
detect: ToolDetectSpec;
/** Declarative inline-prompt arg template for THIRD-PARTY path adapters (e.g.
* ['--print', '{prompt}']). Built-ins keep their logic in launcher-prompt-args.ts.
* Captured in v1; application is a documented fast-follow. */
promptArgTemplate?: string[];
/** true = first-party; false/absent = loaded third-party. */
builtin?: boolean;
}
export const BUILTIN_TOOL_MANIFESTS: readonly ToolManifest[] = [
{ id: 'claude-code', displayName: 'Claude Code', launchable: true, hookCapable: true, hookPointer: '.claude/hive-mind-install.json', detect: { kind: 'path', binaryName: 'claude' }, builtin: true },
{ id: 'claude-desktop', displayName: 'Claude Desktop', launchable: true, hookCapable: false, hookPointer: '.config/Claude/hive-mind-install.json', detect: { kind: 'candidates' }, builtin: true },
{ id: 'cursor', displayName: 'Cursor', launchable: true, hookCapable: true, hookPointer: '.cursor/hive-mind-install.json', detect: { kind: 'candidates' }, builtin: true },
{ id: 'codex', displayName: 'Codex CLI', launchable: true, hookCapable: true, hookPointer: '.codex/hive-mind-install.json', detect: { kind: 'path', binaryName: 'codex' }, builtin: true },
{ id: 'codex-desktop', displayName: 'Codex Desktop', launchable: true, hookCapable: true, hookPointer: '.codex/hive-mind-install.json', detect: { kind: 'candidates' }, builtin: true },
{ id: 'hermes', displayName: 'Hermes Agent', launchable: true, hookCapable: true, hookPointer: '.hermes/hive-mind-install.json', detect: { kind: 'path', binaryName: 'hermes' }, builtin: true },
{ id: 'openclaw', displayName: 'OpenClaw', launchable: true, hookCapable: true, hookPointer: '.openclaw/hive-mind-install.json', detect: { kind: 'path', binaryName: 'openclaw' }, builtin: true },
] as const;
// Derived from the manifests (single source of truth). SUPPORTED_TOOLS stays the
// type anchor above; these keep their ToolId-typed shapes via the cast.
export const TOOL_DISPLAY_NAMES = Object.fromEntries(
BUILTIN_TOOL_MANIFESTS.map((m) => [m.id, m.displayName]),
) as Record<ToolId, string>;
export const LAUNCH_COHORT: readonly ToolId[] =
BUILTIN_TOOL_MANIFESTS.filter((m) => m.launchable).map((m) => m.id as ToolId);
```
(Delete the old literal `LAUNCH_COHORT` and `TOOL_DISPLAY_NAMES` blocks.)
- [ ] **Step 4: Run test + typecheck**
Run: `npx vitest run packages/shared/tests/tool-manifests.test.ts && npx tsc --noEmit -p packages/shared/tsconfig.json`
Expected: PASS + tsc 0.
- [ ] **Step 5: Build shared dist + commit** (downstream tsc reads dist)
```bash
npx tsc -b packages/shared
git add packages/shared/src/tool-detection.ts packages/shared/tests/tool-manifests.test.ts
git commit -m "feat(shared): BUILTIN_TOOL_MANIFESTS source of truth + derived consts (#5)"
```
---
### Task 2: `agent` — declarative manifest loader
**Files:**
- Create: `packages/agent/src/tool-manifest-loader.ts`
- Test: `packages/agent/tests/tool-manifest-loader.test.ts`
**Interfaces:**
- Consumes: `ToolManifest` (Task 1).
- Produces: `loadThirdPartyManifests(deps?: ManifestLoaderDeps): ToolManifest[]`; `ManifestLoaderDeps { dir?: string; readDir?: (dir: string) => string[]; readFile?: (p: string) => string }`.
- [ ] **Step 1: Write the failing test**`packages/agent/tests/tool-manifest-loader.test.ts`:
```ts
import { describe, it, expect } from 'vitest';
import { loadThirdPartyManifests } from '../src/tool-manifest-loader.js';
function deps(files: Record<string, unknown>) {
return {
dir: '/fake',
readDir: () => Object.keys(files),
readFile: (p: string) => JSON.stringify(files[p.split('/').pop()!]),
};
}
describe('loadThirdPartyManifests', () => {
it('loads a valid PATH manifest, stamped builtin:false', () => {
const out = loadThirdPartyManifests(deps({
'foo.json': { id: 'foo-cli', displayName: 'Foo', launchable: true, hookCapable: false, hookPointer: '.foo/hm.json', detect: { kind: 'path', binaryName: 'foo' } },
}));
expect(out).toHaveLength(1);
expect(out[0]).toMatchObject({ id: 'foo-cli', builtin: false, detect: { kind: 'path', binaryName: 'foo' } });
});
it('rejects detect.kind:candidates (code-only strategy)', () => {
const out = loadThirdPartyManifests(deps({
'bad.json': { id: 'bad', displayName: 'B', launchable: true, hookCapable: false, hookPointer: '.b/hm.json', detect: { kind: 'candidates' } },
}));
expect(out).toEqual([]);
});
it('rejects shell-metachar / traversal in fields', () => {
const out = loadThirdPartyManifests(deps({
'evil.json': { id: 'evil', displayName: 'E', launchable: true, hookCapable: false, hookPointer: '../../etc/passwd', detect: { kind: 'path', binaryName: 'foo; rm -rf /' } },
}));
expect(out).toEqual([]);
});
it('returns [] when the dir is missing (readDir throws)', () => {
expect(loadThirdPartyManifests({ dir: '/none', readDir: () => { throw new Error('ENOENT'); }, readFile: () => '' })).toEqual([]);
});
});
```
- [ ] **Step 2: Run test to verify it fails**
Run: `npx vitest run packages/agent/tests/tool-manifest-loader.test.ts`
Expected: FAIL — module not found.
- [ ] **Step 3: Implement** `packages/agent/src/tool-manifest-loader.ts`:
```ts
/**
* AI-OS #5 — declarative loader for third-party tool adapters from
* ~/.waggle/adapters/*.json. Data only: zod-validated, safe-string-refined,
* PATH-detection only, never require()/eval. Never throws into detection.
*/
import os from 'node:os';
import path from 'node:path';
import fs from 'node:fs';
import { z } from 'zod';
import type { ToolManifest } from '@waggle/shared';
// No shell metacharacters, no path traversal, non-empty, bounded.
const SAFE = /^[A-Za-z0-9._/\\-]+$/;
const safe = (max: number) =>
z.string().min(1).max(max).refine((s) => SAFE.test(s) && !s.includes('..'), 'unsafe string');
const ManifestSchema = z.object({
id: safe(64),
displayName: z.string().min(1).max(80),
launchable: z.boolean(),
hookCapable: z.boolean(),
hookPointer: safe(256),
detect: z.object({ kind: z.literal('path'), binaryName: safe(128).refine((s) => !/[\\/]/.test(s), 'binaryName has a path separator') }),
promptArgTemplate: z.array(z.string().max(256)).max(20).optional(),
});
export interface ManifestLoaderDeps {
dir?: string;
readDir?: (dir: string) => string[];
readFile?: (p: string) => string;
}
export function loadThirdPartyManifests(deps: ManifestLoaderDeps = {}): ToolManifest[] {
const dir = deps.dir ?? path.join(os.homedir(), '.waggle', 'adapters');
const readDir = deps.readDir ?? ((d) => fs.readdirSync(d));
const readFile = deps.readFile ?? ((p) => fs.readFileSync(p, 'utf8'));
let names: string[];
try { names = readDir(dir).filter((n) => n.endsWith('.json')); } catch { return []; }
const out: ToolManifest[] = [];
for (const name of names) {
try {
const parsed = ManifestSchema.safeParse(JSON.parse(readFile(path.join(dir, name))));
if (parsed.success) out.push({ ...parsed.data, builtin: false });
} catch { /* skip malformed file */ }
}
return out;
}
```
- [ ] **Step 4: Run test to verify it passes**
Run: `npx vitest run packages/agent/tests/tool-manifest-loader.test.ts`
Expected: PASS (4 cases).
- [ ] **Step 5: Commit** (export wired in Task 3's commit)
```bash
git add packages/agent/src/tool-manifest-loader.ts packages/agent/tests/tool-manifest-loader.test.ts
git commit -m "feat(agent): declarative third-party tool-manifest loader (#5)"
```
---
### Task 3: `agent` — `getToolRegistry()`
**Files:**
- Create: `packages/agent/src/tool-registry.ts`
- Modify: `packages/agent/src/index.ts` (exports)
- Test: `packages/agent/tests/tool-registry.test.ts`
**Interfaces:**
- Consumes: `BUILTIN_TOOL_MANIFESTS` (Task 1), `loadThirdPartyManifests` (Task 2).
- Produces: `getToolRegistry(deps?: ManifestLoaderDeps): ToolManifest[]` (built-ins + loaded third-party; built-in ids win).
- [ ] **Step 1: Write the failing test**`packages/agent/tests/tool-registry.test.ts`:
```ts
import { describe, it, expect } from 'vitest';
import { getToolRegistry } from '../src/tool-registry.js';
import { SUPPORTED_TOOLS } from '@waggle/shared';
const fakeLoaderDeps = (manifests: unknown[]) => ({
dir: '/fake',
readDir: () => manifests.map((_, i) => `m${i}.json`),
readFile: (p: string) => JSON.stringify(manifests[Number(p.match(/m(\d+)\.json/)![1])]),
});
describe('getToolRegistry', () => {
it('includes all built-in tools when no third-party present', () => {
const ids = getToolRegistry({ dir: '/none', readDir: () => { throw new Error('ENOENT'); }, readFile: () => '' }).map((m) => m.id);
expect(ids.sort()).toEqual([...SUPPORTED_TOOLS].sort());
});
it('merges a valid third-party PATH adapter', () => {
const reg = getToolRegistry(fakeLoaderDeps([
{ id: 'foo-cli', displayName: 'Foo', launchable: true, hookCapable: false, hookPointer: '.foo/hm.json', detect: { kind: 'path', binaryName: 'foo' } },
]));
expect(reg.find((m) => m.id === 'foo-cli')).toMatchObject({ builtin: false });
expect(reg.length).toBe(SUPPORTED_TOOLS.length + 1);
});
it('built-in id wins a third-party collision', () => {
const reg = getToolRegistry(fakeLoaderDeps([
{ id: 'claude-code', displayName: 'HIJACK', launchable: true, hookCapable: false, hookPointer: '.x/hm.json', detect: { kind: 'path', binaryName: 'x' } },
]));
const cc = reg.filter((m) => m.id === 'claude-code');
expect(cc).toHaveLength(1);
expect(cc[0].displayName).toBe('Claude Code');
});
});
```
- [ ] **Step 2: Run test to verify it fails**
Run: `npx vitest run packages/agent/tests/tool-registry.test.ts`
Expected: FAIL — module not found.
- [ ] **Step 3: Implement** `packages/agent/src/tool-registry.ts`:
```ts
/**
* AI-OS #5 — the tool-adapter registry. Pure data: the built-in manifests
* (source of truth in @waggle/shared) merged with validated third-party
* manifests from the loader. Built-in ids always win a collision so a
* third-party file can never hijack a first-party tool.
*/
import { BUILTIN_TOOL_MANIFESTS, type ToolManifest } from '@waggle/shared';
import { loadThirdPartyManifests, type ManifestLoaderDeps } from './tool-manifest-loader.js';
export function getToolRegistry(deps?: ManifestLoaderDeps): ToolManifest[] {
const builtins = [...BUILTIN_TOOL_MANIFESTS];
const builtinIds = new Set(builtins.map((m) => m.id));
const thirdParty = loadThirdPartyManifests(deps).filter((m) => !builtinIds.has(m.id));
return [...builtins, ...thirdParty];
}
```
- [ ] **Step 4: Export + run** — add to `packages/agent/src/index.ts` (after the existing tool exports):
```ts
export { getToolRegistry } from './tool-registry.js';
export { loadThirdPartyManifests, type ManifestLoaderDeps } from './tool-manifest-loader.js';
```
Run: `npx vitest run packages/agent/tests/tool-registry.test.ts`
Expected: PASS (3 cases).
- [ ] **Step 5: Typecheck + commit**
```bash
npx tsc --noEmit -p packages/agent/tsconfig.json
git add packages/agent/src/tool-registry.ts packages/agent/src/index.ts
git commit -m "feat(agent): getToolRegistry — built-ins + third-party, built-in wins (#5)"
```
---
### Task 4: `agent` — drive `detectInstalledTools` from the registry
**Files:**
- Modify: `packages/agent/src/tool-detection.ts` (the `detectInstalledTools` orchestrator + remove per-tool wrappers + `detectorsById`; keep `detectByPath`/`detectByCandidates`/`probeHooks`/candidate-path helpers)
- Test: `packages/agent/tests/tool-detection.test.ts` (existing — must stay green; add 1 case for a third-party path adapter)
**Interfaces:**
- Consumes: `getToolRegistry` (Task 3), `BUILTIN_TOOL_MANIFESTS` (for the candidate-resolver keys).
- [ ] **Step 1: Read** the existing `tool-detection.test.ts` to confirm the injected deps (`pathFromEnv`, `exists`, `execVersion`) and that it drives `detectInstalledTools(opts)`. The refactor must keep those exact seams.
- [ ] **Step 2: Write the failing test** — append to `tool-detection.test.ts` (mirror its existing `detectInstalledTools({...injected deps...})` setup; add a manifest-loader dep so a third-party adapter is present):
```ts
it('detects a third-party PATH adapter from the registry', async () => {
const result = await detectInstalledTools({
platform: 'linux',
pathFromEnv: async (bin: string) => (bin === 'foo' ? '/usr/bin/foo' : null),
exists: async (p: string) => p === '/usr/bin/foo',
execVersion: async () => '1.0.0',
// NEW: inject the manifest-loader deps so the registry includes a 3rd-party tool
manifestLoader: {
dir: '/fake',
readDir: () => ['foo.json'],
readFile: () => JSON.stringify({ id: 'foo-cli', displayName: 'Foo', launchable: true, hookCapable: false, hookPointer: '.foo/hm.json', detect: { kind: 'path', binaryName: 'foo' } }),
},
});
const foo = result.tools.find((t) => t.id === 'foo-cli');
expect(foo?.installed).toBe(true);
expect(foo?.installedPath).toBe('/usr/bin/foo');
});
```
- [ ] **Step 3: Run test to verify it fails**
Run: `npx vitest run packages/agent/tests/tool-detection.test.ts -t "third-party"`
Expected: FAIL — registry not wired; `manifestLoader` dep unknown.
- [ ] **Step 4: Implement** in `packages/agent/src/tool-detection.ts`:
(a) Imports — add `getToolRegistry` + the manifest type, and `BUILTIN_TOOL_MANIFESTS` is already imported via shared (it imports `SUPPORTED_TOOLS`/`TOOL_DISPLAY_NAMES`):
```ts
import { getToolRegistry } from './tool-registry.js';
import type { ManifestLoaderDeps } from './tool-manifest-loader.js';
import type { ToolManifest } from '@waggle/shared';
```
(b) A candidate-resolver map keyed by built-in id (the escape hatch — the 3 desktop tools). Place it near the candidate-path helpers:
```ts
const CANDIDATE_RESOLVERS: Record<string, (deps: ResolvedDeps) => string[]> = {
'cursor': cursorCandidatePaths,
'claude-desktop': claudeDesktopCandidatePaths,
'codex-desktop': codexDesktopCandidatePaths,
};
```
(c) A manifest-driven single detector replacing the per-tool wrappers:
```ts
async function detectFromManifest(m: ToolManifest, deps: ResolvedDeps): Promise<DetectedTool> {
if (m.detect.kind === 'path') {
return detectByPath(m.id as ToolId, m.detect.binaryName, deps, m.hookPointer, m.displayName);
}
const resolver = CANDIDATE_RESOLVERS[m.id];
const candidates = resolver ? resolver(deps) : [];
return detectByCandidates(m.id as ToolId, candidates, deps, /* withVersion */ false, m.hookPointer, m.displayName);
}
```
(d) Generalize `detectByPath` / `detectByCandidates` / `probeHooks` to take `hookPointer` + `displayName` as params instead of reading `TOOL_DISPLAY_NAMES[id]` / `HOOK_POINTER_BY_TOOL[id]` (so third-party ids work). Change their signatures to accept `hookPointer: string, displayName: string`, and have `probeHooks(hookPointer, deps)` use the passed pointer. Keep `HOOK_POINTER_BY_TOOL` as a derived export for back-compat:
```ts
// derived from the manifests (was a hand-authored Record)
export const HOOK_POINTER_BY_TOOL = Object.fromEntries(
BUILTIN_TOOL_MANIFESTS.map((m) => [m.id, m.hookPointer]),
) as Record<ToolId, string>;
```
(Import `BUILTIN_TOOL_MANIFESTS` from `@waggle/shared` for this.)
(e) Replace `detectInstalledTools`'s body:
```ts
export async function detectInstalledTools(
opts: ToolDetectionDeps & { manifestLoader?: ManifestLoaderDeps } = {},
): Promise<ToolDetectionResult> {
const deps = resolveDeps(opts);
const registry = getToolRegistry(opts.manifestLoader);
const tools = await Promise.all(registry.map((m) => detectFromManifest(m, deps)));
return { platform: deps.platform, detectedAt: new Date().toISOString(), tools };
}
```
(f) Delete `detectClaudeCode`, `detectCursor`, `detectClaudeDesktop`, `detectCodex`, `detectHermes`, `detectOpenClaw`, `detectCodexDesktop`, the old `detectorsById`, and the old hand-authored `HOOK_POINTER_BY_TOOL` block. Keep `detectByPath`, `detectByCandidates`, `probeHooks`, and the 3 `*CandidatePaths` helpers.
> Note: `detectByPath` currently hardcodes `pathFromEnv('claude')` only in the inline claude-code path — the generic `detectByPath` already takes `binaryName`, so claude-code now flows through it with `binaryName: 'claude'` from its manifest. Drop the special-case inline detector.
- [ ] **Step 5: Run the FULL existing suite + new case**
Run: `npx vitest run packages/agent/tests/tool-detection.test.ts`
Expected: PASS — all existing 7-tool detection assertions + the new third-party case.
- [ ] **Step 6: Typecheck + commit**
```bash
npx tsc --noEmit -p packages/agent/tsconfig.json
git add packages/agent/src/tool-detection.ts packages/agent/tests/tool-detection.test.ts
git commit -m "feat(agent): registry-driven detectInstalledTools (#5)"
```
---
### Task 5: `agent` — derive launcher cohorts from the registry
**Files:**
- Modify: `packages/agent/src/tool-launcher.ts` (`HOOKS_COHORT`, `hookPackageFor`, the `launchTool` cohort guard)
- Test: `packages/agent/tests/tool-launcher.test.ts` (existing — stays green; add a manifest-derivation assertion)
**Interfaces:**
- Consumes: `BUILTIN_TOOL_MANIFESTS` (Task 1).
- [ ] **Step 1: Write the failing test** — append to `tool-launcher.test.ts`:
```ts
import { BUILTIN_TOOL_MANIFESTS } from '@waggle/shared';
it('HOOKS_COHORT equals the hook-capable manifests (claude-desktop excluded)', () => {
const expected = BUILTIN_TOOL_MANIFESTS.filter((m) => m.hookCapable).map((m) => m.id).sort();
expect([...HOOKS_COHORT].sort()).toEqual(expected);
expect(HOOKS_COHORT).not.toContain('claude-desktop');
});
```
(`HOOKS_COHORT` is already imported/exported from `tool-launcher.ts`; add the import if missing.)
- [ ] **Step 2: Run test to verify it fails**
Run: `npx vitest run packages/agent/tests/tool-launcher.test.ts -t "HOOKS_COHORT equals"`
Expected: FAIL — if the literal differs OR (more likely) passes already; if it passes, still proceed to make the derivation real so it can't drift.
- [ ] **Step 3: Implement** in `packages/agent/src/tool-launcher.ts` — replace the literal `HOOKS_COHORT` with a derived one and let `hookPackageFor` honor a manifest override:
```ts
import { BUILTIN_TOOL_MANIFESTS, type ToolId } from '@waggle/shared';
export const HOOKS_COHORT: readonly ToolId[] =
BUILTIN_TOOL_MANIFESTS.filter((m) => m.hookCapable).map((m) => m.id as ToolId);
```
(Keep `LAUNCH_COHORT`'s import from shared as-is — it's already derived in Task 1. The `launchTool` guard `if (!LAUNCH_COHORT.includes(opts.id))` is unchanged.)
- [ ] **Step 4: Run the full launcher suite**
Run: `npx vitest run packages/agent/tests/tool-launcher.test.ts`
Expected: PASS (existing 39 + new).
- [ ] **Step 5: Typecheck + commit**
```bash
npx tsc --noEmit -p packages/agent/tsconfig.json
git add packages/agent/src/tool-launcher.ts packages/agent/tests/tool-launcher.test.ts
git commit -m "feat(agent): derive HOOKS_COHORT from the manifest registry (#5)"
```
---
### Task 6: `web` — derive LauncherApp cohorts from shared manifests
**Files:**
- Modify: `apps/web/src/components/os/apps/LauncherApp.tsx` (the local `LAUNCH_COHORT` / `HOOKS_COHORT` copies, ~lines 4159)
- Test: `apps/web/src/components/os/apps/LauncherApp.test.tsx` (existing — stays green)
- [ ] **Step 1: Implement** — replace the hand-maintained local arrays with derivations from the shared manifests (kills the "kept local to avoid a runtime import" duplication the comments call out):
```ts
import { BUILTIN_TOOL_MANIFESTS } from '@waggle/shared';
const LAUNCH_COHORT = BUILTIN_TOOL_MANIFESTS.filter((m) => m.launchable).map((m) => m.id);
const HOOKS_COHORT = BUILTIN_TOOL_MANIFESTS.filter((m) => m.hookCapable).map((m) => m.id);
```
(Delete the two local literal arrays + their explanatory comments.)
- [ ] **Step 2: Run the LauncherApp suite + web typecheck**
Run: `cd apps/web && npx vitest run -c vitest.config.ts src/components/os/apps/LauncherApp.test.tsx && npx tsc --noEmit`
Expected: PASS (6 tests) + tsc 0.
- [ ] **Step 3: Commit**
```bash
git add apps/web/src/components/os/apps/LauncherApp.tsx
git commit -m "feat(web): derive LauncherApp cohorts from shared manifests (#5)"
```
---
### Final Gate
- [ ] **Build dists then full typecheck:** `npx tsc -b packages/shared packages/agent && npx tsc --noEmit -p packages/server/tsconfig.json && (cd apps/web && npx tsc --noEmit)` → 0.
- [ ] **Touched suites:** `npx vitest run packages/shared/tests/tool-manifests.test.ts packages/agent/tests/tool-manifest-loader.test.ts packages/agent/tests/tool-registry.test.ts packages/agent/tests/tool-detection.test.ts packages/agent/tests/tool-launcher.test.ts` + `(cd apps/web && npx vitest run -c vitest.config.ts src/components/os/apps/LauncherApp.test.tsx)`.
- [ ] **Server route sanity:** `npx vitest run packages/server/tests/tools-routes.test.ts packages/server/tests/tools-routes-launch.test.ts` (they consume detect/launch — must stay green).
- [ ] **Lint** touched files.
## Fast-follow (documented, NOT silently dropped)
**Third-party `promptArgTemplate` application.** The field is captured + validated in v1 but unwired:
the inline-prompt arg logic lives web-side (`apps/web/src/lib/launcher-prompt-args.ts`) and the dock
computes args before POSTing. Wiring a loaded manifest's `promptArgTemplate` means surfacing the
registry (or its prompt templates) to the web bundle — additive, deferred. Built-in prompt-args are
unchanged.
## Self-Review
**Spec coverage:** §3 Unit A → Task 1 · Unit B (registry) → Task 3 · Unit B (detection) → Task 4 · Unit B (launcher) → Task 5 · Unit C (loader) → Task 2 · web derivation (§7 file list) → Task 6 · §5 security → Task 2 tests. The `promptArgTemplate` application is logged as a fast-follow (field captured in Task 1).
**Placeholder scan:** Task 4 Step 1 + Task 6 are read/edit steps with concrete code; no TBD/TODO. Every code step ships real code.
**Type consistency:** `ToolManifest`/`ToolDetectSpec` (Task 1) consumed unchanged in Tasks 26. `loadThirdPartyManifests(deps)` (Task 2) → `getToolRegistry(deps)` (Task 3) → `detectInstalledTools({manifestLoader})` (Task 4). `getToolRegistry` returns `ToolManifest[]`. `detectFromManifest`/`CANDIDATE_RESOLVERS` names consistent within Task 4. `HOOKS_COHORT`/`LAUNCH_COHORT` derived identically in Tasks 1/5/6.