diff --git a/README.md b/README.md index c0c2e55..fa9c4c6 100644 --- a/README.md +++ b/README.md @@ -3,27 +3,36 @@ HappyVertical shared cross-repo configuration for agent-assisted development. This repo is the umbrella for configuration that ≥2 HappyVertical projects -consume. Today that's Claude Code command adapters and Codex workflow skills. Other -shared config (MCP servers, agent hooks, lint/format/tsconfig bases) will -land here as second consumers appear. +consume. Today that's organization standards, service registry docs, agent +instruction snippets, profile-specific commands/skills, and shared +lint/format/tsconfig bases. Runtime agent behavior is installed as local files; +Context Forge is consumed as an install-time snapshot by the have-config +resolver. ## What lives here **In scope:** -- Claude Code command adapters and Codex-visible workflow skills (`claude/`, `codex/`) +- HappyVertical service registry and infrastructure docs (`docs/`, `services/`) +- Organization agent instruction snippets (`agent-doc-snippets/`) +- Profile-specific commands and skills, such as Hermes `check-setup` + (`profiles/`) +- Agent manifests consumed by the have-config resolver (`hv/manifest.json`, + `profiles/*/manifest.json`) - Shared lint / format / tsconfig configs as published npm packages (`packages/eslint-config`, `packages/prettier-config`, `packages/tsconfig-base`) - MCP server configs consumed by ≥2 projects (planned — `TODO.md`) - Agent hook scripts (planned — `TODO.md`) - CLAUDE.md / AGENTS.md template sections that should be identical across - repos (planned — `TODO.md`) + repos **Out of scope:** - Runnable tools — these get their own repos (see [`happyvertical/pr-review`](https://github.com/happyvertical/pr-review)) +- Generic personal workflows such as `ship` and `review-cycle`; those live in + dotfiles and are consumed here as the lowest-priority baseline - Per-repo specifics (e.g. anytown's `apps/dashboard/docs/ad-network.md`) - Anything used by exactly one project @@ -40,32 +49,26 @@ have-config/ ├── LICENSE ├── install.sh # one-line setup (agents) ├── TODO.md # planned additions, with consumer count +├── hv/ +│ └── manifest.json # organization source manifest +├── profiles/ +│ └── hermes/ +│ ├── manifest.json # Hermes-only commands and skills +│ ├── commands/ +│ │ ├── claude/check-setup.md +│ │ └── codex/check-setup.md +│ └── skills/check-setup/ +│ └── SKILL.md +├── agent-doc-snippets/ # cumulative AGENTS / CLAUDE sections +├── docs/ +│ ├── agent-playbook.md # what agents use each service for +│ └── infrastructure.md # HappyVertical service map +├── services/ +│ └── services.json # machine-readable service registry ├── package.json # pnpm workspace root ├── pnpm-workspace.yaml ├── .changeset/ # versioning + publish manifests │ └── config.json -├── claude/ # Claude Code marketplace -│ ├── .claude-plugin/ -│ │ └── marketplace.json -│ └── have/ # the `have` plugin -│ ├── .claude-plugin/ -│ │ └── plugin.json -│ └── commands/ -│ ├── ship.md # /have:ship adapter -│ └── review-cycle.md # /have:review-cycle adapter -├── codex/ # Codex marketplace -│ └── plugins/ -│ └── have/ # the `have` plugin -│ ├── .codex-plugin/ -│ │ └── plugin.json -│ ├── commands/ -│ │ ├── ship.md # /have:ship adapter -│ │ └── review-cycle.md # /have:review-cycle adapter -│ └── skills/ # Codex adapters to ContextForge -│ ├── ship/ -│ │ └── SKILL.md # have:ship -│ └── review-cycle/ -│ └── SKILL.md # have:review-cycle └── packages/ # published npm configs ├── eslint-config/ # @happyvertical/eslint-config ├── prettier-config/ # @happyvertical/prettier-config @@ -110,7 +113,7 @@ See each package's README for usage details: Configs are versioned via Changesets and published on merge to `main`. -## Plugin install (agent workflows) +## Agent Bootstrap ```bash git clone https://github.com/happyvertical/have-config.git ~/Work/happyvertical/repos/have-config @@ -120,60 +123,68 @@ cd ~/Work/happyvertical/repos/have-config `install.sh` does: -1. Clones [`happyvertical/pr-review`](https://github.com/happyvertical/pr-review) - if missing, adds `pr-review/bin` to a shell-rc snippet. -2. Registers this repo as a marketplace for both Claude Code and Codex. -3. Installs the `have` plugin in both agents. -4. Optionally symlinks the cached plugin install back to the live repo - path for in-place editing (`./install.sh --live`). +1. Clones or updates the configured dotfiles repo and runs its `install.sh` + unless `--skip-dotfiles` or `HAVE_CONFIG_SKIP_DOTFILES=1` is set. +2. Clones [`happyvertical/pr-review`](https://github.com/happyvertical/pr-review) + if missing, adds `pr-review/bin` to the current install PATH. +3. Resolves dotfiles, have-config, active profile, Context Forge snapshot, and + local overrides into local generated agent files. -After install, Claude Code has: +By default, have-config caches dotfiles under `~/.config/hv/dotfiles` for +workstations and `~/.hermes/dotfiles` for Hermes agents. Set `DOTFILES_DIR` to +use a personal checkout instead. -- `/have:ship` — end-to-end shipping pipeline -- `/have:review-cycle` — multi-tool review/fix/retest loop +Dotfiles contributes generic baseline workflows such as `ship` and +`review-cycle` through its `agent/manifest.json`. have-config contributes +HappyVertical organization standards, service playbooks, service registry data, +and profile-specific additions. -Codex has equivalent skills: +Hermes agents additionally get local generated commands/skills: -- `have:ship` — end-to-end shipping pipeline -- `have:review-cycle` — multi-tool review/fix/retest loop +- `/check-setup` / `check-setup` — verifies agent access to HappyVertical + services -## Editing +## Agent resolution model -ContextForge is the source of truth for workflow bodies: +The have-config installer composes agent behavior in this order: -| Workflow | Prompt | Resource | -|---|---|---| -| Ship | `have-ship` | `have://happyvertical/workflows/ship` | -| Review cycle | `have-review-cycle` | `have://happyvertical/workflows/review-cycle` | +1. dotfiles baseline workflows +2. have-config organization standard +3. active profile defaults, such as Hermes +4. Context Forge install-time snapshot +5. machine-local overrides -The resources live in the **Happy Vertical** team at -`context.happyvertical.com`. They store the workflow markdown as a base64 -payload because ContextForge's default content scanner rejects raw shell-heavy -workflow markdown. The prompt loaders decode that payload and follow it as the -authoritative workflow. +For command and skill conflicts, later layers win. For AGENTS and CLAUDE +behavior, sections are cumulative and assembled in layer order. -Edits to workflow behavior should happen in ContextForge, not in this repo. -This lets prompt/workflow changes go live without redeploying or reinstalling -the agent plugins. +Context Forge remains the dynamic organization policy source, but it is not a +runtime dependency for normal agent behavior. Export it into +`HV_CONTEXTFORGE_SNAPSHOT_DIR` and rerun `./install.sh` to materialize new +local command/skill files and update `agent-lock.json`. -Edits to the adapter files are picked up: +Hermes profile detection uses explicit environment first: -- **Live mode** (`install.sh --live`): edits are immediately visible to - running sessions via symlink. May need re-linking after - `claude plugin update` rewrites the cache. -- **Standard mode**: edits require `claude plugin update have@have-config` - for Claude, or rerunning `./install.sh` to refresh the Codex plugin cache. +- `HV_AGENT_PROFILE=hermes` or `AGENT_PROFILE=hermes` +- `HERMES`, `HERMES_AGENT`, `HERMES_AGENT_ID`, or `HERMES_HOME` +- `~/.hermes/profile.json` or `~/.hermes/.profile-hermes` -The Claude command files and Codex `skills/` files must remain thin adapters. -Do not put canonical workflow text back into this repo. +When Hermes is active, generated files and reports default under `~/.hermes`. +Local overrides default to `~/.config/hv/overrides` for workstations and +`~/.hermes/overrides` for Hermes agents. The installer creates templates there +but never deletes or rewrites existing override files. On first install, +existing unmanaged global `AGENTS.md` / `CLAUDE.md` files are adopted into that +override directory before generated docs are linked. + +Edits to dotfiles, have-config, profile files, Context Forge snapshots, or +local overrides are picked up by rerunning `./install.sh`. ## Companion tool -The shipping/review commands generate review prompts via +The baseline shipping/review workflows can generate review prompts via [`pr-review`](https://github.com/happyvertical/pr-review). pr-review stays a standalone tool because it has a broader audience (anyone running -pre-PR review with any LLM, regardless of harness). have-config wraps -pr-review in opinionated workflow commands; pr-review itself is unopinionated. +pre-PR review with any LLM, regardless of harness). have-config ensures the +tool is available while organization standards remain in this repo. ## License diff --git a/TODO.md b/TODO.md index e2f5885..c69412a 100644 --- a/TODO.md +++ b/TODO.md @@ -10,8 +10,13 @@ that needs it. ## Slash commands and agent surfaces -- [x] `claude/` — Claude Code `have` plugin -- [x] `codex/plugins/have/` — Codex `have` plugin +- [x] `hv/manifest.json` — org-owned agent docs, env requirements, and service + metadata consumed by the resolver +- [x] `profiles/hermes/` — Hermes-only commands and skills such as + `check-setup` +- [ ] `claude/` / `codex/` packaged surfaces, only if a future second + consumer needs marketplace/plugin distribution instead of generated local + files - [ ] `cursor/` — Cursor commands, when there's a second project using Cursor as the primary IDE @@ -51,16 +56,28 @@ Pick when the first consumer pair exists, not before. ## CLAUDE.md / AGENTS.md template sections -- [ ] `agent-doc-snippets/` — durable policy text that should be +- [x] `agent-doc-snippets/` — durable policy text that should be identical across repos: - - No Workarounds Policy - - No Private API Reach-Ins - - Git Workflow SOPs - - Git And PR SOP - - Review Readiness - - Today every repo carries its own copy and drift is a recurring - problem. Lifting these into snippets would require a - consumption mechanism (probably a generator script that - assembles each repo's CLAUDE.md from snippets + repo-specific - sections). + - HappyVertical source layering + - runtime behavior + - identity and secrets + - service map + + The have-config resolver assembles these snippets with active profiles, + Context Forge snapshots, and local overrides. + +## Service registry and infrastructure docs + +- [x] `docs/infrastructure.md` — HappyVertical service map for humans and agents +- [x] `docs/agent-playbook.md` — agent-facing guide for which service to use + for identity, secrets, files, tasks, chat, gateway access, and memory +- [x] `services/services.json` — machine-readable service registry consumed by + have-config reports + +## Context Forge snapshots + +- [x] `hv/manifest.json` — source manifest consumed by have-config +- [x] `profiles/hermes/manifest.json` — Hermes-only defaults such as + `check-setup` +- [ ] Publish an exporter from Context Forge into the manifest shape expected by + have-config (`manifest.json` with `skills`, `commands`, and `agent_docs`). diff --git a/agent-doc-snippets/happyvertical-standards.md b/agent-doc-snippets/happyvertical-standards.md new file mode 100644 index 0000000..10e388b --- /dev/null +++ b/agent-doc-snippets/happyvertical-standards.md @@ -0,0 +1,28 @@ +# HappyVertical Organization Standards + +## Source Layers +- Treat `dotfiles` as the personal baseline for workstation and agent bootstrap. +- Treat `have-config` as the HappyVertical organization standard for shared agent behavior, infrastructure docs, and reusable development config. +- Treat Context Forge snapshots as dynamic organization policy materialized during install/update. +- Treat machine-local overrides as explicit exceptions that must be visible in install reports. + +## Runtime Behavior +- Agents must use local installed command, skill, and instruction files at runtime. +- Agents should not depend on live Context Forge access during normal task execution. +- When command or skill definitions conflict, use this order: local override, Context Forge snapshot, `have-config`, then `dotfiles`. +- AGENTS and CLAUDE instructions are cumulative; do not discard lower-layer instructions unless a higher layer explicitly supersedes them. + +## Identity And Secrets +- Account identity is per user or per agent. Do not commit account-specific addresses, tokens, or passwords. +- Use `idp.happyvertical.com` for HappyVertical identity and SSO. +- Use Warden for password sharing and retrieval. +- Use SOPS only for machine-provided encrypted environment material and templates; do not place real user-specific values in this repo. + +## HappyVertical Services +- Use `warden.happyvertical.com` for approved password and shared secret access. +- Use `drive.happyvertical.com` for OxiCloud file sharing. +- Use `todo.happyvertical.com` for Vikunja project management. +- Use `stoat.happyvertical.com` for chat and collaboration. +- Use `bifrost.happyvertical.com` as the gateway. +- Use `context.happyvertical.com` for prompts, resources, and memory snapshots. +- Hermes agents run `check-setup` after bootstrap or account changes to verify service access. diff --git a/claude/.claude-plugin/marketplace.json b/claude/.claude-plugin/marketplace.json deleted file mode 100644 index 66281b7..0000000 --- a/claude/.claude-plugin/marketplace.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "$schema": "https://anthropic.com/claude-code/marketplace.schema.json", - "name": "have-config", - "description": "HappyVertical shared agent configuration: ContextForge-backed command adapters, MCP servers, hooks, and other cross-repo configuration consumed by ≥2 projects.", - "owner": { - "name": "Will Griffin", - "email": "buddyrandom@gmail.com" - }, - "plugins": [ - { - "name": "have", - "description": "HappyVertical ContextForge-backed command adapters for Claude Code: /have:ship and /have:review-cycle.", - "author": { - "name": "Will Griffin", - "email": "buddyrandom@gmail.com" - }, - "category": "development", - "source": "./have", - "homepage": "https://github.com/happyvertical/have-config" - } - ] -} diff --git a/claude/have/.claude-plugin/plugin.json b/claude/have/.claude-plugin/plugin.json deleted file mode 100644 index a06fe08..0000000 --- a/claude/have/.claude-plugin/plugin.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "name": "have", - "description": "HappyVertical ContextForge-backed command adapters for Claude Code. Provides /have:ship and /have:review-cycle by loading the authoritative workflow prompts/resources from ContextForge.", - "version": "0.1.1", - "author": { - "name": "Will Griffin", - "email": "buddyrandom@gmail.com", - "url": "https://github.com/willgriffin" - }, - "homepage": "https://github.com/happyvertical/have-config", - "license": "MIT", - "keywords": [ - "claude-code", - "slash-command", - "shipping", - "review", - "ci", - "happyvertical", - "pr-review" - ] -} diff --git a/claude/have/commands/review-cycle.md b/claude/have/commands/review-cycle.md deleted file mode 100644 index 0b9a3ca..0000000 --- a/claude/have/commands/review-cycle.md +++ /dev/null @@ -1,16 +0,0 @@ ---- -description: "Run the HappyVertical review-cycle workflow from ContextForge." ---- - -# /have:review-cycle - -This local command is only an adapter. The authoritative workflow lives in ContextForge. - -Load and follow the Happy Vertical Team prompt: - -- Prompt: `have-review-cycle` -- Resource: `have://happyvertical/workflows/review-cycle` - -Use ContextForge MCP prompts/resources when available. If prompt invocation is unavailable, read the resource by URI. The resource text contains `encoding: base64` and a `payload_base64:` block; decode that payload as UTF-8 markdown and follow it exactly as the review-cycle workflow. - -If ContextForge is unavailable, the prompt is missing, or the resource cannot be read or decoded, stop and report the ContextForge access problem instead of falling back to a stale local workflow. diff --git a/claude/have/commands/ship.md b/claude/have/commands/ship.md deleted file mode 100644 index 6f3c13c..0000000 --- a/claude/have/commands/ship.md +++ /dev/null @@ -1,16 +0,0 @@ ---- -description: "Run the HappyVertical ship workflow from ContextForge." ---- - -# /have:ship - -This local command is only an adapter. The authoritative workflow lives in ContextForge. - -Load and follow the Happy Vertical Team prompt: - -- Prompt: `have-ship` -- Resource: `have://happyvertical/workflows/ship` - -Use ContextForge MCP prompts/resources when available. If prompt invocation is unavailable, read the resource by URI. The resource text contains `encoding: base64` and a `payload_base64:` block; decode that payload as UTF-8 markdown and follow it exactly as the ship workflow. - -If ContextForge is unavailable, the prompt is missing, or the resource cannot be read or decoded, stop and report the ContextForge access problem instead of falling back to a stale local workflow. diff --git a/codex/.agents/plugins/marketplace.json b/codex/.agents/plugins/marketplace.json deleted file mode 100644 index d52e6ac..0000000 --- a/codex/.agents/plugins/marketplace.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "name": "have-config", - "interface": { - "displayName": "HappyVertical Agent Config", - "shortDescription": "ContextForge-backed cross-repo agent configuration for HappyVertical projects" - }, - "plugins": [ - { - "name": "have", - "source": { - "source": "local", - "path": "./plugins/have" - }, - "policy": { - "installation": "AVAILABLE", - "authentication": "ON_INSTALL" - }, - "category": "Engineering" - } - ] -} diff --git a/codex/plugins/have/.codex-plugin/plugin.json b/codex/plugins/have/.codex-plugin/plugin.json deleted file mode 100644 index 533ac61..0000000 --- a/codex/plugins/have/.codex-plugin/plugin.json +++ /dev/null @@ -1,43 +0,0 @@ -{ - "name": "have", - "version": "0.1.1", - "description": "HappyVertical agent workflow adapters for Codex. Provides skills for have:ship and have:review-cycle by loading the authoritative workflow prompts/resources from ContextForge.", - "author": { - "name": "Will Griffin", - "email": "buddyrandom@gmail.com", - "url": "https://github.com/willgriffin" - }, - "homepage": "https://github.com/happyvertical/have-config", - "repository": "https://github.com/happyvertical/have-config", - "license": "MIT", - "keywords": [ - "codex", - "skill", - "slash-command", - "shipping", - "review", - "ci", - "happyvertical", - "pr-review" - ], - "skills": "./skills/", - "interface": { - "displayName": "have — HappyVertical Workflows", - "shortDescription": "ContextForge-backed review and shipping workflows for HappyVertical projects", - "longDescription": "Provides Codex-visible skills for have:review-cycle and have:ship. The skills are thin adapters that load the authoritative workflow prompts/resources from ContextForge in the Happy Vertical team.", - "developerName": "Will Griffin", - "category": "Coding", - "capabilities": [ - "Interactive", - "Read", - "Write" - ], - "websiteURL": "https://github.com/happyvertical/have-config", - "defaultPrompt": [ - "Use have:review-cycle for repeat reviews and fixes.", - "Use have:ship to validate, review, PR, and watch CI." - ], - "brandColor": "#0F766E", - "screenshots": [] - } -} diff --git a/codex/plugins/have/commands/review-cycle.md b/codex/plugins/have/commands/review-cycle.md deleted file mode 100644 index 0b9a3ca..0000000 --- a/codex/plugins/have/commands/review-cycle.md +++ /dev/null @@ -1,16 +0,0 @@ ---- -description: "Run the HappyVertical review-cycle workflow from ContextForge." ---- - -# /have:review-cycle - -This local command is only an adapter. The authoritative workflow lives in ContextForge. - -Load and follow the Happy Vertical Team prompt: - -- Prompt: `have-review-cycle` -- Resource: `have://happyvertical/workflows/review-cycle` - -Use ContextForge MCP prompts/resources when available. If prompt invocation is unavailable, read the resource by URI. The resource text contains `encoding: base64` and a `payload_base64:` block; decode that payload as UTF-8 markdown and follow it exactly as the review-cycle workflow. - -If ContextForge is unavailable, the prompt is missing, or the resource cannot be read or decoded, stop and report the ContextForge access problem instead of falling back to a stale local workflow. diff --git a/codex/plugins/have/commands/ship.md b/codex/plugins/have/commands/ship.md deleted file mode 100644 index 6f3c13c..0000000 --- a/codex/plugins/have/commands/ship.md +++ /dev/null @@ -1,16 +0,0 @@ ---- -description: "Run the HappyVertical ship workflow from ContextForge." ---- - -# /have:ship - -This local command is only an adapter. The authoritative workflow lives in ContextForge. - -Load and follow the Happy Vertical Team prompt: - -- Prompt: `have-ship` -- Resource: `have://happyvertical/workflows/ship` - -Use ContextForge MCP prompts/resources when available. If prompt invocation is unavailable, read the resource by URI. The resource text contains `encoding: base64` and a `payload_base64:` block; decode that payload as UTF-8 markdown and follow it exactly as the ship workflow. - -If ContextForge is unavailable, the prompt is missing, or the resource cannot be read or decoded, stop and report the ContextForge access problem instead of falling back to a stale local workflow. diff --git a/codex/plugins/have/skills/review-cycle/SKILL.md b/codex/plugins/have/skills/review-cycle/SKILL.md deleted file mode 100644 index 51d5f6a..0000000 --- a/codex/plugins/have/skills/review-cycle/SKILL.md +++ /dev/null @@ -1,19 +0,0 @@ ---- -name: review-cycle -description: Use when the user invokes /review-cycle, /have:review-cycle, have:review-cycle, or asks to run HappyVertical's bounded multi-reviewer review, fix, and retest loop before shipping. -metadata: - short-description: Run HappyVertical's ContextForge review cycle ---- - -# Have Review Cycle - -This Codex skill is only an adapter. The authoritative workflow lives in ContextForge. - -Load and follow the Happy Vertical Team prompt: - -- Prompt: `have-review-cycle` -- Resource: `have://happyvertical/workflows/review-cycle` - -Use ContextForge MCP prompts/resources when available. If prompt invocation is unavailable, read the resource by URI. The resource text contains `encoding: base64` and a `payload_base64:` block; decode that payload as UTF-8 markdown and follow it exactly as the review-cycle workflow. - -If ContextForge is unavailable, the prompt is missing, or the resource cannot be read or decoded, stop and report the ContextForge access problem instead of falling back to a stale local workflow. diff --git a/codex/plugins/have/skills/ship/SKILL.md b/codex/plugins/have/skills/ship/SKILL.md deleted file mode 100644 index 32fdb20..0000000 --- a/codex/plugins/have/skills/ship/SKILL.md +++ /dev/null @@ -1,19 +0,0 @@ ---- -name: ship -description: Use when the user invokes /ship, /have:ship, have:ship, or asks to ship current work using HappyVertical's standard workflow. -metadata: - short-description: Run HappyVertical's ContextForge ship workflow ---- - -# Have Ship - -This Codex skill is only an adapter. The authoritative workflow lives in ContextForge. - -Load and follow the Happy Vertical Team prompt: - -- Prompt: `have-ship` -- Resource: `have://happyvertical/workflows/ship` - -Use ContextForge MCP prompts/resources when available. If prompt invocation is unavailable, read the resource by URI. The resource text contains `encoding: base64` and a `payload_base64:` block; decode that payload as UTF-8 markdown and follow it exactly as the ship workflow. - -If ContextForge is unavailable, the prompt is missing, or the resource cannot be read or decoded, stop and report the ContextForge access problem instead of falling back to a stale local workflow. diff --git a/docs/agent-playbook.md b/docs/agent-playbook.md new file mode 100644 index 0000000..7d7d9e2 --- /dev/null +++ b/docs/agent-playbook.md @@ -0,0 +1,45 @@ +# HappyVertical Agent Playbook + +This playbook defines what HappyVertical agents should use each organization +service for. Account identity and credentials are per user or per agent; do not +commit them to any repo. + +## Core Services + +| Need | Service | URL | Agent expectation | +| --- | --- | --- | --- | +| Identity and SSO | HappyVertical IDP | `https://idp.happyvertical.com` | Use the assigned account identity and confirm SSO works before accessing protected services. | +| Email and account identity | HappyVertical email | per account | Use the assigned email account for identity, notifications, and service login recovery. | +| Passwords and shared secrets | Warden | `https://warden.happyvertical.com` | Retrieve credentials from Warden or approved local secret material; never ask users to paste secrets into prompts. | +| File sharing | OxiCloud | `https://drive.happyvertical.com` | Use OxiCloud for shared files and WebDAV access through `rclone` when configured. | +| Project management | Vikunja | `https://todo.happyvertical.com` | Use Vikunja for project and task state. Treat issue trackers in repos as project-specific unless the repo says otherwise. | +| Chat and collaboration | Stoat | `https://stoat.happyvertical.com` | Use Stoat for team communication and collaboration context when available. | +| Gateway | Bifrost | `https://bifrost.happyvertical.com` | Use Bifrost for gateway access and service entry points when a workflow requires it. | +| Prompts, resources, and memory | Context Forge | `https://context.happyvertical.com` | Use Context Forge for dynamic prompts, resources, and long-term memory. Runtime agent files should still be local snapshots generated by install. | + +## Setup Verification + +Hermes agents should run the `check-setup` command after bootstrap, after +account rotation, and whenever service access seems stale. Non-Hermes agents +use the same checklist manually unless their active profile installs an +equivalent command. + +The command should verify: + +- assigned email identity is known and usable +- `idp.happyvertical.com` is reachable and the account can authenticate +- `todo.happyvertical.com` is reachable and the agent can access Vikunja +- `warden.happyvertical.com` is reachable and credentials can be retrieved +- `drive.happyvertical.com` is reachable and WebDAV/OxiCloud access is configured +- `context.happyvertical.com` is reachable and the agent is configured for + prompts, resources, and memory + +## Secret Handling + +- Use Warden as the sharing standard for account passwords and shared secrets. +- Use SOPS only for machine-local encrypted environment files and non-secret + templates committed to repos. +- Redact tokens, cookies, API keys, passwords, recovery codes, and decrypted + secret values in all logs, reports, issues, and PRs. +- Missing credentials should be reported as setup blockers with the exact + service and missing local variable or auth source. diff --git a/docs/infrastructure.md b/docs/infrastructure.md new file mode 100644 index 0000000..8f785a0 --- /dev/null +++ b/docs/infrastructure.md @@ -0,0 +1,34 @@ +# HappyVertical Infrastructure + +This document is the organization-level map of services that agents and humans +should expect when working in HappyVertical environments. Account credentials are +per user or per agent and must stay out of git. + +## Services + +| Service | URL | Purpose | CLI status | +| --- | --- | --- | --- | +| Email | per-account | Human and agent identity | Account-specific | +| HappyVertical IDP | `https://idp.happyvertical.com` | Identity provider and SSO | Verify through browser/session or available connector | +| Warden | `https://warden.happyvertical.com` | Password and shared secret access | Credential source; never print secret values | +| OxiCloud | `https://drive.happyvertical.com` | File sharing | Use WebDAV-capable tooling such as `rclone` | +| Vikunja | `https://todo.happyvertical.com` | Project management | Official CLI is server/container admin only; no remote task CLI selected yet | +| Stoat | `https://stoat.happyvertical.com` | Chat and collaboration | No standard CLI selected yet | +| Bifrost | `https://bifrost.happyvertical.com` | Gateway | No standard CLI selected yet | +| Context Forge | `https://context.happyvertical.com` | Prompts, resources, and memory | Export install-time snapshots | + +## Secrets And Accounts + +- Every workstation and Hermes agent has its own account identity. +- Passwords and shared credentials should be distributed through Warden. +- SOPS may be used for machine-local encrypted environment files, but committed + repos should only contain templates and non-secret policy. +- Installers may hard-fail missing environment variables only for capabilities + explicitly enabled with `HV_ENABLED_CAPABILITIES`. + +## Context Forge Snapshot Policy + +Context Forge is the dynamic source for organization prompt and resource policy. +Installers should materialize snapshots into local generated files and record +the selected content hash in `agent-lock.json`. Runtime agent behavior should +not require live Context Forge access. diff --git a/hv/manifest.json b/hv/manifest.json new file mode 100644 index 0000000..7d7e7cc --- /dev/null +++ b/hv/manifest.json @@ -0,0 +1,140 @@ +{ + "schema": "https://happyvertical.com/hv-agent-manifest/v1", + "layer": "have-config", + "priority": 20, + "agent_docs": [ + { + "id": "happyvertical.org-standards", + "targets": ["agents", "claude"], + "path": "agent-doc-snippets/happyvertical-standards.md" + }, + { + "id": "happyvertical.agent-playbook", + "targets": ["agents", "claude"], + "path": "docs/agent-playbook.md" + } + ], + "commands": [], + "skills": [], + "env_requirements": [ + { + "capability": "happyvertical-identity", + "vars": ["HV_AGENT_EMAIL"], + "default_enabled": false, + "description": "Per-user or per-agent HappyVertical account identity." + }, + { + "capability": "idp", + "vars": ["HV_AGENT_EMAIL"], + "default_enabled": false, + "description": "HappyVertical IDP account access." + }, + { + "capability": "warden", + "vars": ["HV_WARDEN_URL"], + "default_enabled": false, + "description": "Warden credential access. Passwords stay in Warden/local auth." + }, + { + "capability": "vikunja", + "vars": ["HV_VIKUNJA_URL", "HV_VIKUNJA_TOKEN"], + "default_enabled": false, + "description": "Vikunja project-management API access." + }, + { + "capability": "oxicloud", + "vars": ["HV_OXICLOUD_WEBDAV_URL", "HV_OXICLOUD_USER"], + "default_enabled": false, + "description": "OxiCloud WebDAV access. Passwords should come from Warden." + }, + { + "capability": "contextforge", + "vars": ["HV_CONTEXTFORGE_SNAPSHOT_DIR"], + "default_enabled": false, + "description": "Local install-time snapshot exported from context.happyvertical.com." + } + ], + "services": [ + { + "id": "email", + "name": "HappyVertical email", + "url": "mailto:", + "purpose": "Per-user and per-agent identity.", + "cli": { + "status": "account-specific", + "notes": "Use local mail tooling if needed; account credentials stay out of git." + } + }, + { + "id": "idp", + "name": "HappyVertical IDP", + "url": "https://idp.happyvertical.com", + "purpose": "Identity provider and SSO.", + "cli": { + "status": "browser-or-session", + "notes": "Verify reachability and authenticated account access with available browser, connector, or local session." + } + }, + { + "id": "warden", + "name": "Warden", + "url": "https://warden.happyvertical.com", + "purpose": "Password and shared secret access.", + "cli": { + "status": "credential-source", + "notes": "Use approved Warden access or local secret integration; never print secret values." + } + }, + { + "id": "oxicloud", + "name": "OxiCloud", + "url": "https://drive.happyvertical.com", + "purpose": "File sharing via OxiCloud.", + "cli": { + "status": "supported", + "tool": "rclone", + "notes": "Use WebDAV configuration with credentials from Warden/local env." + } + }, + { + "id": "vikunja", + "name": "Vikunja", + "url": "https://todo.happyvertical.com", + "purpose": "Project management.", + "cli": { + "status": "server-cli-only", + "notes": "The official Vikunja CLI is for server/container administration; no standard remote task CLI is selected yet." + } + }, + { + "id": "stoat", + "name": "Stoat", + "url": "https://stoat.happyvertical.com", + "purpose": "Chat and collaboration.", + "cli": { + "status": "none-selected", + "notes": "No standard CLI selected yet." + } + }, + { + "id": "bifrost", + "name": "Bifrost", + "url": "https://bifrost.happyvertical.com", + "purpose": "Gateway.", + "cli": { + "status": "none-selected", + "notes": "Gateway operations are documented before automation." + } + }, + { + "id": "contextforge", + "name": "Context Forge", + "url": "https://context.happyvertical.com", + "purpose": "Dynamic prompts, resources, and memory.", + "cli": { + "status": "snapshot-source", + "notes": "Export snapshots for install-time materialization." + } + } + ] +} diff --git a/install.sh b/install.sh index ae2254d..9e2af59 100755 --- a/install.sh +++ b/install.sh @@ -3,28 +3,24 @@ # install.sh - one-line setup for have-config. # # Does: -# 1. Ensures pr-review is cloned and on $PATH. -# 2. Registers this repo as a marketplace for Claude Code. -# 3. Installs the `have` plugin in Claude Code. -# 4. Registers this repo as a marketplace for Codex. -# 5. Enables and syncs the `have` plugin cache in Codex. -# 6. With --live, symlinks the cached plugin installs back to this repo -# so edits are immediately visible to running sessions. +# 1. Clones/updates dotfiles and runs dotfiles/install.sh for base tooling. +# 2. Ensures pr-review is cloned and on $PATH. +# 3. Resolves dotfiles/org/profile/Context Forge/local agent material. # # Usage: -# ./install.sh # standard install (use `plugin update` after adapter edits) -# ./install.sh --live # live mode (cache symlinked to repo) -# ./install.sh --uninstall # remove marketplaces + plugins +# ./install.sh # standard install +# ./install.sh --dry-run # audit without changing generated files +# ./install.sh --skip-dotfiles # ./install.sh -h # help # set -euo pipefail -LIVE=0 -UNINSTALL=0 +DRY_RUN=0 +SKIP_DOTFILES="${HAVE_CONFIG_SKIP_DOTFILES:-0}" while [[ $# -gt 0 ]]; do case "$1" in - --live) LIVE=1; shift ;; - --uninstall) UNINSTALL=1; shift ;; + --dry-run|--audit) DRY_RUN=1; shift ;; + --skip-dotfiles) SKIP_DOTFILES=1; shift ;; -h|--help) sed -n '2,/^set -euo/p' "$0" | sed -e '$d' -e 's/^# \{0,1\}//' exit 0 ;; @@ -33,125 +29,122 @@ while [[ $# -gt 0 ]]; do done REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +default_hv_config_dir() { + if [[ "${HV_AGENT_PROFILE:-${AGENT_PROFILE:-}}" == *hermes* || -n "${HERMES:-}" || -n "${HERMES_AGENT:-}" || -n "${HERMES_AGENT_ID:-}" || -n "${HERMES_HOME:-}" ]]; then + printf '%s\n' "${HERMES_HOME:-$HOME/.hermes}" + else + printf '%s\n' "$HOME/.config/hv" + fi +} + +HAVE_CONFIG_HOME="${HV_CONFIG_DIR:-$(default_hv_config_dir)}" PR_REVIEW_DIR="${PR_REVIEW_DIR:-$HOME/Work/happyvertical/repos/pr-review}" -HAVE_PLUGIN_VERSION="0.1.1" +DOTFILES_DIR="${DOTFILES_DIR:-$HAVE_CONFIG_HOME/dotfiles}" +DOTFILES_REPO_URL="${DOTFILES_REPO_URL:-git@github.com:willgriffin/dotfiles.git}" +DOTFILES_FALLBACK_REPO_URL="https://github.com/willgriffin/dotfiles.git" + +prepend_pr_review_path() { + if [[ -d "$PR_REVIEW_DIR/bin" && ":$PATH:" != *":$PR_REVIEW_DIR/bin:"* ]]; then + export PATH="$PR_REVIEW_DIR/bin:$PATH" + fi +} + +prepend_pr_review_path cyan() { printf "\033[36m%s\033[0m\n" "$*"; } green() { printf "\033[32m%s\033[0m\n" "$*"; } red() { printf "\033[31m%s\033[0m\n" "$*" >&2; } -if [[ "$UNINSTALL" -eq 1 ]]; then - cyan "Uninstalling have plugins..." - command -v claude >/dev/null 2>&1 && claude plugin uninstall have@have-config 2>/dev/null || true - command -v claude >/dev/null 2>&1 && claude plugin marketplace remove have-config 2>/dev/null || true - command -v codex >/dev/null 2>&1 && codex plugin marketplace remove have-config 2>/dev/null || true - rm -rf "$HOME/.codex/plugins/cache/have-config" - green "Uninstalled." - exit 0 -fi +bootstrap_dotfiles() { + cyan "Step 1/3: dotfiles baseline" -# 1. pr-review -cyan "Step 1/4: pr-review" -if [[ ! -d "$PR_REVIEW_DIR" ]]; then - cyan " Cloning pr-review to ${PR_REVIEW_DIR}..." - mkdir -p "$(dirname "$PR_REVIEW_DIR")" - git clone https://github.com/happyvertical/pr-review.git "$PR_REVIEW_DIR" -else - cyan " Already cloned at $PR_REVIEW_DIR (skipping)." -fi + if [[ "$SKIP_DOTFILES" == "1" || "${HAVE_CONFIG_BOOTSTRAPPING_DOTFILES:-0}" == "1" ]]; then + cyan " Skipping dotfiles baseline." + return 0 + fi -if ! command -v pr-review >/dev/null 2>&1; then - red " pr-review not on \$PATH. Add this to your shell rc:" - red " export PATH=\"$PR_REVIEW_DIR/bin:\$PATH\"" -else - green " pr-review on PATH ($(command -v pr-review))" -fi + if [[ "$DRY_RUN" -eq 1 ]]; then + if [[ -e "$DOTFILES_DIR/.git" ]]; then + cyan " Dry-run: dotfiles exists at $DOTFILES_DIR" + else + cyan " Dry-run: would clone dotfiles to $DOTFILES_DIR" + fi + cyan " Dry-run: would run dotfiles/install.sh for base tooling." + return 0 + fi -# 2. Claude marketplace + plugin -cyan "Step 2/4: Claude Code marketplace + have plugin" -if command -v claude >/dev/null 2>&1; then - if ! claude plugin marketplace list 2>/dev/null | grep -q "have-config"; then - claude plugin marketplace add "$REPO_ROOT/claude" + if [[ -e "$DOTFILES_DIR/.git" ]]; then + cyan " Updating dotfiles at $DOTFILES_DIR..." + if ! git -C "$DOTFILES_DIR" pull --ff-only --quiet; then + red " Could not fast-forward dotfiles; using existing checkout." + fi else - cyan " marketplace 'have-config' already registered." + cyan " Cloning dotfiles to $DOTFILES_DIR..." + mkdir -p "$(dirname "$DOTFILES_DIR")" + if ! git clone --quiet "$DOTFILES_REPO_URL" "$DOTFILES_DIR"; then + if [[ "$DOTFILES_REPO_URL" != "$DOTFILES_FALLBACK_REPO_URL" ]]; then + red " SSH clone failed, trying HTTPS..." + git clone --quiet "$DOTFILES_FALLBACK_REPO_URL" "$DOTFILES_DIR" + else + return 1 + fi + fi fi - if ! claude plugin list 2>/dev/null | grep -q "have@have-config"; then - claude plugin install have@have-config + + if [[ ! -x "$DOTFILES_DIR/install.sh" ]]; then + red " dotfiles installer not executable at $DOTFILES_DIR/install.sh" + return 1 + fi + + (cd "$DOTFILES_DIR" && HAVE_CONFIG_BOOTSTRAPPING_DOTFILES=1 ./install.sh) +} + +bootstrap_dotfiles + +cyan "Step 2/3: pr-review" +if [[ "$DRY_RUN" -eq 1 ]]; then + if [[ -d "$PR_REVIEW_DIR" ]]; then + cyan " Dry-run: pr-review exists at $PR_REVIEW_DIR" else - cyan " have@have-config already installed." + cyan " Dry-run: would clone pr-review to $PR_REVIEW_DIR" fi - green " Claude: /have:ship and /have:review-cycle ready." else - red " claude CLI not found; skipping Claude install." + if [[ ! -d "$PR_REVIEW_DIR" ]]; then + cyan " Cloning pr-review to ${PR_REVIEW_DIR}..." + mkdir -p "$(dirname "$PR_REVIEW_DIR")" + git clone https://github.com/happyvertical/pr-review.git "$PR_REVIEW_DIR" + else + cyan " Already cloned at $PR_REVIEW_DIR (skipping)." + fi fi -# 3. Codex marketplace -cyan "Step 3/4: Codex marketplace + have plugin" -if command -v codex >/dev/null 2>&1; then - # Codex marketplace add registers the marketplace; plugin enablement is - # config-driven (codex has no `plugin install/enable` CLI). We add the - # enabled=true entry to ~/.codex/config.toml directly. - codex plugin marketplace add "$REPO_ROOT/codex" 2>&1 | head -3 || true - - # Idempotently enable have@have-config - python3 - <<'PY' || red " Could not auto-enable plugin in ~/.codex/config.toml; add this manually: - [plugins.\"have@have-config\"] - enabled = true" -import os -path = os.path.expanduser("~/.codex/config.toml") -content = open(path).read() if os.path.exists(path) else "" -if 'plugins."have@have-config"' in content: - print(" have@have-config already enabled in codex config.") -else: - with open(path, "a") as f: - f.write('\n[plugins."have@have-config"]\nenabled = true\n') - print(" Enabled have@have-config in ~/.codex/config.toml.") -PY - - CODEX_CACHE="$HOME/.codex/plugins/cache/have-config/have/$HAVE_PLUGIN_VERSION" - CODEX_SOURCE="$REPO_ROOT/codex/plugins/have" - mkdir -p "$(dirname "$CODEX_CACHE")" - rm -rf "$CODEX_CACHE" - cp -R "$CODEX_SOURCE" "$CODEX_CACHE" - green " Codex cache synced: $CODEX_CACHE" - - green " Codex: have:ship and have:review-cycle skills ready (after restart)." +prepend_pr_review_path +if [[ "$DRY_RUN" -eq 1 && ! -d "$PR_REVIEW_DIR/bin" ]]; then + cyan " Dry-run: would add $PR_REVIEW_DIR/bin to the current install PATH." +elif ! command -v pr-review >/dev/null 2>&1; then + red " pr-review not on \$PATH. Add this to your shell rc:" + red " export PATH=\"$PR_REVIEW_DIR/bin:\$PATH\"" else - red " codex CLI not found; skipping Codex install." + green " pr-review on PATH ($(command -v pr-review))" fi -# 4. Live mode (optional) -if [[ "$LIVE" -eq 1 ]]; then - cyan "Step 4/4: --live mode - symlinking caches to repo" - - # Claude cache path: ~/.claude/plugins/cache//// - CLAUDE_CACHE="$HOME/.claude/plugins/cache/have-config/have/$HAVE_PLUGIN_VERSION" - CLAUDE_SOURCE="$REPO_ROOT/claude/have" - if [[ -d "$(dirname "$CLAUDE_CACHE")" ]]; then - rm -rf "$CLAUDE_CACHE" - ln -s "$CLAUDE_SOURCE" "$CLAUDE_CACHE" - green " Claude cache symlinked: $CLAUDE_CACHE -> $CLAUDE_SOURCE" - cyan " (Adapter edits to $CLAUDE_SOURCE are now live. Workflow body edits happen in ContextForge.)" - else - red " Claude cache dir not found at $(dirname "$CLAUDE_CACHE"); install may have failed." +run_agent_resolver() { + cyan "Step 3/3: agent materialization" + + if ! command -v python3 >/dev/null 2>&1; then + red " python3 not found; cannot resolve HappyVertical agent configuration." + return 1 fi - CODEX_CACHE="$HOME/.codex/plugins/cache/have-config/have/$HAVE_PLUGIN_VERSION" - CODEX_SOURCE="$REPO_ROOT/codex/plugins/have" - if [[ -d "$(dirname "$CODEX_CACHE")" ]]; then - rm -rf "$CODEX_CACHE" - ln -s "$CODEX_SOURCE" "$CODEX_CACHE" - green " Codex cache symlinked: $CODEX_CACHE -> $CODEX_SOURCE" - else - red " Codex cache dir not found at $(dirname "$CODEX_CACHE"); install may have failed." + local args=(--dotfiles-dir "$DOTFILES_DIR" --have-config-dir "$REPO_ROOT") + if [[ "$DRY_RUN" -eq 1 ]]; then + args+=(--dry-run) fi -else - cyan "Step 4/4: skipping --live (pass --live to enable live edits)" -fi + + python3 "$REPO_ROOT/scripts/hv-agent-resolver.py" "${args[@]}" +} + +run_agent_resolver green "" -green "Done. Restart Claude / Codex sessions to pick up the workflows." -green "Claude: /have:review-cycle" -green " /have:ship" -green "Codex: have:review-cycle" -green " have:ship" +green "Done. Restart Claude / Codex sessions to pick up generated commands and skills." diff --git a/profiles/hermes/commands/claude/check-setup.md b/profiles/hermes/commands/claude/check-setup.md new file mode 100644 index 0000000..b3a1611 --- /dev/null +++ b/profiles/hermes/commands/claude/check-setup.md @@ -0,0 +1,54 @@ +--- +description: "Verify this agent's HappyVertical service access." +--- + +# /check-setup + +Verify that this workstation or agent container is correctly connected to +HappyVertical services. Do not print secrets, tokens, cookies, passwords, or +decrypted values. + +Produce a concise setup report with columns: + +| Service | Check | Result | Evidence | Next action | +| --- | --- | --- | --- | --- | + +Use `OK`, `Blocked`, or `Skipped` for each result. `Skipped` is only valid when +the service is intentionally not enabled for this agent. + +Run these checks: + +1. Email identity + - Confirm `HV_AGENT_EMAIL` or an equivalent local account identity is set. + - If a mail connector or CLI is available, verify the account can list or + read its own mailbox metadata without exposing message contents. +2. HappyVertical IDP + - Confirm `https://idp.happyvertical.com` is reachable. + - If authenticated browser/session/CLI access is available, verify the + assigned account can authenticate. +3. Vikunja project management + - Confirm `https://todo.happyvertical.com` is reachable. + - If `HV_VIKUNJA_URL` and `HV_VIKUNJA_TOKEN` are set, make a read-only API + request that proves access. +4. Warden + - Confirm `https://warden.happyvertical.com` is reachable. + - Verify the agent can access its approved credential source without + printing any secret value. +5. OxiCloud file sharing + - Confirm `https://drive.happyvertical.com` is reachable. + - If `rclone` and an OxiCloud/WebDAV remote are configured, run a read-only + listing or config check. +6. Context Forge memory and prompts + - Confirm `https://context.happyvertical.com` is reachable. + - Verify the agent is configured to use Context Forge for prompts, + resources, and memory. If Hindsight or Context Forge MCP tools are + available, perform a harmless recall/list/read check against the expected + HappyVertical memory bank. + - Verify `HV_CONTEXTFORGE_SNAPSHOT_DIR` exists when Context Forge snapshots + are expected for install-time materialization. + +If a check cannot be performed noninteractively, mark it `Blocked` and state +the missing credential, connector, environment variable, CLI, or local config. + +If a Context Forge snapshot or local override replaced this command during +install, follow the generated installed command instead of this org fallback. diff --git a/profiles/hermes/commands/codex/check-setup.md b/profiles/hermes/commands/codex/check-setup.md new file mode 100644 index 0000000..b3a1611 --- /dev/null +++ b/profiles/hermes/commands/codex/check-setup.md @@ -0,0 +1,54 @@ +--- +description: "Verify this agent's HappyVertical service access." +--- + +# /check-setup + +Verify that this workstation or agent container is correctly connected to +HappyVertical services. Do not print secrets, tokens, cookies, passwords, or +decrypted values. + +Produce a concise setup report with columns: + +| Service | Check | Result | Evidence | Next action | +| --- | --- | --- | --- | --- | + +Use `OK`, `Blocked`, or `Skipped` for each result. `Skipped` is only valid when +the service is intentionally not enabled for this agent. + +Run these checks: + +1. Email identity + - Confirm `HV_AGENT_EMAIL` or an equivalent local account identity is set. + - If a mail connector or CLI is available, verify the account can list or + read its own mailbox metadata without exposing message contents. +2. HappyVertical IDP + - Confirm `https://idp.happyvertical.com` is reachable. + - If authenticated browser/session/CLI access is available, verify the + assigned account can authenticate. +3. Vikunja project management + - Confirm `https://todo.happyvertical.com` is reachable. + - If `HV_VIKUNJA_URL` and `HV_VIKUNJA_TOKEN` are set, make a read-only API + request that proves access. +4. Warden + - Confirm `https://warden.happyvertical.com` is reachable. + - Verify the agent can access its approved credential source without + printing any secret value. +5. OxiCloud file sharing + - Confirm `https://drive.happyvertical.com` is reachable. + - If `rclone` and an OxiCloud/WebDAV remote are configured, run a read-only + listing or config check. +6. Context Forge memory and prompts + - Confirm `https://context.happyvertical.com` is reachable. + - Verify the agent is configured to use Context Forge for prompts, + resources, and memory. If Hindsight or Context Forge MCP tools are + available, perform a harmless recall/list/read check against the expected + HappyVertical memory bank. + - Verify `HV_CONTEXTFORGE_SNAPSHOT_DIR` exists when Context Forge snapshots + are expected for install-time materialization. + +If a check cannot be performed noninteractively, mark it `Blocked` and state +the missing credential, connector, environment variable, CLI, or local config. + +If a Context Forge snapshot or local override replaced this command during +install, follow the generated installed command instead of this org fallback. diff --git a/profiles/hermes/manifest.json b/profiles/hermes/manifest.json new file mode 100644 index 0000000..c5451d9 --- /dev/null +++ b/profiles/hermes/manifest.json @@ -0,0 +1,36 @@ +{ + "schema": "https://happyvertical.com/hv-agent-manifest/v1", + "layer": "profile:hermes", + "priority": 25, + "commands": [ + { + "agent": "claude", + "name": "check-setup", + "path": "commands/claude/check-setup.md", + "description": "Verify HappyVertical service access for this Hermes agent" + }, + { + "agent": "codex", + "name": "check-setup", + "path": "commands/codex/check-setup.md", + "description": "Verify HappyVertical service access for this Hermes agent" + } + ], + "skills": [ + { + "agent": "codex", + "name": "check-setup", + "path": "skills/check-setup", + "description": "Verify HappyVertical service access for this Hermes agent" + } + ], + "env_requirements": [ + { + "capability": "hermes", + "vars": ["HV_AGENT_EMAIL"], + "default_enabled": false, + "description": "Per-agent HappyVertical account identity for Hermes." + } + ], + "services": [] +} diff --git a/profiles/hermes/skills/check-setup/SKILL.md b/profiles/hermes/skills/check-setup/SKILL.md new file mode 100644 index 0000000..67ba554 --- /dev/null +++ b/profiles/hermes/skills/check-setup/SKILL.md @@ -0,0 +1,57 @@ +--- +name: check-setup +description: Use when the user invokes /check-setup, check-setup, or asks to verify this Hermes agent's HappyVertical service access. +metadata: + short-description: Verify HappyVertical agent setup +--- + +# Have Check Setup + +Verify that this workstation or agent container is correctly connected to +HappyVertical services. Do not print secrets, tokens, cookies, passwords, or +decrypted values. + +Produce a concise setup report with columns: + +| Service | Check | Result | Evidence | Next action | +| --- | --- | --- | --- | --- | + +Use `OK`, `Blocked`, or `Skipped` for each result. `Skipped` is only valid when +the service is intentionally not enabled for this agent. + +Run these checks: + +1. Email identity + - Confirm `HV_AGENT_EMAIL` or an equivalent local account identity is set. + - If a mail connector or CLI is available, verify the account can list or + read its own mailbox metadata without exposing message contents. +2. HappyVertical IDP + - Confirm `https://idp.happyvertical.com` is reachable. + - If authenticated browser/session/CLI access is available, verify the + assigned account can authenticate. +3. Vikunja project management + - Confirm `https://todo.happyvertical.com` is reachable. + - If `HV_VIKUNJA_URL` and `HV_VIKUNJA_TOKEN` are set, make a read-only API + request that proves access. +4. Warden + - Confirm `https://warden.happyvertical.com` is reachable. + - Verify the agent can access its approved credential source without + printing any secret value. +5. OxiCloud file sharing + - Confirm `https://drive.happyvertical.com` is reachable. + - If `rclone` and an OxiCloud/WebDAV remote are configured, run a read-only + listing or config check. +6. Context Forge memory and prompts + - Confirm `https://context.happyvertical.com` is reachable. + - Verify the agent is configured to use Context Forge for prompts, + resources, and memory. If Hindsight or Context Forge MCP tools are + available, perform a harmless recall/list/read check against the expected + HappyVertical memory bank. + - Verify `HV_CONTEXTFORGE_SNAPSHOT_DIR` exists when Context Forge snapshots + are expected for install-time materialization. + +If a check cannot be performed noninteractively, mark it `Blocked` and state +the missing credential, connector, environment variable, CLI, or local config. + +If a Context Forge snapshot or local override replaced this skill during +install, follow the generated installed skill instead of this org fallback. diff --git a/scripts/hv-agent-resolver.py b/scripts/hv-agent-resolver.py new file mode 100755 index 0000000..83b340f --- /dev/null +++ b/scripts/hv-agent-resolver.py @@ -0,0 +1,952 @@ +#!/usr/bin/env python3 +"""Resolve HappyVertical agent definitions into local generated files. + +The resolver composes these layers: + +1. dotfiles baseline workflows +2. have-config organization standard +3. optional profile defaults, such as Hermes +4. Context Forge install-time snapshot +5. machine-local overrides + +Commands and skills use winner-takes-all resolution by layer priority. Agent +documents are cumulative and assembled in layer order. +""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import os +import shutil +import sys +from dataclasses import dataclass, field +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + + +LAYER_PRIORITIES = { + "dotfiles": 10, + "have-config": 20, + "profile": 25, + "contextforge": 30, + "local": 40, +} + +TARGETS = { + "agents": "AGENTS.md", + "codex": "AGENTS.md", + "claude": "CLAUDE.md", +} + + +@dataclass(frozen=True) +class SourceLayer: + name: str + root: Path + manifest: Path | None + priority: int + available: bool + notes: list[str] = field(default_factory=list) + + +@dataclass +class Candidate: + kind: str + agent: str + name: str + layer: str + priority: int + source: str + path: Path | None = None + content: str | None = None + metadata: dict[str, Any] = field(default_factory=dict) + + @property + def key(self) -> str: + return f"{self.agent}:{self.kind}:{self.name}" + + def digest(self) -> str: + if self.content is not None: + return sha256_text(self.content) + if self.path is None: + return sha256_text("") + return sha256_path(self.path) + + +@dataclass +class DocSnippet: + snippet_id: str + targets: list[str] + layer: str + priority: int + source: str + path: Path | None = None + content: str | None = None + + def read(self) -> str: + if self.content is not None: + return self.content.rstrip() + "\n" + if self.path is None: + return "" + return self.path.read_text(encoding="utf-8").rstrip() + "\n" + + def digest(self) -> str: + if self.content is not None: + return sha256_text(self.content) + if self.path is None: + return sha256_text("") + return sha256_path(self.path) + + +def sha256_text(value: str) -> str: + return hashlib.sha256(value.encode("utf-8")).hexdigest() + + +def sha256_path(path: Path) -> str: + if path.is_file(): + return hashlib.sha256(path.read_bytes()).hexdigest() + + digest = hashlib.sha256() + if path.is_dir(): + for child in sorted(p for p in path.rglob("*") if p.is_file()): + digest.update(str(child.relative_to(path)).encode("utf-8")) + digest.update(b"\0") + digest.update(child.read_bytes()) + digest.update(b"\0") + return digest.hexdigest() + + +def load_json(path: Path) -> dict[str, Any]: + with path.open(encoding="utf-8") as f: + return json.load(f) + + +def resolve_path(root: Path, value: str | None) -> Path | None: + if not value: + return None + raw = Path(os.path.expandvars(os.path.expanduser(value))) + if raw.is_absolute(): + return raw + return root / raw + + +def source_label(layer: str, root: Path, path: Path | None, content: str | None) -> str: + if path is None: + return f"{layer}:inline:{sha256_text(content or '')[:12]}" + try: + rel = path.relative_to(root) + return f"{layer}:{rel}" + except ValueError: + return f"{layer}:{path}" + + +def expand_agents(agent: str, kind: str) -> list[str]: + if agent != "all": + return [agent] + if kind == "command": + return ["claude", "codex"] + return ["codex"] + + +def doc_target_matches(target: str, snippet_targets: list[str]) -> bool: + if "all" in snippet_targets: + return True + if target == "agents": + return "agents" in snippet_targets or "codex" in snippet_targets + if target == "codex": + return "codex" in snippet_targets or "agents" in snippet_targets + return target in snippet_targets + + +def split_profiles(value: str | None) -> list[str]: + if not value: + return [] + return [item.strip() for item in value.split(",") if item.strip()] + + +def detect_profiles(home_dir: Path) -> tuple[list[str], list[str]]: + explicit = split_profiles(os.environ.get("HV_AGENT_PROFILE") or os.environ.get("AGENT_PROFILE")) + if explicit: + return explicit, ["profile selected from HV_AGENT_PROFILE/AGENT_PROFILE"] + + hermes_markers = [ + os.environ.get("HERMES"), + os.environ.get("HERMES_AGENT"), + os.environ.get("HERMES_AGENT_ID"), + os.environ.get("HERMES_HOME"), + ] + if any(marker for marker in hermes_markers): + return ["hermes"], ["Hermes profile detected from HERMES environment"] + + hermes_home = Path(os.environ.get("HERMES_HOME", str(home_dir / ".hermes"))).expanduser() + if (hermes_home / "profile.json").exists() or (hermes_home / ".profile-hermes").exists(): + return ["hermes"], [f"Hermes profile detected from {hermes_home}"] + + return [], ["no agent profile detected"] + + +def manifest_layer(name: str, root: Path, manifest_name: str, default_priority: int) -> SourceLayer: + manifest = root / manifest_name + if manifest.exists(): + data = load_json(manifest) + notes: list[str] = [] + declared_priority = data.get("priority") + if declared_priority is not None: + try: + declared_priority_int = int(declared_priority) + if isinstance(declared_priority, bool): + raise ValueError + except (TypeError, ValueError): + notes.append( + f"invalid declared priority {declared_priority!r} ignored; using fixed {default_priority}" + ) + else: + if declared_priority_int != default_priority: + notes.append(f"declared priority {declared_priority} ignored; using fixed {default_priority}") + return SourceLayer(name, root, manifest, default_priority, True, notes) + if name == "local" and root.exists(): + return SourceLayer(name, root, None, default_priority, True, [f"no {manifest}; using convention-based overrides"]) + return SourceLayer(name, root, None, default_priority, False, [f"missing {manifest}"]) + + +def collect_manifest(layer: SourceLayer) -> tuple[list[Candidate], list[DocSnippet], list[dict[str, Any]], list[dict[str, Any]]]: + if not layer.available or layer.manifest is None: + return [], [], [], [] + + data = load_json(layer.manifest) + root = layer.root + priority = layer.priority + layer_name = layer.name + + candidates: list[Candidate] = [] + docs: list[DocSnippet] = [] + + for item in data.get("skills", []): + path = resolve_path(root, item.get("path")) + content = item.get("content") + name = item["name"] + agent = item.get("agent", "codex") + for expanded_agent in expand_agents(agent, "skill"): + candidates.append( + Candidate( + kind="skill", + agent=expanded_agent, + name=name, + layer=layer_name, + priority=priority, + path=path, + content=content, + source=source_label(layer_name, root, path, content), + metadata={k: v for k, v in item.items() if k not in {"path", "content"}}, + ) + ) + + for item in data.get("commands", []): + path = resolve_path(root, item.get("path")) + content = item.get("content") + name = item["name"] + agent = item.get("agent", "all") + for expanded_agent in expand_agents(agent, "command"): + candidates.append( + Candidate( + kind="command", + agent=expanded_agent, + name=name, + layer=layer_name, + priority=priority, + path=path, + content=content, + source=source_label(layer_name, root, path, content), + metadata={k: v for k, v in item.items() if k not in {"path", "content"}}, + ) + ) + + for item in data.get("agent_docs", []): + path = resolve_path(root, item.get("path")) + content = item.get("content") + docs.append( + DocSnippet( + snippet_id=item["id"], + targets=list(item.get("targets", ["agents"])), + layer=layer_name, + priority=priority, + path=path, + content=content, + source=source_label(layer_name, root, path, content), + ) + ) + + return candidates, docs, data.get("env_requirements", []), data.get("services", []) + + +def collect_local_conventions(root: Path, priority: int) -> tuple[list[Candidate], list[DocSnippet]]: + candidates: list[Candidate] = [] + docs: list[DocSnippet] = [] + + skill_roots = [ + ("codex", root / "skills" / "codex"), + ("claude", root / "skills" / "claude"), + ] + for agent, skill_root in skill_roots: + if not skill_root.is_dir(): + continue + for skill_dir in sorted(p for p in skill_root.iterdir() if p.is_dir()): + if (skill_dir / "SKILL.md").exists(): + candidates.append( + Candidate( + kind="skill", + agent=agent, + name=skill_dir.name, + layer="local", + priority=priority, + path=skill_dir, + source=source_label("local", root, skill_dir, None), + ) + ) + + commands_root = root / "commands" + for agent in ["claude", "codex"]: + agent_root = commands_root / agent + if not agent_root.is_dir(): + continue + for command_file in sorted(agent_root.glob("*.md")): + candidates.append( + Candidate( + kind="command", + agent=agent, + name=command_file.stem, + layer="local", + priority=priority, + path=command_file, + source=source_label("local", root, command_file, None), + ) + ) + + doc_root = root / "agent-docs" + doc_map = [ + ("local.agents", ["agents", "codex"], doc_root / "AGENTS.md"), + ("local.claude", ["claude"], doc_root / "CLAUDE.md"), + ] + for snippet_id, targets, path in doc_map: + if path.exists(): + docs.append( + DocSnippet( + snippet_id=snippet_id, + targets=targets, + layer="local", + priority=priority, + path=path, + source=source_label("local", root, path, None), + ) + ) + + return candidates, docs + + +def collect_service_registry(root: Path) -> list[dict[str, Any]]: + registry = root / "services" / "services.json" + if not registry.exists(): + return [] + data = load_json(registry) + return [{**service, "source": "services/services.json"} for service in data.get("services", [])] + + +def dedupe_services(services: list[dict[str, Any]]) -> list[dict[str, Any]]: + keyed: dict[str, dict[str, Any]] = {} + anonymous: list[dict[str, Any]] = [] + for service in services: + service_id = service.get("id") + if service_id: + keyed[service_id] = service + else: + anonymous.append(service) + return [keyed[key] for key in sorted(keyed)] + anonymous + + +def ensure_parent(path: Path) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + + +def replace_tree(src: Path, dest: Path) -> None: + if dest.exists() or dest.is_symlink(): + if dest.is_dir() and not dest.is_symlink(): + shutil.rmtree(dest) + else: + dest.unlink() + if src.is_dir(): + shutil.copytree(src, dest, symlinks=True) + else: + ensure_parent(dest) + shutil.copy2(src, dest) + + +def write_candidate(candidate: Candidate, dest: Path) -> None: + if candidate.content is not None: + if dest.suffix: + ensure_parent(dest) + dest.write_text(candidate.content.rstrip() + "\n", encoding="utf-8") + else: + dest.mkdir(parents=True, exist_ok=True) + (dest / "SKILL.md").write_text(candidate.content.rstrip() + "\n", encoding="utf-8") + return + if candidate.path is None: + return + replace_tree(candidate.path, dest) + + +def is_managed_target(path: Path, generated_root: Path, repo_roots: list[Path]) -> bool: + if not path.exists() and not path.is_symlink(): + return True + if not path.is_symlink(): + return False + target = Path(os.readlink(path)) + if not target.is_absolute(): + target = (path.parent / target).resolve() + try: + target.resolve().relative_to(generated_root.resolve()) + return True + except ValueError: + pass + for root in repo_roots: + try: + target.resolve().relative_to(root.resolve()) + return True + except ValueError: + continue + parts = set(target.parts) + if ".agents" in parts and "skills" in parts: + return True + if target.name == "AGENTS.md" and ".codex" in parts: + return True + if target.name == "CLAUDE.md" and ".claude" in parts: + return True + if "commands" in parts and (".claude" in parts or ".codex" in parts): + return True + return False + + +def link_target( + src: Path, + target: Path, + generated_root: Path, + repo_roots: list[Path], + dry_run: bool, + report: list[str], + allow_unmanaged_dry_run: bool = False, +) -> None: + if not is_managed_target(target, generated_root, repo_roots): + if dry_run and allow_unmanaged_dry_run: + report.append(f"- would link `{target}` -> `{src}` after adopting existing local file") + return + report.append(f"- blocked managed link `{target}`; existing file is not managed by hv") + return + if dry_run: + report.append(f"- would link `{target}` -> `{src}`") + return + ensure_parent(target) + if target.exists() or target.is_symlink(): + if target.is_dir() and not target.is_symlink(): + shutil.rmtree(target) + else: + target.unlink() + target.symlink_to(src) + + +def resolve_candidates(candidates: list[Candidate]) -> dict[str, list[Candidate]]: + by_key: dict[str, list[Candidate]] = {} + for candidate in candidates: + by_key.setdefault(candidate.key, []).append(candidate) + for items in by_key.values(): + items.sort(key=lambda c: (c.priority, c.layer, c.source)) + return by_key + + +def selected_candidate(items: list[Candidate]) -> Candidate: + return sorted(items, key=lambda c: (c.priority, c.layer, c.source))[-1] + + +def candidate_record(candidate: Candidate) -> dict[str, Any]: + return { + "kind": candidate.kind, + "agent": candidate.agent, + "name": candidate.name, + "layer": candidate.layer, + "priority": candidate.priority, + "source": candidate.source, + "sha256": candidate.digest(), + "metadata": candidate.metadata, + } + + +def parse_enabled_capabilities(value: str | None, defaults: list[str]) -> set[str]: + enabled = {item.strip() for item in defaults if item.strip()} + if value: + enabled.update(item.strip() for item in value.split(",") if item.strip()) + return enabled + + +def validate_env(requirements: list[dict[str, Any]]) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]: + defaults = [ + req["capability"] + for req in requirements + if req.get("default_enabled") is True and req.get("capability") + ] + enabled = parse_enabled_capabilities(os.environ.get("HV_ENABLED_CAPABILITIES"), defaults) + if "all" in enabled: + enabled.update(req.get("capability", "") for req in requirements) + + checked: list[dict[str, Any]] = [] + missing: list[dict[str, Any]] = [] + for req in requirements: + capability = req.get("capability") + vars_required = list(req.get("vars", [])) + if not capability or capability not in enabled: + continue + absent = [name for name in vars_required if not os.environ.get(name)] + record = { + "capability": capability, + "vars": vars_required, + "missing": absent, + "source": req.get("source"), + } + checked.append(record) + if absent: + missing.append(record) + return checked, missing + + +def assemble_doc(target: str, snippets: list[DocSnippet]) -> str: + title = "Global Agent Instructions" if target in {"agents", "codex"} else "Global Claude Instructions" + lines = [ + f"# {title}", + "", + "", + "", + ] + for snippet in sorted(snippets, key=lambda s: (s.priority, s.layer, s.snippet_id)): + if not doc_target_matches(target, snippet.targets): + continue + lines.extend( + [ + f"", + snippet.read().rstrip(), + "", + ] + ) + return "\n".join(lines).rstrip() + "\n" + + +def doc_conflicts(content: str) -> list[str]: + must: set[str] = set() + must_not: set[str] = set() + for line in content.splitlines(): + cleaned = line.strip().lower().strip("-* ") + if "must not " in cleaned: + must_not.add(cleaned.split("must not ", 1)[1]) + elif "must " in cleaned: + must.add(cleaned.split("must ", 1)[1]) + return sorted(must.intersection(must_not)) + + +def write_report( + path: Path, + layers: list[SourceLayer], + resolved: dict[str, list[Candidate]], + doc_outputs: dict[str, str], + env_checked: list[dict[str, Any]], + env_missing: list[dict[str, Any]], + services: list[dict[str, Any]], + link_report: list[str], + dry_run: bool, +) -> None: + lines = [ + "# HappyVertical Agent Install Report", + "", + f"- Generated: {datetime.now(timezone.utc).isoformat()}", + f"- Mode: {'dry-run' if dry_run else 'install'}", + "", + "## Source Layers", + "", + ] + for layer in layers: + status = "available" if layer.available else "missing" + lines.append(f"- `{layer.name}` priority {layer.priority}: {status} at `{layer.root}`") + for note in layer.notes: + lines.append(f" - {note}") + + lines.extend(["", "## Resolved Commands And Skills", ""]) + for key in sorted(resolved): + items = resolved[key] + winner = selected_candidate(items) + lines.append(f"- `{key}` -> `{winner.source}` ({winner.layer})") + for item in items: + if item is winner: + continue + lines.append(f" - overrides `{item.source}` ({item.layer})") + + lines.extend(["", "## Agent Docs", ""]) + for target, content in doc_outputs.items(): + conflicts = doc_conflicts(content) + lines.append(f"- `{target}` generated ({len(content.splitlines())} lines)") + for conflict in conflicts: + lines.append(f" - potential must/must-not conflict: `{conflict}`") + + lines.extend(["", "## Environment Requirements", ""]) + if not env_checked: + lines.append("- No enabled capabilities required env validation.") + for item in env_checked: + if item["missing"]: + lines.append(f"- `{item['capability']}` missing: {', '.join(item['missing'])}") + else: + lines.append(f"- `{item['capability']}` satisfied.") + + lines.extend(["", "## Services", ""]) + if not services: + lines.append("- No service registry entries found.") + for service in services: + cli = service.get("cli", {}) + status = cli.get("status", "documented") + source = service.get("source", "unknown") + url = service.get("url") or "" + lines.append(f"- `{service.get('id')}` {url} CLI: {status} (source: {source})") + + if link_report: + lines.extend(["", "## Managed Links", ""]) + lines.extend(link_report) + + ensure_parent(path) + path.write_text("\n".join(lines).rstrip() + "\n", encoding="utf-8") + + +def write_lock( + path: Path, + layers: list[SourceLayer], + resolved: dict[str, list[Candidate]], + docs: list[DocSnippet], + env_checked: list[dict[str, Any]], + services: list[dict[str, Any]], +) -> None: + data = { + "schema": "https://happyvertical.com/hv-agent-lock/v1", + "generated_at": datetime.now(timezone.utc).isoformat(), + "layers": [ + { + "name": layer.name, + "root": str(layer.root), + "manifest": str(layer.manifest) if layer.manifest else None, + "priority": layer.priority, + "available": layer.available, + } + for layer in layers + ], + "definitions": [], + "docs": [ + { + "id": doc.snippet_id, + "targets": doc.targets, + "layer": doc.layer, + "priority": doc.priority, + "source": doc.source, + "sha256": doc.digest(), + } + for doc in sorted(docs, key=lambda d: (d.priority, d.layer, d.snippet_id)) + ], + "env": env_checked, + "services": services, + } + for key in sorted(resolved): + items = resolved[key] + winner = selected_candidate(items) + data["definitions"].append( + { + "key": key, + "winner": candidate_record(winner), + "candidates": [candidate_record(item) for item in items], + } + ) + ensure_parent(path) + path.write_text(json.dumps(data, indent=2, sort_keys=True) + "\n", encoding="utf-8") + + +def materialize( + resolved: dict[str, list[Candidate]], + doc_outputs: dict[str, str], + output_dir: Path, + home_dir: Path, + repo_roots: list[Path], + dry_run: bool, +) -> list[str]: + report: list[str] = [] + if not dry_run: + output_dir.mkdir(parents=True, exist_ok=True) + for child in ["skills", "commands"]: + target = output_dir / child + if target.exists(): + shutil.rmtree(target) + + for key in sorted(resolved): + winner = selected_candidate(resolved[key]) + if winner.kind == "skill": + dest = output_dir / "skills" / winner.name + if not dry_run: + write_candidate(winner, dest) + link_target(dest, home_dir / ".agents" / "skills" / winner.name, output_dir, repo_roots, dry_run, report) + elif winner.kind == "command": + suffix = ".md" if winner.path is None or winner.path.is_file() else "" + dest = output_dir / "commands" / winner.agent / f"{winner.name}{suffix}" + if not dry_run: + write_candidate(winner, dest) + if winner.agent == "claude": + link_target(dest, home_dir / ".claude" / "commands" / f"{winner.name}.md", output_dir, repo_roots, dry_run, report) + elif winner.agent == "codex": + link_target(dest, home_dir / ".codex" / "commands" / f"{winner.name}.md", output_dir, repo_roots, dry_run, report) + + for target, content in doc_outputs.items(): + filename = TARGETS[target] + dest = output_dir / "docs" / target / filename + if not dry_run: + ensure_parent(dest) + dest.write_text(content, encoding="utf-8") + if target in {"agents", "codex"}: + link_target( + dest, + home_dir / ".codex" / "AGENTS.md", + output_dir, + repo_roots, + dry_run, + report, + allow_unmanaged_dry_run=True, + ) + if target == "claude": + link_target( + dest, + home_dir / ".claude" / "CLAUDE.md", + output_dir, + repo_roots, + dry_run, + report, + allow_unmanaged_dry_run=True, + ) + return report + + +def ensure_local_override_templates(local_dir: Path, dry_run: bool) -> list[str]: + report: list[str] = [] + directories = [ + local_dir / "skills", + local_dir / "skills" / "codex", + local_dir / "skills" / "claude", + local_dir / "commands" / "claude", + local_dir / "commands" / "codex", + local_dir / "agent-docs", + ] + readme = local_dir / "README.md" + + if dry_run: + report.append(f"- would ensure local override directories under `{local_dir}`") + return report + + for directory in directories: + directory.mkdir(parents=True, exist_ok=True) + + if not readme.exists(): + readme.write_text( + "\n".join( + [ + "# HappyVertical Local Overrides", + "", + "Files in this directory are machine-local and are never overwritten by", + "the have-config installer.", + "", + "- `skills/codex//SKILL.md` overrides Codex skills.", + "- `skills/claude//SKILL.md` overrides Claude skills when supported.", + "- `commands/claude/.md` overrides Claude commands.", + "- `commands/codex/.md` overrides Codex commands.", + "- `agent-docs/AGENTS.md` and `agent-docs/CLAUDE.md` are appended", + " to generated global instructions.", + "", + "Local overrides win over Context Forge snapshots, profile defaults,", + "have-config, and dotfiles. Keep them intentional and review the install report after", + "each update.", + "", + ] + ), + encoding="utf-8", + ) + return report + + +def adopt_existing_agent_docs( + home_dir: Path, + local_dir: Path, + generated_root: Path, + repo_roots: list[Path], + dry_run: bool, +) -> list[str]: + report: list[str] = [] + doc_targets = [ + (home_dir / ".codex" / "AGENTS.md", local_dir / "agent-docs" / "AGENTS.md"), + (home_dir / ".claude" / "CLAUDE.md", local_dir / "agent-docs" / "CLAUDE.md"), + ] + + for target, override_path in doc_targets: + if not target.exists() and not target.is_symlink(): + continue + if is_managed_target(target, generated_root, repo_roots): + continue + if override_path.exists(): + report.append( + f"- blocked adoption of `{target}`; local override already exists at `{override_path}`" + ) + continue + if dry_run: + report.append(f"- would adopt existing `{target}` into `{override_path}`") + continue + ensure_parent(override_path) + shutil.move(str(target), str(override_path)) + report.append(f"- adopted existing `{target}` into `{override_path}`") + + return report + + +def ensure_hermes_home(hermes_dir: Path, active_profiles: list[str], dry_run: bool) -> list[str]: + report: list[str] = [] + if "hermes" not in active_profiles: + return report + + if dry_run: + report.append(f"- would ensure Hermes profile directories under `{hermes_dir}`") + return report + + for directory in [hermes_dir, hermes_dir / "overrides", hermes_dir / "generated"]: + directory.mkdir(parents=True, exist_ok=True) + + profile_path = hermes_dir / "profile.json" + profile_path.write_text( + json.dumps( + { + "profile": "hermes", + "managed_by": "have-config", + "updated_at": datetime.now(timezone.utc).isoformat(), + "local_overrides": str(hermes_dir / "overrides"), + }, + indent=2, + sort_keys=True, + ) + + "\n", + encoding="utf-8", + ) + return report + + +def main() -> int: + home_default = Path("~").expanduser().resolve() + detected_profiles, detection_notes = detect_profiles(home_default) + active_profiles_default = ",".join(detected_profiles) + pre_parser = argparse.ArgumentParser(add_help=False) + pre_parser.add_argument("--profiles", default=os.environ.get("HV_AGENT_PROFILE", active_profiles_default)) + pre_args, _ = pre_parser.parse_known_args() + default_profiles = split_profiles(pre_args.profiles) + hermes_active = "hermes" in default_profiles + hv_config_dir = os.environ.get( + "HV_CONFIG_DIR", + os.environ.get("HERMES_HOME", "~/.hermes") if hermes_active else "~/.config/hv", + ) + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--dotfiles-dir", default=os.environ.get("DOTFILES_DIR", f"{hv_config_dir}/dotfiles")) + parser.add_argument("--have-config-dir", default=os.environ.get("HAVE_CONFIG_DIR", os.getcwd())) + parser.add_argument("--profiles", default=pre_args.profiles) + parser.add_argument("--contextforge-dir", default=os.environ.get("HV_CONTEXTFORGE_SNAPSHOT_DIR", f"{hv_config_dir}/contextforge")) + parser.add_argument("--local-overrides-dir", default=os.environ.get("HV_LOCAL_OVERRIDES_DIR", f"{hv_config_dir}/overrides")) + parser.add_argument("--output-dir", default=os.environ.get("HV_GENERATED_DIR", f"{hv_config_dir}/generated")) + parser.add_argument("--home-dir", default="~") + parser.add_argument("--lock-path", default=os.environ.get("HV_AGENT_LOCK", f"{hv_config_dir}/agent-lock.json")) + parser.add_argument("--report-path", default=os.environ.get("HV_INSTALL_REPORT", f"{hv_config_dir}/install-report.md")) + parser.add_argument("--dry-run", action="store_true") + args = parser.parse_args() + + dotfiles = Path(args.dotfiles_dir).expanduser().resolve() + have_config = Path(args.have_config_dir).expanduser().resolve() + active_profiles = split_profiles(args.profiles) + contextforge = Path(args.contextforge_dir).expanduser().resolve() + local = Path(args.local_overrides_dir).expanduser().resolve() + output_dir = Path(args.output_dir).expanduser().resolve() + home_dir = Path(args.home_dir).expanduser().resolve() + lock_path = Path(args.lock_path).expanduser().resolve() + report_path = Path(args.report_path).expanduser().resolve() + + hermes_dir = Path(os.environ.get("HERMES_HOME", str(home_dir / ".hermes"))).expanduser().resolve() + repo_roots = [dotfiles, have_config, hermes_dir] + + link_report = ensure_local_override_templates(local, args.dry_run) + link_report.extend(ensure_hermes_home(hermes_dir, active_profiles, args.dry_run)) + link_report.extend(adopt_existing_agent_docs(home_dir, local, output_dir, repo_roots, args.dry_run)) + + layers = [ + manifest_layer("dotfiles", dotfiles, "agent/manifest.json", LAYER_PRIORITIES["dotfiles"]), + manifest_layer("have-config", have_config, "hv/manifest.json", LAYER_PRIORITIES["have-config"]), + ] + for profile in active_profiles: + profile_root = have_config / "profiles" / profile + layer = manifest_layer(f"profile:{profile}", profile_root, "manifest.json", LAYER_PRIORITIES["profile"]) + if profile in detected_profiles: + layer.notes.extend(detection_notes) + layers.append(layer) + if not active_profiles: + layers.append(SourceLayer("profile", have_config / "profiles", None, LAYER_PRIORITIES["profile"], False, detection_notes)) + layers.extend( + [ + manifest_layer("contextforge", contextforge, "manifest.json", LAYER_PRIORITIES["contextforge"]), + manifest_layer("local", local, "manifest.json", LAYER_PRIORITIES["local"]), + ] + ) + + candidates: list[Candidate] = [] + docs: list[DocSnippet] = [] + env_requirements: list[dict[str, Any]] = [] + services: list[dict[str, Any]] = [] + + for layer in layers: + layer_candidates, layer_docs, layer_env, layer_services = collect_manifest(layer) + candidates.extend(layer_candidates) + docs.extend(layer_docs) + env_requirements.extend({**item, "source": layer.name} for item in layer_env) + services.extend({**item, "source": layer.name} for item in layer_services) + services.extend(collect_service_registry(have_config)) + services = dedupe_services(services) + + local_candidates, local_docs = collect_local_conventions(local, LAYER_PRIORITIES["local"]) + candidates.extend(local_candidates) + docs.extend(local_docs) + + resolved = resolve_candidates(candidates) + env_checked, env_missing = validate_env(env_requirements) + doc_outputs = { + target: assemble_doc(target, docs) + for target in ["agents", "claude"] + if any(doc_target_matches(target, snippet.targets) for snippet in docs) + } + + link_report.extend(materialize(resolved, doc_outputs, output_dir, home_dir, repo_roots, args.dry_run)) + + if not args.dry_run: + write_lock(lock_path, layers, resolved, docs, env_checked, services) + write_report(report_path, layers, resolved, doc_outputs, env_checked, env_missing, services, link_report, args.dry_run) + + print(f"HappyVertical agent report: {report_path}") + if not args.dry_run: + print(f"HappyVertical agent lock: {lock_path}") + + if env_missing: + print("Missing required environment variables for enabled capabilities:", file=sys.stderr) + for item in env_missing: + print(f" {item['capability']}: {', '.join(item['missing'])}", file=sys.stderr) + return 2 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/test-hv-agent-resolver.sh b/scripts/test-hv-agent-resolver.sh new file mode 100755 index 0000000..d72bdc8 --- /dev/null +++ b/scripts/test-hv-agent-resolver.sh @@ -0,0 +1,246 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +TMP_DIR="$(mktemp -d)" +trap 'rm -rf "$TMP_DIR"' EXIT + +DOTFILES_DIR="$TMP_DIR/dotfiles" +HAVE_CONFIG_DIR="$TMP_DIR/have-config" +CONTEXTFORGE_DIR="$TMP_DIR/contextforge" +LOCAL_DIR="$TMP_DIR/local" +HOME_DIR="$TMP_DIR/home" +OUTPUT_DIR="$TMP_DIR/generated" +LOCK_PATH="$TMP_DIR/agent-lock.json" +REPORT_PATH="$TMP_DIR/install-report.md" + +mkdir -p "$DOTFILES_DIR/agent" "$DOTFILES_DIR/.agents/commands/codex" \ + "$DOTFILES_DIR/.agents/commands/claude" "$DOTFILES_DIR/.agents/skills/ship" \ + "$DOTFILES_DIR/.agents/skills/review-cycle" "$HAVE_CONFIG_DIR/hv" "$HAVE_CONFIG_DIR/profiles/hermes/commands/codex" \ + "$HAVE_CONFIG_DIR/profiles/hermes/skills/check-setup" "$HAVE_CONFIG_DIR/services" "$CONTEXTFORGE_DIR" \ + "$LOCAL_DIR/commands/codex" "$LOCAL_DIR/skills/codex/ship" "$HOME_DIR" +mkdir -p "$HOME_DIR/.claude" + +cat > "$DOTFILES_DIR/agent/manifest.json" <<'JSON' +{ + "schema": "https://example.test/agent-manifest/v1", + "layer": "dotfiles", + "priority": 10, + "commands": [ + { + "agent": "codex", + "name": "review-cycle", + "path": ".agents/commands/codex/review-cycle.md" + }, + { + "agent": "claude", + "name": "review-cycle", + "path": ".agents/commands/claude/review-cycle.md" + } + ], + "skills": [ + { + "agent": "codex", + "name": "ship", + "path": ".agents/skills/ship" + }, + { + "agent": "codex", + "name": "review-cycle", + "path": ".agents/skills/review-cycle" + } + ], + "agent_docs": [ + { + "id": "dotfiles.test", + "targets": ["agents"], + "content": "Agents may use dotfiles-baseline." + } + ] +} +JSON + +cat > "$DOTFILES_DIR/.agents/commands/codex/review-cycle.md" <<'EOF' +dotfiles codex review-cycle +EOF + +cat > "$DOTFILES_DIR/.agents/commands/claude/review-cycle.md" <<'EOF' +dotfiles claude review-cycle +EOF + +cat > "$DOTFILES_DIR/.agents/skills/ship/SKILL.md" <<'EOF' +dotfiles ship +EOF + +cat > "$DOTFILES_DIR/.agents/skills/review-cycle/SKILL.md" <<'EOF' +dotfiles review-cycle skill +EOF + +cat > "$HAVE_CONFIG_DIR/hv/manifest.json" <<'JSON' +{ + "schema": "https://happyvertical.com/hv-agent-manifest/v1", + "layer": "have-config", + "priority": 20, + "commands": [], + "skills": [], + "agent_docs": [ + { + "id": "have-config.test", + "targets": ["agents"], + "content": "Agents must use fixture-order." + } + ], + "env_requirements": [ + { + "capability": "identity", + "vars": ["HV_AGENT_EMAIL"], + "default_enabled": false + } + ] +} +JSON + +cat > "$HAVE_CONFIG_DIR/profiles/hermes/manifest.json" <<'JSON' +{ + "schema": "https://happyvertical.com/hv-agent-manifest/v1", + "layer": "profile:hermes", + "priority": 25, + "commands": [ + { + "agent": "codex", + "name": "check-setup", + "path": "commands/codex/check-setup.md" + } + ], + "skills": [ + { + "agent": "codex", + "name": "check-setup", + "path": "skills/check-setup" + } + ] +} +JSON + +cat > "$HAVE_CONFIG_DIR/profiles/hermes/commands/codex/check-setup.md" <<'EOF' +hermes check setup +EOF + +cat > "$HAVE_CONFIG_DIR/profiles/hermes/skills/check-setup/SKILL.md" <<'EOF' +hermes check setup skill +EOF + +cat > "$HAVE_CONFIG_DIR/services/services.json" <<'JSON' +{ + "schema": "https://happyvertical.com/service-registry/v1", + "services": [ + { + "id": "fixture-service", + "name": "Fixture Service", + "url": "https://fixture.example.test", + "cli": { + "status": "test-only" + } + } + ] +} +JSON + +cat > "$CONTEXTFORGE_DIR/manifest.json" <<'JSON' +{ + "schema": "https://happyvertical.com/hv-agent-manifest/v1", + "layer": "contextforge", + "priority": "dynamic", + "commands": [ + { + "agent": "codex", + "name": "review-cycle", + "content": "contextforge review-cycle" + } + ], + "skills": [ + { + "agent": "codex", + "name": "ship", + "content": "contextforge ship" + } + ], + "agent_docs": [ + { + "id": "contextforge.test", + "targets": ["codex"], + "content": "Agents must not use fixture-order." + } + ] +} +JSON + +cat > "$LOCAL_DIR/commands/codex/review.md" <<'EOF' +local review +EOF + +cat > "$LOCAL_DIR/commands/codex/review-cycle.md" <<'EOF' +local review-cycle +EOF + +cat > "$LOCAL_DIR/skills/codex/ship/SKILL.md" <<'EOF' +local ship +EOF + +cat > "$HOME_DIR/.claude/CLAUDE.md" <<'EOF' +local claude note +EOF + +HV_AGENT_PROFILE=hermes python3 "$ROOT_DIR/scripts/hv-agent-resolver.py" \ + --dotfiles-dir "$DOTFILES_DIR" \ + --have-config-dir "$HAVE_CONFIG_DIR" \ + --contextforge-dir "$CONTEXTFORGE_DIR" \ + --local-overrides-dir "$LOCAL_DIR" \ + --output-dir "$OUTPUT_DIR" \ + --home-dir "$HOME_DIR" \ + --lock-path "$LOCK_PATH" \ + --report-path "$REPORT_PATH" >/dev/null + +grep -q "local review-cycle" "$HOME_DIR/.codex/commands/review-cycle.md" +grep -q "dotfiles claude review-cycle" "$HOME_DIR/.claude/commands/review-cycle.md" +grep -q "local ship" "$HOME_DIR/.agents/skills/ship/SKILL.md" +grep -q "dotfiles review-cycle skill" "$HOME_DIR/.agents/skills/review-cycle/SKILL.md" +grep -q "hermes check setup" "$HOME_DIR/.codex/commands/check-setup.md" +grep -q "hermes check setup skill" "$HOME_DIR/.agents/skills/check-setup/SKILL.md" +grep -q "local claude note" "$LOCAL_DIR/agent-docs/CLAUDE.md" +grep -q "local claude note" "$HOME_DIR/.claude/CLAUDE.md" +grep -q "potential must/must-not conflict" "$REPORT_PATH" +grep -q '"key": "codex:command:review-cycle"' "$LOCK_PATH" +grep -q '`dotfiles` priority 10: available' "$REPORT_PATH" +grep -q "invalid declared priority 'dynamic' ignored; using fixed 30" "$REPORT_PATH" +grep -q '`fixture-service` https://fixture.example.test CLI: test-only (source: services/services.json)' "$REPORT_PATH" +grep -q '"id": "fixture-service"' "$LOCK_PATH" +grep -q 'skills/codex//SKILL.md' "$LOCAL_DIR/README.md" + +if HV_ENABLED_CAPABILITIES=identity python3 "$ROOT_DIR/scripts/hv-agent-resolver.py" \ + --dotfiles-dir "$DOTFILES_DIR" \ + --have-config-dir "$HAVE_CONFIG_DIR" \ + --contextforge-dir "$CONTEXTFORGE_DIR" \ + --local-overrides-dir "$LOCAL_DIR" \ + --output-dir "$TMP_DIR/generated-env-failure" \ + --home-dir "$TMP_DIR/home-env-failure" \ + --lock-path "$TMP_DIR/env-failure-lock.json" \ + --report-path "$TMP_DIR/env-failure-report.md" >/dev/null 2>&1; then + echo "Expected missing HV_AGENT_EMAIL to fail when identity capability is enabled" >&2 + exit 1 +fi + +EXPLICIT_HOME="$TMP_DIR/explicit-home" +mkdir -p "$EXPLICIT_HOME" +EXPLICIT_HOME="$(cd "$EXPLICIT_HOME" && pwd -P)" +HOME="$EXPLICIT_HOME" python3 "$ROOT_DIR/scripts/hv-agent-resolver.py" \ + --profiles hermes \ + --dotfiles-dir "$DOTFILES_DIR" \ + --have-config-dir "$HAVE_CONFIG_DIR" \ + --contextforge-dir "$CONTEXTFORGE_DIR" \ + --dry-run >/dev/null + +grep -q '`profile:hermes` priority 25: available' "$EXPLICIT_HOME/.hermes/install-report.md" +grep -q "would ensure local override directories under \`$EXPLICIT_HOME/.hermes/overrides\`" "$EXPLICIT_HOME/.hermes/install-report.md" + +echo "hv-agent-resolver tests passed" diff --git a/services/services.json b/services/services.json new file mode 100644 index 0000000..868b3d1 --- /dev/null +++ b/services/services.json @@ -0,0 +1,89 @@ +{ + "schema": "https://happyvertical.com/service-registry/v1", + "services": [ + { + "id": "email", + "name": "HappyVertical email", + "purpose": "Per-user and per-agent identity", + "url": null, + "auth": "Per account; credentials stay in Warden or local environment.", + "cli": { + "status": "account-specific" + } + }, + { + "id": "idp", + "name": "HappyVertical IDP", + "purpose": "Identity provider and SSO", + "url": "https://idp.happyvertical.com", + "auth": "Per-account SSO session.", + "cli": { + "status": "browser-or-session", + "notes": "Verify reachability and authenticated account access with available browser, connector, or local session." + } + }, + { + "id": "warden", + "name": "Warden", + "purpose": "Password and shared secret access", + "url": "https://warden.happyvertical.com", + "auth": "Per-account Warden access.", + "cli": { + "status": "credential-source", + "notes": "Use approved Warden access or local secret integration; never print secret values." + } + }, + { + "id": "oxicloud", + "name": "OxiCloud", + "purpose": "File sharing", + "url": "https://drive.happyvertical.com", + "auth": "WebDAV credentials from Warden/local environment.", + "cli": { + "status": "supported", + "tool": "rclone" + } + }, + { + "id": "vikunja", + "name": "Vikunja", + "purpose": "Project management", + "url": "https://todo.happyvertical.com", + "auth": "Per-account API token from Warden/local environment.", + "cli": { + "status": "server-cli-only", + "notes": "The official Vikunja CLI is for server/container administration; no standard remote task CLI is selected yet." + } + }, + { + "id": "stoat", + "name": "Stoat", + "purpose": "Chat and collaboration", + "url": "https://stoat.happyvertical.com", + "auth": "Per-account credentials.", + "cli": { + "status": "none-selected" + } + }, + { + "id": "bifrost", + "name": "Bifrost", + "purpose": "Gateway", + "url": "https://bifrost.happyvertical.com", + "auth": "Per-account or service-specific credentials.", + "cli": { + "status": "none-selected" + } + }, + { + "id": "contextforge", + "name": "Context Forge", + "purpose": "Dynamic prompts, resources, and memory", + "url": "https://context.happyvertical.com", + "auth": "Per-account or agent Context Forge access.", + "cli": { + "status": "snapshot-source" + } + } + ] +}