diff --git a/.gitignore b/.gitignore index f17155e..7dcd544 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,8 @@ *.swp *.swo node_modules/ +__pycache__/ +*.py[cod] # Copilot CLI session transcripts can leak into the working dir if # probe prompts reference filenames. Per `gh copilot -- --help`: diff --git a/README.md b/README.md index fa9c4c6..cecda30 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,8 @@ HappyVertical shared cross-repo configuration for agent-assisted development. This repo is the umbrella for configuration that ≥2 HappyVertical projects 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; +lint/format/tsconfig bases. Runtime agent behavior and reusable operational +scripts are installed as local files; Context Forge is consumed as an install-time snapshot by the have-config resolver. @@ -19,6 +20,8 @@ resolver. (`profiles/`) - Agent manifests consumed by the have-config resolver (`hv/manifest.json`, `profiles/*/manifest.json`) +- Reusable operational scripts consumed by ≥2 Hermes/cricket workflows + (`reusable-scripts/`) - Shared lint / format / tsconfig configs as published npm packages (`packages/eslint-config`, `packages/prettier-config`, `packages/tsconfig-base`) @@ -51,6 +54,8 @@ have-config/ ├── TODO.md # planned additions, with consumer count ├── hv/ │ └── manifest.json # organization source manifest +├── reusable-scripts/ +│ └── hermes/no-agent/ # reusable scripts for schedulers/watchers ├── profiles/ │ └── hermes/ │ ├── manifest.json # Hermes-only commands and skills @@ -143,6 +148,15 @@ Hermes agents additionally get local generated commands/skills: - `/check-setup` / `check-setup` — verifies agent access to HappyVertical services +- `hermes-ops` — documents scheduled Vikunja pickup state, blocked-task + recovery, and watcher setup + +The base manifest also installs reusable no-agent scripts when executable +script links are managed: + +- `hv-hermes-vikunja-task-updates` — polls Vikunja task updates +- `hv-hermes-github-cricket-issues` — polls GitHub open issues labeled + `cricket` ## Agent resolution model @@ -156,6 +170,8 @@ The have-config installer composes agent behavior in this order: For command and skill conflicts, later layers win. For AGENTS and CLAUDE behavior, sections are cumulative and assembled in layer order. +Reusable scripts use the same layer priority model and are materialized under +the generated config tree; executable scripts are linked into `~/.local/bin`. Context Forge remains the dynamic organization policy source, but it is not a runtime dependency for normal agent behavior. Export it into diff --git a/TODO.md b/TODO.md index c69412a..3bbbb83 100644 --- a/TODO.md +++ b/TODO.md @@ -14,6 +14,8 @@ that needs it. metadata consumed by the resolver - [x] `profiles/hermes/` — Hermes-only commands and skills such as `check-setup` +- [x] `reusable-scripts/hermes/no-agent/` — reusable notification scripts for + Hermes Vikunja task updates and GitHub `cricket`-labeled issue updates - [ ] `claude/` / `codex/` packaged surfaces, only if a future second consumer needs marketplace/plugin distribution instead of generated local files @@ -28,6 +30,8 @@ that needs it. ## Agent hooks - [ ] `hooks/` — pre-/post-tool-use hooks consumed by ≥2 repos. +- [x] `scripts` manifest entries — reusable operational scripts materialized + by the resolver and linked into `~/.local/bin` when executable ## Lint / format / build bases @@ -79,5 +83,7 @@ Pick when the first consumer pair exists, not before. - [x] `hv/manifest.json` — source manifest consumed by have-config - [x] `profiles/hermes/manifest.json` — Hermes-only defaults such as `check-setup` +- [x] Hermes `hermes-ops` skill — scheduled Vikunja pickup state, + blocked-task recovery, and watcher 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 index 10e388b..b95ec27 100644 --- a/agent-doc-snippets/happyvertical-standards.md +++ b/agent-doc-snippets/happyvertical-standards.md @@ -22,7 +22,8 @@ - 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 `chat.happyvertical.com` (Zulip) for primary chat and collaboration; Hermes agents should use bot credentials and long-poll events when chat response is enabled. +- Treat `stoat.happyvertical.com` as legacy/superseded chat unless a task explicitly asks for Stoat. - 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/docs/agent-playbook.md b/docs/agent-playbook.md index 7d7d9e2..b803751 100644 --- a/docs/agent-playbook.md +++ b/docs/agent-playbook.md @@ -13,7 +13,8 @@ commit them to any repo. | 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. | +| Chat and collaboration | Zulip | `https://chat.happyvertical.com` | Use Zulip as the primary team chat and agent-response channel. Hermes agents should connect with bot credentials and long-poll `/api/v1/events`. | +| Legacy chat | Stoat | `https://stoat.happyvertical.com` | Treat Stoat as legacy/superseded unless a task explicitly asks for it. | | 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. | @@ -34,6 +35,21 @@ The command should verify: - `context.happyvertical.com` is reachable and the agent is configured for prompts, resources, and memory +## Hermes No-Agent Watchers + +The base have-config manifest provides reusable scripts for operational +notifications that can run from cron, systemd user timers, or another local +scheduler without an active agent session: + +- `hv-hermes-vikunja-task-updates` polls recently updated Vikunja tasks. +- `hv-hermes-github-cricket-issues` polls open GitHub issues labeled + `cricket`. + +The resolver materializes these scripts into the generated config tree and +links executable scripts into `~/.local/bin` when that target is managed by +have-config. Store API tokens in the local environment or approved secret +source; do not commit them. + ## Secret Handling - Use Warden as the sharing standard for account passwords and shared secrets. diff --git a/docs/hermes-zulip-gateway.md b/docs/hermes-zulip-gateway.md new file mode 100644 index 0000000..37a0597 --- /dev/null +++ b/docs/hermes-zulip-gateway.md @@ -0,0 +1,54 @@ +# Hermes Zulip Gateway + +HappyVertical's current team chat is Zulip at `https://chat.happyvertical.com`. +Hermes agents that need immediate chat response should run a Zulip bot through +the Hermes gateway using Zulip's long-poll event queue API. + +## Required local secrets + +Do not commit these values. Store them in the local Hermes `.env`, Warden, or the +approved machine-local secret source: + +- `ZULIP_SITE_URL=https://chat.happyvertical.com` +- `ZULIP_EMAIL` — Zulip bot email address +- `ZULIP_API_KEY` — Zulip bot API key + +Optional routing and authorization: + +- `ZULIP_ALLOWED_USERS` — comma-separated Zulip user IDs or emails allowed to DM the bot +- `ZULIP_ALLOW_ALL_USERS=true` — only for trusted/dev environments +- `ZULIP_HOME_CHANNEL` — default delivery target, e.g. `dm:12345` or `stream:general:ops` +- `ZULIP_REQUIRE_MENTION=false` — answer all visible stream messages; default is to answer stream mentions and all DMs + +Authorization readiness requires either `ZULIP_ALLOWED_USERS` or explicit +`ZULIP_ALLOW_ALL_USERS=true`; otherwise a default-deny adapter may authenticate +successfully but refuse to respond to users. + +## Runtime expectation + +A Hermes Zulip adapter should: + +1. Authenticate with Zulip Basic auth using `ZULIP_EMAIL:ZULIP_API_KEY`. +2. Register a message event queue with `POST /api/v1/register`. +3. Long-poll `GET /api/v1/events` with the queue ID and last event ID. Register + only message events when possible (for example, `event_types=["message"]`) to + avoid unnecessary gateway wakeups. +4. Ignore messages sent by the bot itself. +5. Respond to DMs from allowed users immediately and to stream messages only when mentioned unless + `ZULIP_REQUIRE_MENTION=false` is explicitly configured. +6. Use default-deny authorization unless `ZULIP_ALLOWED_USERS` is configured or + `ZULIP_ALLOW_ALL_USERS=true` is explicitly set for a trusted/dev environment. +7. Re-register the queue when Zulip returns an expired or invalid queue ID. +8. Send responses through `POST /api/v1/messages` without logging token values. + +## Setup verification + +A non-secret verification pass should report: + +- whether `ZULIP_SITE_URL`, `ZULIP_EMAIL`, and `ZULIP_API_KEY` are present +- whether `GET /api/v1/users/me` succeeds for the bot +- whether a register/events long-poll loop starts without auth errors +- the configured home channel identifier, if any, without exposing message content + +If credentials are missing, report Zulip as `Blocked` with the missing variable +names rather than asking for or printing secret values. diff --git a/docs/infrastructure.md b/docs/infrastructure.md index 8f785a0..ee5f2df 100644 --- a/docs/infrastructure.md +++ b/docs/infrastructure.md @@ -12,8 +12,9 @@ per user or per agent and must stay out of git. | 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 | +| Vikunja | `https://todo.happyvertical.com` | Project management | Official CLI is server/container admin only; have-config provides a reusable Hermes notification watcher | +| Zulip | `https://chat.happyvertical.com` | Primary team chat and agent-response channel | Hermes gateway long-poll adapter with bot credentials | +| Stoat | `https://stoat.happyvertical.com` | Legacy chat and collaboration | Superseded by Zulip unless explicitly requested | | 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 | @@ -32,3 +33,10 @@ 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. + +## Reusable Operational Scripts + +have-config can materialize reusable operational scripts declared in +`hv/manifest.json`. Current Hermes no-agent scripts poll Vikunja task updates +and GitHub open issues labeled `cricket`; credentials must come from local +environment variables or approved secret tooling. diff --git a/hv/manifest.json b/hv/manifest.json index 7d7e7cc..757ad6a 100644 --- a/hv/manifest.json +++ b/hv/manifest.json @@ -5,53 +5,107 @@ "agent_docs": [ { "id": "happyvertical.org-standards", - "targets": ["agents", "claude"], + "targets": [ + "agents", + "claude" + ], "path": "agent-doc-snippets/happyvertical-standards.md" }, { "id": "happyvertical.agent-playbook", - "targets": ["agents", "claude"], + "targets": [ + "agents", + "claude" + ], "path": "docs/agent-playbook.md" + }, + { + "id": "happyvertical.hermes-zulip-gateway", + "targets": [ + "agents", + "claude" + ], + "path": "docs/hermes-zulip-gateway.md" } ], "commands": [], "skills": [], + "scripts": [ + { + "agent": "no-agent", + "name": "hv-hermes-vikunja-task-updates", + "path": "reusable-scripts/hermes/no-agent/hv-hermes-vikunja-task-updates", + "executable": true, + "description": "Poll Vikunja task updates for Hermes operators without requiring an agent runtime." + }, + { + "agent": "no-agent", + "name": "hv-hermes-github-cricket-issues", + "path": "reusable-scripts/hermes/no-agent/hv-hermes-github-cricket-issues", + "executable": true, + "description": "Poll GitHub open issues labeled cricket without requiring an agent runtime." + } + ], "env_requirements": [ { "capability": "happyvertical-identity", - "vars": ["HV_AGENT_EMAIL"], + "vars": [ + "HV_AGENT_EMAIL" + ], "default_enabled": false, "description": "Per-user or per-agent HappyVertical account identity." }, { "capability": "idp", - "vars": ["HV_AGENT_EMAIL"], + "vars": [ + "HV_AGENT_EMAIL" + ], "default_enabled": false, "description": "HappyVertical IDP account access." }, { "capability": "warden", - "vars": ["HV_WARDEN_URL"], + "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"], + "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"], + "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"], + "vars": [ + "HV_CONTEXTFORGE_SNAPSHOT_DIR" + ], "default_enabled": false, "description": "Local install-time snapshot exported from context.happyvertical.com." + }, + { + "capability": "zulip", + "vars": [ + "ZULIP_SITE_URL", + "ZULIP_EMAIL", + "ZULIP_API_KEY" + ], + "default_enabled": false, + "description": "Zulip bot API access for Hermes gateway long-poll messaging." } ], "services": [ @@ -103,7 +157,7 @@ "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." + "notes": "The official Vikunja CLI is for server/container administration; have-config provides hv-hermes-vikunja-task-updates for notification polling." } }, { @@ -112,8 +166,18 @@ "url": "https://stoat.happyvertical.com", "purpose": "Chat and collaboration.", "cli": { - "status": "none-selected", - "notes": "No standard CLI selected yet." + "status": "legacy-or-superseded", + "notes": "Zulip at https://chat.happyvertical.com is the current primary chat path; keep Stoat notes only for legacy context." + } + }, + { + "id": "zulip", + "name": "Zulip", + "url": "https://chat.happyvertical.com", + "purpose": "Primary team chat and agent-response channel.", + "cli": { + "status": "hermes-gateway-long-poll", + "notes": "Hermes agents should use a Zulip bot account with ZULIP_SITE_URL, ZULIP_EMAIL, and ZULIP_API_KEY; the gateway adapter consumes /api/v1/register and /api/v1/events long polling." } }, { diff --git a/profiles/hermes/commands/claude/check-setup.md b/profiles/hermes/commands/claude/check-setup.md index b3a1611..4fc5819 100644 --- a/profiles/hermes/commands/claude/check-setup.md +++ b/profiles/hermes/commands/claude/check-setup.md @@ -46,6 +46,17 @@ Run these checks: HappyVertical memory bank. - Verify `HV_CONTEXTFORGE_SNAPSHOT_DIR` exists when Context Forge snapshots are expected for install-time materialization. +7. Zulip chat gateway + - Confirm `https://chat.happyvertical.com` is reachable. + - Confirm `ZULIP_SITE_URL`, `ZULIP_EMAIL`, and `ZULIP_API_KEY` are present + when Zulip chat response is expected; do not print their values. + - Report whether authorization is configured with `ZULIP_ALLOWED_USERS` or + explicit `ZULIP_ALLOW_ALL_USERS=true`; otherwise mark response readiness as + `Blocked` even if authentication succeeds. + - If a Hermes Zulip gateway adapter is configured, verify `GET /api/v1/users/me` + succeeds for the bot and that the `/api/v1/register` + `/api/v1/events` + long-poll listener can start without auth errors. + - Report missing bot credentials as `Blocked` with the variable names. If a check cannot be performed noninteractively, mark it `Blocked` and state the missing credential, connector, environment variable, CLI, or local config. diff --git a/profiles/hermes/commands/codex/check-setup.md b/profiles/hermes/commands/codex/check-setup.md index b3a1611..4fc5819 100644 --- a/profiles/hermes/commands/codex/check-setup.md +++ b/profiles/hermes/commands/codex/check-setup.md @@ -46,6 +46,17 @@ Run these checks: HappyVertical memory bank. - Verify `HV_CONTEXTFORGE_SNAPSHOT_DIR` exists when Context Forge snapshots are expected for install-time materialization. +7. Zulip chat gateway + - Confirm `https://chat.happyvertical.com` is reachable. + - Confirm `ZULIP_SITE_URL`, `ZULIP_EMAIL`, and `ZULIP_API_KEY` are present + when Zulip chat response is expected; do not print their values. + - Report whether authorization is configured with `ZULIP_ALLOWED_USERS` or + explicit `ZULIP_ALLOW_ALL_USERS=true`; otherwise mark response readiness as + `Blocked` even if authentication succeeds. + - If a Hermes Zulip gateway adapter is configured, verify `GET /api/v1/users/me` + succeeds for the bot and that the `/api/v1/register` + `/api/v1/events` + long-poll listener can start without auth errors. + - Report missing bot credentials as `Blocked` with the variable names. If a check cannot be performed noninteractively, mark it `Blocked` and state the missing credential, connector, environment variable, CLI, or local config. diff --git a/profiles/hermes/manifest.json b/profiles/hermes/manifest.json index c5451d9..f6c04a2 100644 --- a/profiles/hermes/manifest.json +++ b/profiles/hermes/manifest.json @@ -22,14 +22,32 @@ "name": "check-setup", "path": "skills/check-setup", "description": "Verify HappyVertical service access for this Hermes agent" + }, + { + "agent": "codex", + "name": "hermes-ops", + "path": "skills/hermes-ops", + "description": "Operate Hermes scheduled pickup, blocked-task recovery, and no-agent watchers" } ], "env_requirements": [ { "capability": "hermes", - "vars": ["HV_AGENT_EMAIL"], + "vars": [ + "HV_AGENT_EMAIL" + ], "default_enabled": false, "description": "Per-agent HappyVertical account identity for Hermes." + }, + { + "capability": "zulip", + "vars": [ + "ZULIP_SITE_URL", + "ZULIP_EMAIL", + "ZULIP_API_KEY" + ], + "default_enabled": false, + "description": "Hermes gateway Zulip bot credentials and routing for long-poll chat response." } ], "services": [] diff --git a/profiles/hermes/skills/check-setup/SKILL.md b/profiles/hermes/skills/check-setup/SKILL.md index 67ba554..b11d9f6 100644 --- a/profiles/hermes/skills/check-setup/SKILL.md +++ b/profiles/hermes/skills/check-setup/SKILL.md @@ -49,6 +49,17 @@ Run these checks: HappyVertical memory bank. - Verify `HV_CONTEXTFORGE_SNAPSHOT_DIR` exists when Context Forge snapshots are expected for install-time materialization. +7. Zulip chat gateway + - Confirm `https://chat.happyvertical.com` is reachable. + - Confirm `ZULIP_SITE_URL`, `ZULIP_EMAIL`, and `ZULIP_API_KEY` are present + when Zulip chat response is expected; do not print their values. + - Report whether authorization is configured with `ZULIP_ALLOWED_USERS` or + explicit `ZULIP_ALLOW_ALL_USERS=true`; otherwise mark response readiness as + `Blocked` even if authentication succeeds. + - If a Hermes Zulip gateway adapter is configured, verify `GET /api/v1/users/me` + succeeds for the bot and that the `/api/v1/register` + `/api/v1/events` + long-poll listener can start without auth errors. + - Report missing bot credentials as `Blocked` with the variable names. If a check cannot be performed noninteractively, mark it `Blocked` and state the missing credential, connector, environment variable, CLI, or local config. diff --git a/profiles/hermes/skills/hermes-ops/SKILL.md b/profiles/hermes/skills/hermes-ops/SKILL.md new file mode 100644 index 0000000..baad0a8 --- /dev/null +++ b/profiles/hermes/skills/hermes-ops/SKILL.md @@ -0,0 +1,90 @@ +--- +name: hermes-ops +description: Use when setting up or recovering Hermes operational watchers, Vikunja pickup state, or blocked task handling. +metadata: + short-description: Hermes operational watcher procedure +--- + +# Hermes Operations Procedure + +Use this procedure for Hermes agents that pick up scheduled Vikunja work, +recover blocked tasks, or run no-agent watcher scripts. Do not print or store +tokens, cookies, passwords, or decrypted secret values. + +## Scheduled Vikunja Pickup State + +1. Check the current task state in Vikunja before starting work. + - Confirm the task is still assigned to this Hermes identity or explicitly + available for pickup. + - Read the latest task comments and activity before changing status. +2. Record pickup in Vikunja with a short comment: + - agent identity or hostname + - intended next action + - expected next checkpoint time +3. Keep state in Vikunja as the source of truth. + - Local state files are only watcher cursors. + - If local state disagrees with Vikunja, trust Vikunja and refresh the + watcher cursor. + +## Blocked Task Recovery + +1. Re-read the task, linked issue, and latest comments. +2. Identify the first concrete blocker: + - missing credential or environment variable + - unavailable service + - ambiguous task scope + - failing dependency or upstream issue +3. Add a Vikunja comment that names the blocker and the smallest next action. +4. If a no-agent watcher is configured, verify it still reports updates after + the recovery comment. +5. Do not mark a task unblocked until the required credential, service, + clarification, or dependency is actually available. + +## Watcher Setup + +The base have-config manifest provides these reusable no-agent scripts when +the resolver runs: + +- `hv-hermes-vikunja-task-updates` +- `hv-hermes-github-cricket-issues` + +The resolver links executable scripts into `~/.local/bin` when that location is +managed by have-config. Use cron, systemd user timers, or the local scheduler +already present on the host. + +Required local environment: + +- `HV_VIKUNJA_TOKEN` for Vikunja polling +- `HV_VIKUNJA_URL` when not using `https://todo.happyvertical.com` +- `GITHUB_TOKEN` or `GH_TOKEN` for GitHub polling when rate limits or private + repository access matter +- `HV_HERMES_STATE_DIR` when watcher cursor files should live somewhere other + than `$XDG_STATE_HOME/hv` or `~/.local/state/hv` +- `ZULIP_SITE_URL`, `ZULIP_EMAIL`, and `ZULIP_API_KEY` when the Hermes gateway + should join HappyVertical Zulip and long-poll for immediate chat responses + +Recommended cadence: + +- Vikunja task updates: every 5 to 15 minutes +- GitHub cricket-labeled issues: every 10 to 30 minutes + +Watcher state files are cursors, not task records. If a cursor is stale or +corrupt, move it aside and run the watcher once to initialize from the current +remote state. + +## Zulip Gateway Setup + +HappyVertical's primary chat is Zulip at `https://chat.happyvertical.com`. +When a Hermes agent is expected to respond there immediately, configure a Zulip +bot account in the local Hermes `.env` or approved secret source: + +- `ZULIP_SITE_URL=https://chat.happyvertical.com` +- `ZULIP_EMAIL` +- `ZULIP_API_KEY` +- optional `ZULIP_ALLOWED_USERS`, `ZULIP_ALLOW_ALL_USERS`, `ZULIP_HOME_CHANNEL`, + and `ZULIP_REQUIRE_MENTION` + +The Hermes gateway adapter should use Zulip's `/api/v1/register` and +`/api/v1/events` long-poll loop. If credentials are missing, mark Zulip setup as +blocked with the missing variable names; never ask the user to paste the secret +into task comments or logs. diff --git a/reusable-scripts/hermes/no-agent/hv-hermes-github-cricket-issues b/reusable-scripts/hermes/no-agent/hv-hermes-github-cricket-issues new file mode 100755 index 0000000..6c81743 --- /dev/null +++ b/reusable-scripts/hermes/no-agent/hv-hermes-github-cricket-issues @@ -0,0 +1,170 @@ +#!/usr/bin/env python3 +"""Poll GitHub cricket-labeled issue updates for no-agent Hermes notifications. + +Empty stdout means no change, including initial state seeding. +""" +from __future__ import annotations + +import argparse +import json +import os +import shutil +import subprocess +import urllib.parse +import urllib.request +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +DEFAULT_QUERY = "org:happyvertical is:issue is:open label:cricket" +API = "https://api.github.com" +PER_PAGE = 100 +MAX_PAGES = 10 # GitHub search exposes at most the first 1000 results. + + +def usage_env() -> str: + return """Environment: + GITHUB_TOKEN or GH_TOKEN Optional GitHub token for higher rate limits/private repos. + HV_GITHUB_SEARCH_QUERY Search query. Default: org:happyvertical is:issue is:open label:cricket + HV_HERMES_STATE_DIR State directory. Default: $XDG_STATE_HOME/hv or ~/.local/state/hv +""" + + +def state_file() -> Path: + state_root = os.environ.get("HV_HERMES_STATE_DIR") + if not state_root: + state_root = os.path.join(os.environ.get("XDG_STATE_HOME", str(Path.home() / ".local" / "state")), "hv") + return Path(state_root) / "hermes-github-cricket-issues.json" + + +def request_json(query: str, token: str, page: int) -> dict[str, Any]: + params = urllib.parse.urlencode( + { + "q": query, + "sort": "updated", + "order": "desc", + "per_page": PER_PAGE, + "page": page, + } + ) + headers = { + "Accept": "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", + "User-Agent": "hv-hermes-github-cricket-issues", + } + if token: + headers["Authorization"] = f"Bearer {token}" + req = urllib.request.Request(f"{API}/search/issues?{params}", headers=headers) + with urllib.request.urlopen(req, timeout=30) as resp: + return json.loads(resp.read().decode("utf-8")) + + +def load_state(path: Path) -> dict[str, Any]: + if not path.exists(): + return {"initialized": False, "issues": {}} + try: + return json.loads(path.read_text(encoding="utf-8")) + except Exception: + return {"initialized": False, "issues": {}} + + +def save_state(path: Path, state: dict[str, Any]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + tmp = path.with_suffix(".tmp") + tmp.write_text(json.dumps(state, indent=2, sort_keys=True) + "\n", encoding="utf-8") + tmp.replace(path) + + +def issue_key(issue: dict[str, Any]) -> str: + repo_url = str(issue.get("repository_url") or "") + owner_repo = "/".join(repo_url.rsplit("/", 2)[-2:]) if repo_url else "unknown/unknown" + return f"{owner_repo}#{issue.get('number')}" + + +def issue_signature(issue: dict[str, Any]) -> dict[str, Any]: + return { + "id": issue.get("id"), + "number": issue.get("number"), + "title": issue.get("title") or "(untitled)", + "state": issue.get("state"), + "updated_at": issue.get("updated_at") or "", + "url": issue.get("html_url") or "", + "repo": "/".join(str(issue.get("repository_url") or "").rsplit("/", 2)[-2:]), + "labels": sorted(label.get("name") for label in issue.get("labels", []) if isinstance(label, dict) and label.get("name")), + } + + +def fetch_issues(query: str, token: str) -> dict[str, dict[str, Any]]: + """Fetch a complete GitHub search snapshot within GitHub's 1000-result cap.""" + current: dict[str, dict[str, Any]] = {} + for page in range(1, MAX_PAGES + 1): + payload = request_json(query, token, page) + items = payload.get("items", []) + if not items: + break + for issue in items: + if not isinstance(issue, dict): + continue + current[issue_key(issue)] = issue_signature(issue) + if len(items) < PER_PAGE: + break + return current + + +def notify(title: str, body: str) -> None: + print(f"{title}\n{body}") + notifier = shutil.which("notify-send") + if notifier: + subprocess.run([notifier, title, body], check=False) + + +def fmt_issue(sig: dict[str, Any]) -> str: + bits = [f"{sig.get('repo')}#{sig.get('number')}: {sig.get('title')}"] + if sig.get("updated_at"): + bits.append(f"updated: {sig['updated_at']}") + if sig.get("url"): + bits.append(str(sig["url"])) + return "\n".join(bits) + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__, epilog=usage_env(), formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument("--dry-run", action="store_true", help="Fetch and diff without writing watcher state") + args = parser.parse_args() + + query = os.environ.get("HV_GITHUB_SEARCH_QUERY", DEFAULT_QUERY) + token = os.environ.get("GITHUB_TOKEN") or os.environ.get("GH_TOKEN") or "" + path = state_file() + state = load_state(path) + previous: dict[str, dict[str, Any]] = state.get("issues", {}) + previous_latest = str(state.get("latest_updated_at") or "") + initialized = bool(state.get("initialized")) + + current = fetch_issues(query, token) + latest = max((sig.get("updated_at") or "" for sig in current.values()), default=previous_latest) + + if initialized: + for key, sig in sorted(current.items(), key=lambda kv: (kv[1].get("updated_at") or "", kv[0])): + old = previous.get(key) + if old is None: + notify("New GitHub cricket issue", fmt_issue(sig)) + elif old != sig: + notify("GitHub cricket issue updated", fmt_issue(sig)) + for key, old in sorted(previous.items()): + if key not in current: + notify("GitHub cricket issue removed or closed", fmt_issue(old)) + + new_state = { + "initialized": True, + "checked_at": datetime.now(timezone.utc).isoformat(), + "query": query, + "latest_updated_at": latest, + "issues": current, + } + if not args.dry_run: + save_state(path, new_state) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/reusable-scripts/hermes/no-agent/hv-hermes-vikunja-task-updates b/reusable-scripts/hermes/no-agent/hv-hermes-vikunja-task-updates new file mode 100755 index 0000000..7e244b8 --- /dev/null +++ b/reusable-scripts/hermes/no-agent/hv-hermes-vikunja-task-updates @@ -0,0 +1,220 @@ +#!/usr/bin/env python3 +"""Poll Vikunja task updates for no-agent Hermes notifications. + +Empty stdout means no change, including initial state seeding. +""" +from __future__ import annotations + +import argparse +import json +import os +import shutil +import subprocess +import sys +import urllib.error +import urllib.parse +import urllib.request +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +DEFAULT_BASE_URL = "https://todo.happyvertical.com" + + +def usage_env() -> str: + return """Environment: + HV_VIKUNJA_URL Vikunja base URL. Default: https://todo.happyvertical.com + HV_VIKUNJA_TOKEN Vikunja API token. Required. + HV_VIKUNJA_PROJECT_IDS Optional comma/space-separated project id allowlist. + HV_HERMES_STATE_DIR State directory. Default: $XDG_STATE_HOME/hv or ~/.local/state/hv +""" + + +def api_root() -> str: + base = os.environ.get("HV_VIKUNJA_URL", DEFAULT_BASE_URL).rstrip("/") + return base if base.endswith("/api/v1") else f"{base}/api/v1" + + +def state_file() -> Path: + state_root = os.environ.get("HV_HERMES_STATE_DIR") + if not state_root: + state_root = os.path.join(os.environ.get("XDG_STATE_HOME", str(Path.home() / ".local" / "state")), "hv") + return Path(state_root) / "hermes-vikunja-task-updates.json" + + +def request_json(root: str, token: str, path: str, params: dict[str, Any] | None = None) -> Any: + url = root + path + if params: + url += "?" + urllib.parse.urlencode(params, doseq=True) + req = urllib.request.Request( + url, + headers={"Authorization": f"Bearer {token}", "Accept": "application/json"}, + ) + with urllib.request.urlopen(req, timeout=30) as resp: + body = resp.read().decode("utf-8") + return json.loads(body) if body else None + + +def paged_list(root: str, token: str, path: str, params: dict[str, Any] | None = None, per_page: int = 100) -> list[Any]: + results: list[Any] = [] + base_params = dict(params or {}) + for page in range(1, 101): + payload = request_json(root, token, path, {**base_params, "per_page": per_page, "page": page}) + items = payload.get("tasks") if isinstance(payload, dict) else payload + if not isinstance(items, list): + break + results.extend(items) + if len(items) < per_page: + break + return results + + +def task_signature(task: dict[str, Any], project_id: int, project_title: str) -> dict[str, Any]: + labels = sorted( + str(label.get("title") or label.get("name") or label.get("id")) + for label in (task.get("labels") or []) + if isinstance(label, dict) + ) + assignees = sorted( + str(user.get("name") or user.get("username") or user.get("id")) + for user in (task.get("assignees") or []) + if isinstance(user, dict) + ) + return { + "id": task.get("id"), + "title": task.get("title") or task.get("text") or "(untitled)", + "done": bool(task.get("done")), + "project_id": project_id, + "project_title": project_title, + "updated": task.get("updated") or task.get("updated_at") or "", + "priority": task.get("priority"), + "labels": labels, + "assignees": assignees, + } + + +def load_state(path: Path) -> dict[str, Any]: + if not path.exists(): + return {"initialized": False, "tasks": {}} + try: + return json.loads(path.read_text(encoding="utf-8")) + except Exception: + return {"initialized": False, "tasks": {}} + + +def save_state(path: Path, state: dict[str, Any]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + tmp = path.with_suffix(".tmp") + tmp.write_text(json.dumps(state, indent=2, sort_keys=True) + "\n", encoding="utf-8") + tmp.replace(path) + + +def fmt_task(sig: dict[str, Any]) -> str: + bits = [f"#{sig['id']} {sig['title']}", f"project: {sig['project_title']}"] + if sig.get("done"): + bits.append("done: yes") + if sig.get("priority"): + bits.append(f"priority: {sig['priority']}") + if sig.get("labels"): + bits.append("labels: " + ", ".join(sig["labels"])) + if sig.get("assignees"): + bits.append("assignees: " + ", ".join(sig["assignees"])) + if sig.get("updated"): + bits.append(f"updated: {sig['updated']}") + return "\n".join(bits) + + +def notify(title: str, body: str) -> None: + print(f"{title}\n{body}") + notifier = shutil.which("notify-send") + if notifier: + subprocess.run([notifier, title, body], check=False) + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__, epilog=usage_env(), formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument("--dry-run", action="store_true", help="Fetch and diff without writing watcher state") + args = parser.parse_args() + + token = os.environ.get("HV_VIKUNJA_TOKEN", "").strip() + if not token: + print("HV_VIKUNJA_TOKEN is required", file=sys.stderr) + return 2 + + root = api_root() + allowed_raw = os.environ.get("HV_VIKUNJA_PROJECT_IDS", "") + allowed = {int(x) for x in allowed_raw.replace(",", " ").split() if x.isdigit()} + + projects = paged_list(root, token, "/projects") + if allowed: + projects = [p for p in projects if int(p.get("id", 0)) in allowed] + + current: dict[str, dict[str, Any]] = {} + failed_project_ids: set[int] = set() + for project in projects: + if not isinstance(project, dict): + continue + pid = int(project.get("id", 0)) + title = str(project.get("title") or f"Project {pid}") + try: + tasks = paged_list(root, token, f"/projects/{pid}/tasks") + except urllib.error.URLError as exc: + print(f"Skipping Vikunja project {pid} ({title}) after fetch failure: {exc}", file=sys.stderr) + failed_project_ids.add(pid) + continue + for task in tasks: + if isinstance(task, dict): + sig = task_signature(task, pid, title) + current[str(sig["id"])] = sig + + path = state_file() + state = load_state(path) + previous: dict[str, dict[str, Any]] = state.get("tasks", {}) + initialized = bool(state.get("initialized")) + + # If a project fetch fails, keep its previous task signatures in the + # baseline for this run. Otherwise a transient HTTP/network error would + # look identical to every task in that project being removed and would also + # corrupt the saved state by dropping those tasks from future comparisons. + if failed_project_ids: + for tid, old in previous.items(): + try: + old_project_id = int(old.get("project_id", 0)) + except (TypeError, ValueError): + old_project_id = 0 + if old_project_id in failed_project_ids and tid not in current: + current[tid] = old + + if failed_project_ids and not initialized: + print( + "Skipping initial Vikunja watcher state seed because one or more projects failed to fetch", + file=sys.stderr, + ) + return 0 + + if initialized: + for tid, sig in sorted(current.items(), key=lambda kv: int(kv[0])): + old = previous.get(tid) + if old is None: + notify("New Vikunja task", fmt_task(sig)) + elif old != sig: + title = "Vikunja task completed" if sig.get("done") and not old.get("done") else "Vikunja task updated" + notify(title, fmt_task(sig)) + for tid, old in sorted(previous.items(), key=lambda kv: int(kv[0])): + if tid not in current: + notify("Vikunja task removed or archived", fmt_task(old)) + + new_state = { + "initialized": True, + "checked_at": datetime.now(timezone.utc).isoformat(), + "api_root": root, + "project_ids": [int(p.get("id")) for p in projects if isinstance(p, dict) and p.get("id")], + "tasks": current, + } + if not args.dry_run: + save_state(path, new_state) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/hv-agent-resolver.py b/scripts/hv-agent-resolver.py index 83b340f..48d71df 100755 --- a/scripts/hv-agent-resolver.py +++ b/scripts/hv-agent-resolver.py @@ -9,8 +9,8 @@ 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. +Commands, skills, and reusable scripts use winner-takes-all resolution by layer +priority. Agent documents are cumulative and assembled in layer order. """ from __future__ import annotations @@ -264,6 +264,24 @@ def collect_manifest(layer: SourceLayer) -> tuple[list[Candidate], list[DocSnipp ) ) + for item in data.get("scripts", []): + path = resolve_path(root, item.get("path")) + content = item.get("content") + name = item["name"] + candidates.append( + Candidate( + kind="script", + agent=item.get("agent", "no-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") @@ -397,6 +415,15 @@ def write_candidate(candidate: Candidate, dest: Path) -> None: replace_tree(candidate.path, dest) +def write_script_candidate(candidate: Candidate, dest: Path) -> None: + """Write a script candidate as a file, including inline content.""" + if candidate.content is not None: + ensure_parent(dest) + dest.write_text(candidate.content.rstrip() + "\n", encoding="utf-8") + return + write_candidate(candidate, 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 @@ -410,6 +437,9 @@ def is_managed_target(path: Path, generated_root: Path, repo_roots: list[Path]) return True except ValueError: pass + is_local_bin_link = path.parent.name == "bin" and path.parent.parent.name == ".local" + if is_local_bin_link: + return False for root in repo_roots: try: target.resolve().relative_to(root.resolve()) @@ -577,7 +607,7 @@ def write_report( for note in layer.notes: lines.append(f" - {note}") - lines.extend(["", "## Resolved Commands And Skills", ""]) + lines.extend(["", "## Resolved Commands, Skills, And Scripts", ""]) for key in sorted(resolved): items = resolved[key] winner = selected_candidate(items) @@ -682,7 +712,7 @@ def materialize( report: list[str] = [] if not dry_run: output_dir.mkdir(parents=True, exist_ok=True) - for child in ["skills", "commands"]: + for child in ["skills", "commands", "scripts"]: target = output_dir / child if target.exists(): shutil.rmtree(target) @@ -703,6 +733,14 @@ def materialize( 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) + elif winner.kind == "script": + dest = output_dir / "scripts" / winner.name + if not dry_run: + write_script_candidate(winner, dest) + if winner.metadata.get("executable") is True: + dest.chmod(dest.stat().st_mode | 0o111) + if winner.metadata.get("executable") is True: + link_target(dest, home_dir / ".local" / "bin" / winner.name, output_dir, repo_roots, dry_run, report) for target, content in doc_outputs.items(): filename = TARGETS[target] diff --git a/scripts/test-hv-agent-resolver.sh b/scripts/test-hv-agent-resolver.sh index d72bdc8..fc0bb38 100755 --- a/scripts/test-hv-agent-resolver.sh +++ b/scripts/test-hv-agent-resolver.sh @@ -17,7 +17,8 @@ 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" \ + "$HAVE_CONFIG_DIR/profiles/hermes/skills/check-setup" "$HAVE_CONFIG_DIR/reusable-scripts/hermes/no-agent" \ + "$HAVE_CONFIG_DIR/services" "$CONTEXTFORGE_DIR" \ "$LOCAL_DIR/commands/codex" "$LOCAL_DIR/skills/codex/ship" "$HOME_DIR" mkdir -p "$HOME_DIR/.claude" @@ -83,6 +84,22 @@ cat > "$HAVE_CONFIG_DIR/hv/manifest.json" <<'JSON' "priority": 20, "commands": [], "skills": [], + "scripts": [ + { + "agent": "no-agent", + "name": "fixture-notify", + "path": "reusable-scripts/hermes/no-agent/fixture-notify", + "executable": true, + "description": "fixture no-agent script" + }, + { + "agent": "no-agent", + "name": "inline-notify", + "content": "#!/usr/bin/env bash\necho inline notify", + "executable": true, + "description": "inline no-agent script" + } + ], "agent_docs": [ { "id": "have-config.test", @@ -100,6 +117,11 @@ cat > "$HAVE_CONFIG_DIR/hv/manifest.json" <<'JSON' } JSON +cat > "$HAVE_CONFIG_DIR/reusable-scripts/hermes/no-agent/fixture-notify" <<'EOF' +#!/usr/bin/env bash +echo fixture notify +EOF + cat > "$HAVE_CONFIG_DIR/profiles/hermes/manifest.json" <<'JSON' { "schema": "https://happyvertical.com/hv-agent-manifest/v1", @@ -191,7 +213,7 @@ cat > "$HOME_DIR/.claude/CLAUDE.md" <<'EOF' local claude note EOF -HV_AGENT_PROFILE=hermes python3 "$ROOT_DIR/scripts/hv-agent-resolver.py" \ +HV_AGENT_PROFILE=hermes HERMES_HOME="$HOME_DIR/.hermes" python3 "$ROOT_DIR/scripts/hv-agent-resolver.py" \ --dotfiles-dir "$DOTFILES_DIR" \ --have-config-dir "$HAVE_CONFIG_DIR" \ --contextforge-dir "$CONTEXTFORGE_DIR" \ @@ -207,17 +229,60 @@ 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 "fixture notify" "$OUTPUT_DIR/scripts/fixture-notify" +grep -q "fixture notify" "$HOME_DIR/.local/bin/fixture-notify" +test -x "$HOME_DIR/.local/bin/fixture-notify" +grep -q "inline notify" "$OUTPUT_DIR/scripts/inline-notify" +grep -q "inline notify" "$HOME_DIR/.local/bin/inline-notify" +test -x "$HOME_DIR/.local/bin/inline-notify" +test ! -e "$OUTPUT_DIR/scripts/inline-notify/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 '"key": "no-agent:script:fixture-notify"' "$LOCK_PATH" +grep -q '"key": "no-agent:script:inline-notify"' "$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" \ +UNMANAGED_HOME="$TMP_DIR/unmanaged-home" +UNMANAGED_TARGET_ROOT="$TMP_DIR/foreign" +mkdir -p "$UNMANAGED_HOME/.local/bin" "$UNMANAGED_TARGET_ROOT/.local/bin" +printf 'foreign fixture notify\n' > "$UNMANAGED_TARGET_ROOT/.local/bin/fixture-notify" +ln -s "$UNMANAGED_TARGET_ROOT/.local/bin/fixture-notify" "$UNMANAGED_HOME/.local/bin/fixture-notify" +HERMES_HOME="$UNMANAGED_HOME/.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 "$TMP_DIR/generated-unmanaged" \ + --home-dir "$UNMANAGED_HOME" \ + --lock-path "$TMP_DIR/unmanaged-lock.json" \ + --report-path "$TMP_DIR/unmanaged-report.md" >/dev/null +grep -q 'blocked managed link' "$TMP_DIR/unmanaged-report.md" +test "$(readlink "$UNMANAGED_HOME/.local/bin/fixture-notify")" = "$UNMANAGED_TARGET_ROOT/.local/bin/fixture-notify" + +UNMANAGED_REPO_HOME="$TMP_DIR/unmanaged-repo-home" +UNMANAGED_REPO_TARGET="$HAVE_CONFIG_DIR/manual-bin/fixture-notify" +mkdir -p "$UNMANAGED_REPO_HOME/.local/bin" "$(dirname "$UNMANAGED_REPO_TARGET")" +printf 'manual repo-root fixture notify\n' > "$UNMANAGED_REPO_TARGET" +ln -s "$UNMANAGED_REPO_TARGET" "$UNMANAGED_REPO_HOME/.local/bin/fixture-notify" +HERMES_HOME="$UNMANAGED_REPO_HOME/.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 "$TMP_DIR/generated-unmanaged-repo" \ + --home-dir "$UNMANAGED_REPO_HOME" \ + --lock-path "$TMP_DIR/unmanaged-repo-lock.json" \ + --report-path "$TMP_DIR/unmanaged-repo-report.md" >/dev/null +grep -q 'blocked managed link' "$TMP_DIR/unmanaged-repo-report.md" +test "$(readlink "$UNMANAGED_REPO_HOME/.local/bin/fixture-notify")" = "$UNMANAGED_REPO_TARGET" + +if HV_ENABLED_CAPABILITIES=identity HERMES_HOME="$TMP_DIR/home-env-failure/.hermes" python3 "$ROOT_DIR/scripts/hv-agent-resolver.py" \ --dotfiles-dir "$DOTFILES_DIR" \ --have-config-dir "$HAVE_CONFIG_DIR" \ --contextforge-dir "$CONTEXTFORGE_DIR" \ @@ -233,7 +298,7 @@ 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" \ +HOME="$EXPLICIT_HOME" HERMES_HOME="$EXPLICIT_HOME/.hermes" python3 "$ROOT_DIR/scripts/hv-agent-resolver.py" \ --profiles hermes \ --dotfiles-dir "$DOTFILES_DIR" \ --have-config-dir "$HAVE_CONFIG_DIR" \ diff --git a/services/services.json b/services/services.json index 868b3d1..0736bf7 100644 --- a/services/services.json +++ b/services/services.json @@ -52,17 +52,29 @@ "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." + "notes": "The official Vikunja CLI is for server/container administration; have-config provides hv-hermes-vikunja-task-updates for notification polling." } }, { "id": "stoat", "name": "Stoat", - "purpose": "Chat and collaboration", + "purpose": "Legacy chat and collaboration", "url": "https://stoat.happyvertical.com", - "auth": "Per-account credentials.", + "auth": "Per-account credentials when legacy Stoat access is explicitly needed.", "cli": { - "status": "none-selected" + "status": "legacy-or-superseded", + "notes": "Zulip at https://chat.happyvertical.com is the current primary chat path." + } + }, + { + "id": "zulip", + "name": "Zulip", + "purpose": "Primary team chat and agent-response channel", + "url": "https://chat.happyvertical.com", + "auth": "Per-agent bot API credentials from Warden/local environment.", + "cli": { + "status": "hermes-gateway-long-poll", + "notes": "Hermes gateway adapters should use Zulip /api/v1/register and /api/v1/events long polling with ZULIP_SITE_URL, ZULIP_EMAIL, and ZULIP_API_KEY." } }, {