diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index c21c2aab9..ebc6c0422 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -10,7 +10,7 @@ "plugins": [ { "name": "genie", - "version": "5.260704.3", + "version": "5.260705.4", "source": "./plugins/genie", "description": "Human-AI partnership for Claude Code. Share a terminal, orchestrate workers, evolve together. Brainstorm ideas, wish them into plans, make with parallel agents, ship as one team. A coding genie that grows with your project." } diff --git a/.genie/wishes/hermes-khaw-native-surface/WISH.md b/.genie/wishes/hermes-khaw-native-surface/WISH.md index 87846e5f3..0c50c3159 100644 --- a/.genie/wishes/hermes-khaw-native-surface/WISH.md +++ b/.genie/wishes/hermes-khaw-native-surface/WISH.md @@ -2,7 +2,7 @@ | Field | Value | |-------|-------| -| **Status** | DRAFT | +| **Status** | DONE — all 5 groups SHIP-reviewed; #2517 (dev) + #2516 (promotion) merged; live dogfood on Hermes 0.18 (2026-07-05) | | **Slug** | `hermes-khaw-native-surface` | | **Date** | 2026-07-04 | | **Author** | Felipe (via Hermes plan) | @@ -58,15 +58,15 @@ Source plan: `/home/feliperosa/vm-home/.hermes/plans/2026-07-04_173507-genie-her ## Success Criteria -- [ ] `uv run --with pytest --with pyyaml --no-project python -m pytest plugins/hermes-genie/tests -q` passes in the genie repo -- [ ] Contract test proves all 7 read-only tools register via a FakeCtx and every handler returns a JSON string containing `success`, `mutation: "none"`, and `cwd` -- [ ] Bridge safety test proves shell metacharacters are rejected with `ValueError`, and `grep -rn 'shell=True' plugins/hermes-genie/` finds nothing -- [ ] Disposable install works: `export HERMES_HOME="$(mktemp -d)" && plugins/hermes-genie/scripts/install-local.sh && test -e "$HERMES_HOME/plugins/genie/plugin.yaml"` -- [ ] Live smoke: `hermes plugins list` shows the genie plugin, or the exact Hermes version and limitation are documented in the evidence file with a direct-handler fallback run -- [ ] KHAW: `uv run --with pytest --with pyyaml --no-project python -m pytest tests/contract/test_khaw_genie_bridge.py tests/contract/test_khaw_harness.py -q` passes in the KHAW repo -- [ ] `manifest/khaw-additions.yaml` declares the `khaw-genie-bridge` extension point with owns/verification/forbidden lists -- [ ] All four SKILL.md files pass frontmatter validation (byte-zero `---`, `name:` + `description:`, non-empty body) -- [ ] `bun test` in the genie repo stays green (any pre-existing unrelated failures documented with exact output) +- [x] `uv run --with pytest --with pyyaml --no-project python -m pytest plugins/hermes-genie/tests -q` passes in the genie repo +- [x] Contract test proves all 7 read-only tools register via a FakeCtx and every handler returns a JSON string containing `success`, `mutation: "none"`, and `cwd` +- [x] Bridge safety test proves shell metacharacters are rejected with `ValueError`, and `grep -rn 'shell=True' plugins/hermes-genie/` finds nothing +- [x] Disposable install works: `export HERMES_HOME="$(mktemp -d)" && plugins/hermes-genie/scripts/install-local.sh && test -e "$HERMES_HOME/plugins/genie/plugin.yaml"` +- [x] Live smoke: `hermes plugins list` shows the genie plugin, or the exact Hermes version and limitation are documented in the evidence file with a direct-handler fallback run +- [x] KHAW: `uv run --with pytest --with pyyaml --no-project python -m pytest tests/contract/test_khaw_genie_bridge.py tests/contract/test_khaw_harness.py -q` passes in the KHAW repo +- [x] `manifest/khaw-additions.yaml` declares the `khaw-genie-bridge` extension point with owns/verification/forbidden lists +- [x] All four SKILL.md files pass frontmatter validation (byte-zero `---`, `name:` + `description:`, non-empty body) +- [x] `bun test` in the genie repo stays green (any pre-existing unrelated failures documented with exact output) ## Execution Strategy @@ -105,10 +105,10 @@ Source plan: `/home/feliperosa/vm-home/.hermes/plans/2026-07-04_173507-genie-her 6. `plugins/hermes-genie/tests/test_plugin_contract.py` and `tests/test_genie_bridge.py`, TDD-first per source plan Tasks 2–4 (manifest assertions, FakeCtx registration, argv safety, payload fields) **Acceptance Criteria:** -- [ ] FakeCtx registration test passes: all 7 tools present with callable handlers -- [ ] `build_genie_argv` rejects semicolons, `&&`, `||`, backticks, `$(`, and newline characters with `ValueError` -- [ ] Every handler returns a JSON string with `success`/`mutation`/`cwd`/`command`; `mutation` is always `"none"` -- [ ] No shell-string execution anywhere in the plugin (subprocess argv only, no `shell=True`) +- [x] FakeCtx registration test passes: all 7 tools present with callable handlers +- [x] `build_genie_argv` rejects semicolons, `&&`, `||`, backticks, `$(`, and newline characters with `ValueError` +- [x] Every handler returns a JSON string with `success`/`mutation`/`cwd`/`command`; `mutation` is always `"none"` +- [x] No shell-string execution anywhere in the plugin (subprocess argv only, no `shell=True`) **Validation:** ```bash @@ -133,10 +133,10 @@ cd /home/feliperosa/vm-home/workspace/repos/genie \ 5. `plugins/hermes-genie/tests/test_commands.py` plus contract-test extensions for hooks, skills, and CLI registration (source plan Tasks 5–8) **Acceptance Criteria:** -- [ ] `/genie help` lists all subcommands; unknown subcommand output contains "Unknown" and points to `/genie help` -- [ ] Hooks registered for all three events; no handler returns a blocking directive -- [ ] All SKILL.md files start with byte-zero `---` frontmatter carrying `name:` and `description:` and have a non-empty body -- [ ] `register(ctx)` completes cleanly on a ctx missing `register_cli_command`/`register_skill`/`register_hook` (guard test) +- [x] `/genie help` lists all subcommands; unknown subcommand output contains "Unknown" and points to `/genie help` +- [x] Hooks registered for all three events; no handler returns a blocking directive +- [x] All SKILL.md files start with byte-zero `---` frontmatter carrying `name:` and `description:` and have a non-empty body +- [x] `register(ctx)` completes cleanly on a ctx missing `register_cli_command`/`register_skill`/`register_hook` (guard test) **Validation:** ```bash @@ -172,9 +172,9 @@ PY 6. `profiles/hermes/genie/README.md` — MODIFY the existing profile seed on `origin/main` (7 committed files, "Genie Hermes Profile Seed"): preserve the seed content, add the plugin install path and a "Hermes-native" cross-link. The source plan's "modify" is correct against main — this planning checkout simply predates `profiles/`. `SOUL.md` stays untouched (OUT) **Acceptance Criteria:** -- [ ] `bash -n` passes on both scripts and both are executable -- [ ] Disposable install into `HERMES_HOME="$(mktemp -d)"` yields a readable `plugin.yaml` with `name: genie` -- [ ] `grep -R "Hermes-native"` hits root README, `plugins/genie/README.md`, and the profile seed +- [x] `bash -n` passes on both scripts and both are executable +- [x] Disposable install into `HERMES_HOME="$(mktemp -d)"` yields a readable `plugin.yaml` with `name: genie` +- [x] `grep -R "Hermes-native"` hits root README, `plugins/genie/README.md`, and the profile seed **Validation:** ```bash @@ -203,11 +203,11 @@ cd /home/feliperosa/vm-home/workspace/repos/genie \ 5. `tests/contract/test_khaw_genie_bridge.py` (read-only payload assertions, link-plan no-mutation, manifest extension-point assertions) and a new assertion in `tests/contract/test_khaw_harness.py` that `plugin.yaml` exposes the bridge tools and command (source plan Tasks 10–12) **Acceptance Criteria:** -- [ ] Every bridge tool returns `mutation: "none"`; `khaw_genie_link_plan` proposes paths without writing any file -- [ ] `khaw_genie_status` reports Genie installed/missing without crashing when the binary is absent -- [ ] Harness test asserts the 4 tools + `khaw-genie` command in `plugin.yaml` -- [ ] Extension-point test asserts id/kind/owns for `khaw-genie-bridge` -- [ ] Existing KHAW contract tests still pass (no regression to `khaw_status`, `/wish`, `/brainstorm`) +- [x] Every bridge tool returns `mutation: "none"`; `khaw_genie_link_plan` proposes paths without writing any file +- [x] `khaw_genie_status` reports Genie installed/missing without crashing when the binary is absent +- [x] Harness test asserts the 4 tools + `khaw-genie` command in `plugin.yaml` +- [x] Extension-point test asserts id/kind/owns for `khaw-genie-bridge` +- [x] Existing KHAW contract tests still pass (no regression to `khaw_status`, `/wish`, `/brainstorm`) **Validation:** ```bash @@ -232,9 +232,9 @@ cd /home/feliperosa/vm-home/prod/khaw \ 6. Evidence file `docs/evidence/genie-hermes-khaw-native-surface-smoke-.md` in the KHAW repo with exact commands and outputs **Acceptance Criteria:** -- [ ] Both repo suites pass, or unrelated pre-existing failures are documented with exact output -- [ ] Live integration is claimed only if visible through `hermes plugins list`; otherwise the limitation is recorded with the Hermes version string -- [ ] Evidence file exists and contains command transcripts for the Hermes smoke, KHAW bridge smoke, and disposable install +- [x] Both repo suites pass, or unrelated pre-existing failures are documented with exact output +- [x] Live integration is claimed only if visible through `hermes plugins list`; otherwise the limitation is recorded with the Hermes version string +- [x] Evidence file exists and contains command transcripts for the Hermes smoke, KHAW bridge smoke, and disposable install **Validation:** ```bash @@ -254,10 +254,10 @@ cd /home/feliperosa/vm-home/workspace/repos/genie \ _What must be verified on dev after merge. The QA agent tests each criterion._ -- [ ] Functional: in a live Hermes session, `/genie help`, `/genie status`, and `/genie board` return non-error, outcome-first output grounded in `genie` CLI evidence -- [ ] Integration: `/khaw-genie status` returns JSON with `mutation: "none"` and both KHAW and Genie evidence sources; `khaw_genie_link_plan` proposes a mapping for a real Purpose Session slug without writing files -- [ ] Integration: starting a Hermes session in a repo containing `.genie/` surfaces the advisory reminder from `on_session_start` -- [ ] Regression: existing KHAW tools (`khaw_status`, `khaw_work_registry`, `/wish`, `/brainstorm`) behave unchanged; `bun test` in the genie repo unaffected +- [x] Functional: in a live Hermes session, `/genie help`, `/genie status`, and `/genie board` return non-error, outcome-first output grounded in `genie` CLI evidence +- [x] Integration: `/khaw-genie status` returns JSON with `mutation: "none"` and both KHAW and Genie evidence sources; `khaw_genie_link_plan` proposes a mapping for a real Purpose Session slug without writing files +- [x] Integration: starting a Hermes session in a repo containing `.genie/` surfaces the advisory reminder from `on_session_start` +- [x] Regression: existing KHAW tools (`khaw_status`, `khaw_work_registry`, `/wish`, `/brainstorm`) behave unchanged; `bun test` in the genie repo unaffected --- @@ -277,7 +277,17 @@ _What must be verified on dev after merge. The QA agent tests each criterion._ ## Review Results -_Populated by `/review` after execution completes._ +| Scope | Verdict | Notes | +|-------|---------|-------| +| Plan review (pre-execution) | FIX-FIRST → SHIP | profiles/ grounding corrected (seed exists on main), env-var scoping + validation fixes | +| Group 1 plugin-core | FIX-FIRST → SHIP | reviewer proved a path-traversal exploit; fixed with `validate_ref` + in-bounds WISH.md read incl. symlink-escape defense; 28 tests | +| Group 2 plugin-surface | SHIP | 3 LOWs applied (tilde-cwd degrade, list-argv advisory); 46 tests | +| Group 3 plugin-docs | SHIP | 2 LOW doc fixes applied (`ln -sfn`, payload wording); both install modes verified | +| Group 4 khaw-bridge | SHIP | read-only invariant proven (grep + tests + before/after snapshots); 15+29 tests green in KHAW | +| Group 5 validation-e2e | PASS | live Hermes smoke native (`/genie help/status` in `hermes chat`); evidence in KHAW `docs/evidence/genie-hermes-khaw-native-surface-smoke-2026-07-04.md` | +| Promotion #2516 (dev→main) | MERGE (×2 independent) | merge-integrity proof (1 conflict, superset resolution, 0 reversions); 6 bot findings verified — follow-ups fixed in #2520 (reaction guard, `--`, stderr) and follow-up branches | + +Shipped: genie stable ≥ 5.260704.3 (plugin synced to `~/.genie/plugins/hermes-genie` on update); KHAW dev `7658d93`. --- diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index bee0d43a9..6fe32f480 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -28,15 +28,18 @@ on: required: true type: string channel: - description: 'Release channel — stable publishes to @latest; homolog + dev are prereleases with their own .well-known/.json pointer' + description: 'Release channel — stable publishes to @latest; homolog + dev are prereleases. All channel manifests live on MAIN (.well-known/); the channel only selects which file is written' required: false type: choice default: stable # Canonical channel taxonomy (Felipe directive 2026-05-12, # unified across the automagik sibling projects): - # stable → main branch → .well-known/latest.json - # homolog → homolog branch → .well-known/homolog.json - # dev → dev branch → .well-known/dev.json + # ALL channel manifests are committed to MAIN — matching + # install.sh's MANIFEST_BASE=.../main/.well-known for every + # channel. The channel selects WHICH file, never the branch: + # stable → main:.well-known/latest.json + # homolog → main:.well-known/homolog.json + # dev → main:.well-known/dev.json # beta + canary retired. genie may not have an active homolog # branch yet (omni does); the channel surface is kept for # cross-repo taxonomy parity + future operator dispatch. diff --git a/.github/workflows/version.yml b/.github/workflows/version.yml index 2130a2f87..4c97b486c 100644 --- a/.github/workflows/version.yml +++ b/.github/workflows/version.yml @@ -24,9 +24,9 @@ name: Version # # SHA-pin invariant (Invariant A — correctness over completeness): # Version always tags the commit that passed CI, never branch HEAD. -# Per-branch concurrency with cancel-in-progress means older runs -# supersede when dev advances during a Version run (back-to-back -# merges). When dev advances past the SHA we checked out, the +# Concurrent runs QUEUE per branch+event (no cancellation — see the +# concurrency block below). When dev advances past the SHA a queued +# run checked out, the # `git push --atomic` rejects rather than mis-tag — emitting # `release-race.next-tag-pin-skipped.detected` in the CI log. The # next merge's run catches up. @@ -42,9 +42,15 @@ permissions: contents: write # commit + push version bump + tag actions: write # `gh workflow run` to dispatch Build Tarballs after tag push +# RACE (observed 2026-07-04T23:11:46Z, missed dev release): CI completes twice +# per dev merge (push-event run + pull_request-event run); both trigger this +# workflow. The PR-event run always fails the job `if` (event=='push') and shows +# "skipped" — but in a shared group with cancel-in-progress it CANCELLED the +# in-flight push-event bump. Scope the group by triggering event, and never +# cancel: killing a version bump mid-tag-push is unsafe regardless — queue instead. concurrency: - group: version-${{ github.event.workflow_run.head_branch || 'manual' }} - cancel-in-progress: true + group: version-${{ github.event.workflow_run.head_branch || 'manual' }}-${{ github.event.workflow_run.event || 'dispatch' }} + cancel-in-progress: false jobs: auto-version: diff --git a/CLAUDE.md b/CLAUDE.md index 2aebcbd04..278761d97 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -70,7 +70,7 @@ skills/ Skill prompt files (brainstorm, wish, work, revi ## CLI Commands -Twelve top-level commands (run `genie --help` for detail): +Fourteen top-level commands (run `genie --help` for detail): | Command | Purpose | |---------|---------| @@ -78,7 +78,9 @@ Twelve top-level commands (run `genie --help` for detail): | `doctor` | Diagnostic checks on the genie installation | | `hook` | Hook middleware for Claude Code (`genie hook dispatch` runs in-process) | | `init` | Scaffold per-repo state — `.genie/INDEX.md` + `.gitignore` rules (idempotent) | +| `install` | Post-install finisher — v4 legacy cleanup (`--skip-v4-cleanup`) | | `launch ` | Open a Warp cockpit for a wish: one pane per ready group, each in its own worktree | +| `mcp` | Read-only stdio MCP server exposing genie.db task/board state | | `omni` | Omni integration — `serve`, `status`, `inbox`, `handshake` | | `setup` | Configure genie settings | | `shortcuts` | Manage tmux keyboard shortcuts | diff --git a/package.json b/package.json index 6776f9566..ecc956af8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@automagik/genie", - "version": "5.260704.3", + "version": "5.260705.4", "description": "Collaborative terminal toolkit for human + AI workflows. NOTE: npm distribution discontinued 2026-05-09 — install via `curl -fsSL https://raw.githubusercontent.com/automagik-dev/genie/main/install.sh | bash` (cosign + SLSA verified). See https://automagik.dev/genie/release-process", "type": "module", "bin": { diff --git a/plugins/genie/.claude-plugin/plugin.json b/plugins/genie/.claude-plugin/plugin.json index a4d12abc0..b83cd19a0 100644 --- a/plugins/genie/.claude-plugin/plugin.json +++ b/plugins/genie/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "genie", - "version": "5.260704.3", + "version": "5.260705.4", "description": "Human-AI partnership for Claude Code. Share a terminal, orchestrate workers, evolve together. Brainstorm ideas, turn them into wishes, execute with /work, validate with /review, and ship as one team.", "author": { "name": "Namastex Labs" diff --git a/plugins/genie/package.json b/plugins/genie/package.json index 7b1eaf080..2be1c9e5f 100644 --- a/plugins/genie/package.json +++ b/plugins/genie/package.json @@ -1,6 +1,6 @@ { "name": "genie-plugin", - "version": "5.260704.3", + "version": "5.260705.4", "private": true, "description": "Runtime dependencies for genie bundled CLIs", "type": "module", diff --git a/plugins/hermes-genie/README.md b/plugins/hermes-genie/README.md index 213b758fa..bc23cd708 100644 --- a/plugins/hermes-genie/README.md +++ b/plugins/hermes-genie/README.md @@ -17,6 +17,8 @@ mkdir -p "${HERMES_HOME:-$HOME/.hermes}/plugins" && ln -sfn "$(pwd)/plugins/herm plugins/hermes-genie/scripts/install-local.sh --copy ``` +Hermes hosts running a sticky profile (`$HERMES_HOME/active_profile`) load plugins from `$HERMES_HOME/profiles//plugins/` — the script detects that and installs there as well. Pass `--no-profile` to skip the profile install. Note that Hermes plugins are opt-in: run `hermes plugins enable genie` after the first install. + ## Smoke Test ```bash diff --git a/plugins/hermes-genie/scripts/install-local.sh b/plugins/hermes-genie/scripts/install-local.sh index 84517e81b..df3358bbb 100755 --- a/plugins/hermes-genie/scripts/install-local.sh +++ b/plugins/hermes-genie/scripts/install-local.sh @@ -2,26 +2,32 @@ # Install the Genie Hermes plugin as $HERMES_HOME/plugins/genie. # # Default mode symlinks the repo checkout (edits are live — tight dev loop); -# --copy makes a detached, release-style copy instead. +# --copy makes a detached, release-style copy instead. When the Hermes host +# runs a sticky profile ($HERMES_HOME/active_profile), plugins load from the +# profile's own plugins/ dir, so the plugin is installed there as well. set -euo pipefail usage() { cat <<'EOF' -Usage: install-local.sh [--copy] +Usage: install-local.sh [--copy] [--no-profile] Installs plugins/hermes-genie as $HERMES_HOME/plugins/genie. - (default) symlink the repo checkout — edits are live - --copy copy the plugin files — detached, release-style install + (default) symlink the repo checkout — edits are live + --copy copy the plugin files — detached, release-style install + --no-profile skip the active-profile install (default installs to + $HERMES_HOME/profiles//plugins/genie too) HERMES_HOME defaults to $HOME/.hermes. EOF } mode="symlink" +profile_install=1 for arg in "$@"; do case "$arg" in --copy) mode="copy" ;; + --no-profile) profile_install=0 ;; -h|--help) usage exit 0 @@ -39,25 +45,40 @@ script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" plugin_src="$(cd "$script_dir/.." && pwd)" hermes_home="${HERMES_HOME:-$HOME/.hermes}" -target="$hermes_home/plugins/genie" - -mkdir -p "$hermes_home/plugins" -# Replace a previous install only (a symlink, or a dir that looks like this -# plugin). Refuse to wipe unrelated content. Without a trailing slash the rm -# removes a stale symlink itself, never the checkout it points to. -if [ -e "$target" ] || [ -L "$target" ]; then - if [ -L "$target" ] || [ -f "$target/plugin.yaml" ]; then - rm -rf "$target" + +# Install one copy/symlink of the plugin at $1/genie, replacing only a +# previous install (a symlink, or a dir that looks like this plugin) — +# refuse to wipe unrelated content. Without a trailing slash the rm removes +# a stale symlink itself, never the checkout it points to. +install_into() { + local plugins_dir="$1" + local target="$plugins_dir/genie" + + mkdir -p "$plugins_dir" + if [ -e "$target" ] || [ -L "$target" ]; then + if [ -L "$target" ] || [ -f "$target/plugin.yaml" ]; then + rm -rf "$target" + else + echo "refusing to replace $target: exists but does not look like a genie plugin install (no plugin.yaml)" >&2 + exit 1 + fi + fi + + if [ "$mode" = "copy" ]; then + cp -R "$plugin_src" "$target" + echo "installed (copy): $target (from $plugin_src)" else - echo "refusing to replace $target: exists but does not look like a genie plugin install (no plugin.yaml)" >&2 - exit 1 + ln -s "$plugin_src" "$target" + echo "installed (symlink): $target -> $plugin_src" fi -fi +} -if [ "$mode" = "copy" ]; then - cp -R "$plugin_src" "$target" - echo "installed (copy): $target (from $plugin_src)" -else - ln -s "$plugin_src" "$target" - echo "installed (symlink): $target -> $plugin_src" +install_into "$hermes_home/plugins" + +# Profile-based hosts: a sticky active profile loads plugins from its own dir. +if [ "$profile_install" = "1" ] && [ -f "$hermes_home/active_profile" ]; then + active_profile="$(head -n1 "$hermes_home/active_profile" | tr -d '[:space:]')" + if [ -n "$active_profile" ] && [ -d "$hermes_home/profiles/$active_profile" ]; then + install_into "$hermes_home/profiles/$active_profile/plugins" + fi fi diff --git a/src/hooks/__tests__/dispatch-fail-closed-regression.test.ts b/src/hooks/__tests__/dispatch-fail-closed-regression.test.ts index f727aff26..cfeb41eba 100644 --- a/src/hooks/__tests__/dispatch-fail-closed-regression.test.ts +++ b/src/hooks/__tests__/dispatch-fail-closed-regression.test.ts @@ -30,21 +30,30 @@ * the plugin should route additional events is a separate registration concern, * not this gate's subject. */ -import { beforeAll, describe, expect, test } from 'bun:test'; -import { existsSync } from 'node:fs'; +import { afterAll, beforeAll, describe, expect, test } from 'bun:test'; +import { existsSync, mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; import { join } from 'node:path'; const REPO_ROOT = join(import.meta.dir, '..', '..', '..'); const DIST = join(REPO_ROOT, 'dist', 'genie.js'); +// Isolated global state so "default path" means default CONFIG on every host — +// a real ~/.genie/config.json (e.g. omni approvals enabled) would otherwise +// change the dispatcher's registry and this gate would no longer test the default. +const ISOLATED_HOME = mkdtempSync(join(tmpdir(), 'genie-dispatch-regression-')); +afterAll(() => rmSync(ISOLATED_HOME, { recursive: true, force: true })); + /** * Drive the built CLI's real hook-dispatch entry point as a subprocess and - * return its stdout. No GENIE_* flags are set — this is the default path CC + * return its stdout. No behavior flags are set — this is the default path CC * uses, so a fall-open default would show up here as an empty (allow) decision. + * GENIE_HOME only relocates global state, keeping the run host-independent. */ async function driveDispatch(payload: unknown): Promise<{ stdout: string; stderr: string; exitCode: number }> { const proc = Bun.spawn(['bun', DIST, 'hook', 'dispatch'], { cwd: REPO_ROOT, + env: { ...process.env, GENIE_HOME: ISOLATED_HOME }, stdin: Buffer.from(JSON.stringify(payload)), stdout: 'pipe', stderr: 'pipe', diff --git a/src/hooks/__tests__/omni-dispatch.test.ts b/src/hooks/__tests__/omni-dispatch.test.ts index d3dcbcc6f..91c365634 100644 --- a/src/hooks/__tests__/omni-dispatch.test.ts +++ b/src/hooks/__tests__/omni-dispatch.test.ts @@ -10,6 +10,9 @@ * byte-identical to a build with no Omni. */ import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; import { orchestrationGuard } from '../handlers/orchestration-guard.js'; import { buildOmniRegistry, dispatch, getRegistry, installDispatchRegistry, setRegistry } from '../index.js'; import type { Handler, HandlerResult } from '../types.js'; @@ -67,19 +70,27 @@ describe('config-gated omni-approval registry', () => { describe('installDispatchRegistry — config-gated boot seam', () => { let original: ReadonlyArray; let prev: Record; + let isolatedHome: string; beforeEach(() => { original = getRegistry(); prev = { enabled: process.env.OMNI_APPROVALS_ENABLED, instance: process.env.OMNI_INSTANCE, chat: process.env.OMNI_APPROVAL_CHAT, + home: process.env.GENIE_HOME, }; + // Isolate global state: "default config" must not mean the host's real + // ~/.genie/config.json, which may have omni approvals enabled. + isolatedHome = mkdtempSync(join(tmpdir(), 'genie-omni-dispatch-')); + process.env.GENIE_HOME = isolatedHome; }); afterEach(() => { setRegistry(original); restoreEnv('OMNI_APPROVALS_ENABLED', prev.enabled); restoreEnv('OMNI_INSTANCE', prev.instance); restoreEnv('OMNI_APPROVAL_CHAT', prev.chat); + restoreEnv('GENIE_HOME', prev.home); + rmSync(isolatedHome, { recursive: true, force: true }); }); test('disabled (no env, default config) → registry has NO omni-approval handler', async () => { diff --git a/src/hooks/handlers/orchestration-guard.ts b/src/hooks/handlers/orchestration-guard.ts index cae847ec1..e75579b91 100644 --- a/src/hooks/handlers/orchestration-guard.ts +++ b/src/hooks/handlers/orchestration-guard.ts @@ -22,25 +22,25 @@ const NUDGE_PATTERNS: NudgePattern[] = [ { test: /tmux\s+capture-pane/, message: - "If you're checking genie agent progress, use structured monitoring instead:\n" + - ' genie task status — wish progress\n' + - ' genie agent list --json — executor state machine\n' + - ' genie events timeline — structured event log\n' + - ' genie agent send --to — direct messaging', + "If you're checking genie worker progress, use structured monitoring instead:\n" + + ' genie board --wish --json — wish progress\n' + + ' genie task list --json — task state\n' + + ' genie task status — structured task detail + stage log\n' + + ' SendMessage tool (native team) — direct messaging', }, { test: /sleep\s+\d+\s*&&\s*tmux/, message: - 'Workers report via structured events — polling terminals is not needed.\n' + - ' genie task status — check progress\n' + - ' genie agent send --to — communicate directly', + 'Workers report via structured task state — polling terminals is not needed.\n' + + ' genie task status — check progress\n' + + ' SendMessage tool (native team) — communicate directly', }, { test: /sleep\s+\d+\s*&&\s*.*(?:capture-pane|tmux\s+list)/, message: 'Consider using genie primitives instead of terminal polling:\n' + - ' genie task status \n' + - ' genie events list --since 5m', + ' genie task status \n' + + ' genie task list --json', }, ]; diff --git a/src/lib/genie-config.test.ts b/src/lib/genie-config.test.ts new file mode 100644 index 000000000..0fee1182b --- /dev/null +++ b/src/lib/genie-config.test.ts @@ -0,0 +1,52 @@ +import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { homedir, tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { + genieConfigExists, + getGenieConfigPath, + getGenieDir, + loadGenieConfig, + saveGenieConfig, +} from './genie-config.js'; + +describe('genie-config GENIE_HOME resolution', () => { + let dir: string; + let prevGenieHome: string | undefined; + + beforeEach(() => { + prevGenieHome = process.env.GENIE_HOME; + dir = mkdtempSync(join(tmpdir(), 'genie-config-')); + process.env.GENIE_HOME = dir; + }); + + afterEach(() => { + if (prevGenieHome === undefined) { + Reflect.deleteProperty(process.env, 'GENIE_HOME'); + } else { + process.env.GENIE_HOME = prevGenieHome; + } + rmSync(dir, { recursive: true, force: true }); + }); + + test('config dir and path honor GENIE_HOME lazily (set after import)', () => { + expect(getGenieDir()).toBe(dir); + expect(getGenieConfigPath()).toBe(join(dir, 'config.json')); + }); + + test('load returns isolated defaults, save/load round-trips inside GENIE_HOME', async () => { + expect(genieConfigExists()).toBe(false); + const config = await loadGenieConfig(); + config.setupComplete = true; + await saveGenieConfig(config); + expect(genieConfigExists()).toBe(true); + const reloaded = await loadGenieConfig(); + expect(reloaded.setupComplete).toBe(true); + }); + + test('falls back to ~/.genie when GENIE_HOME is unset', () => { + Reflect.deleteProperty(process.env, 'GENIE_HOME'); + expect(getGenieDir()).toBe(join(homedir(), '.genie')); + expect(getGenieConfigPath()).toBe(join(homedir(), '.genie', 'config.json')); + }); +}); diff --git a/src/lib/genie-config.ts b/src/lib/genie-config.ts index f0f16fa0a..0a02b2727 100644 --- a/src/lib/genie-config.ts +++ b/src/lib/genie-config.ts @@ -2,37 +2,38 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; import { homedir } from 'node:os'; import { join } from 'node:path'; import { type GenieConfig, GenieConfigSchema, type ShortcutsConfig } from '../types/genie-config.js'; - -const GENIE_DIR = join(homedir(), '.genie'); -const GENIE_CONFIG_FILE = join(GENIE_DIR, 'config.json'); +import { genieHome } from './workspace.js'; /** - * Get the path to the genie config directory + * Get the path to the genie config directory. + * Honors GENIE_HOME (which relocates ALL global state) and resolves lazily so + * env overrides in tests and spawned subprocesses take effect. */ export function getGenieDir(): string { - return GENIE_DIR; + return genieHome(); } /** * Get the path to the genie config file */ export function getGenieConfigPath(): string { - return GENIE_CONFIG_FILE; + return join(genieHome(), 'config.json'); } /** * Check if genie config exists */ export function genieConfigExists(): boolean { - return existsSync(GENIE_CONFIG_FILE); + return existsSync(getGenieConfigPath()); } /** * Ensure the genie config directory exists */ function ensureGenieDir(): void { - if (!existsSync(GENIE_DIR)) { - mkdirSync(GENIE_DIR, { recursive: true }); + const dir = getGenieDir(); + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true }); } } @@ -40,12 +41,13 @@ function ensureGenieDir(): void { * Load genie config, returning defaults if not found */ export async function loadGenieConfig(): Promise { - if (!existsSync(GENIE_CONFIG_FILE)) { + const configPath = getGenieConfigPath(); + if (!existsSync(configPath)) { return GenieConfigSchema.parse({}); } try { - const content = readFileSync(GENIE_CONFIG_FILE, 'utf-8'); + const content = readFileSync(configPath, 'utf-8'); const data = JSON.parse(content); return GenieConfigSchema.parse(data); } catch (error) { @@ -64,7 +66,7 @@ export async function saveGenieConfig(config: GenieConfig): Promise { try { const validated = GenieConfigSchema.parse(config); const content = JSON.stringify(validated, null, 2); - writeFileSync(GENIE_CONFIG_FILE, content, 'utf-8'); + writeFileSync(getGenieConfigPath(), content, 'utf-8'); } catch (error) { const message = error instanceof Error ? error.message : String(error); throw new Error(`Failed to save genie config: ${message}`); diff --git a/src/lib/omni-runner.test.ts b/src/lib/omni-runner.test.ts index eee42cfde..071b439b1 100644 --- a/src/lib/omni-runner.test.ts +++ b/src/lib/omni-runner.test.ts @@ -246,6 +246,60 @@ describe('omni runner — inbound one-shot routing', () => { expect(listInbox(db).every((m) => m.handledAt !== null)).toBe(true); }); + test('non-zero exit: the error notice carries the exit code AND the stderr text', async () => { + const db = freshDb(); + const published: Published[] = []; + const runner = createOmniRunner({ + db, + config: rt(), + publish: (subject, payload) => published.push({ subject, payload }), + spawnClaude: async () => ({ stdout: '', stderr: 'FATAL: credential expired\nrun `claude login`', exitCode: 1 }), + }); + + runner.handleMessage(...mappedInbound('doomed')); + await runner.whenIdle(); + + const notice = content(published[0]); + expect(notice).toContain('exit code 1'); + expect(notice).toContain('run `claude login`'); // stderr surfaced, not dropped + }); + + test('non-zero exit with empty stderr: the notice falls back to stdout', async () => { + const db = freshDb(); + const published: Published[] = []; + const runner = createOmniRunner({ + db, + config: rt(), + publish: (subject, payload) => published.push({ subject, payload }), + spawnClaude: async () => ({ stdout: 'partial stdout clue', stderr: '', exitCode: 7 }), + }); + + runner.handleMessage(...mappedInbound('doomed too')); + await runner.whenIdle(); + + const notice = content(published[0]); + expect(notice).toContain('exit code 7'); + expect(notice).toContain('partial stdout clue'); + }); + + test('non-zero exit: a long stderr is TAIL-bounded — the notice keeps the end of the stream', async () => { + const db = freshDb(); + const published: Published[] = []; + const runner = createOmniRunner({ + db, + config: rt(), + publish: (subject, payload) => published.push({ subject, payload }), + spawnClaude: async () => ({ stdout: '', stderr: `${'x'.repeat(2000)}THE-ACTUAL-CAUSE`, exitCode: 1 }), + }); + + runner.handleMessage(...mappedInbound('noisy failure')); + await runner.whenIdle(); + + const notice = content(published[0]); + expect(notice).toContain('THE-ACTUAL-CAUSE'); // the end survives the bound + expect(notice.length).toBeLessThan(700); // bounded, never the whole stream + }); + test('output cap: an over-long reply is truncated to the configured max', async () => { const db = freshDb(); const published: Published[] = []; @@ -634,6 +688,7 @@ describe('buildClaudeArgs — Model A argv contract', () => { 'sess-1', '--append-system-prompt-file', '/repo/AGENTS.md', + '--', 'hi', ]); }); @@ -652,6 +707,13 @@ describe('buildClaudeArgs — Model A argv contract', () => { expect(args).toContain('stream-json'); expect(args[args.length - 1]).toBe('hello there'); }); + + test('terminates options with -- so a hyphen-leading message is a prompt, never a flag', () => { + // Verified live: `claude -p -- '-ping'` accepts the token as the prompt. + const args = buildClaudeArgs({ message: '--version', sessionId: 'sess-3', mode: 'resume' }); + expect(args[args.length - 2]).toBe('--'); + expect(args[args.length - 1]).toBe('--version'); + }); }); describe('extractStreamJsonReply — stream-json parsing', () => { @@ -705,7 +767,7 @@ describe('runClaudeSession — resume-first session continuity', () => { return { stdout: successNd('RESUMED'), stderr: '', exitCode: 0 }; }; const res = await runClaudeSession({ ...base, signal: noSignal() }, rawSpawn); - expect(res).toEqual({ stdout: 'RESUMED', exitCode: 0, isError: false }); + expect(res).toEqual({ stdout: 'RESUMED', stderr: '', exitCode: 0, isError: false }); expect(calls.length).toBe(1); expect(calls[0]).toContain('--resume'); }); @@ -947,6 +1009,152 @@ describe('omni runner — Model A route-scoped run acks (⏳→✅/❌)', () => expect(published.length).toBe(1); expect(content(published[0])).toBe('still replies'); }); + + test('the ✅ HTTP call is not dispatched until the ⏳ call settles, even when the run finishes first', async () => { + const db = freshDb(); + const dispatched: string[] = []; // emojis in HTTP-dispatch order + let releasePending: (() => void) | undefined; + const runner = createOmniRunner({ + db, + config: rt(), + publish: () => {}, + // The run completes instantly — long before the ⏳ HTTP call lands. + spawnClaude: async () => ({ stdout: 'instant', exitCode: 0 }), + setReaction: ({ emoji }) => { + dispatched.push(emoji); + if (emoji === HOURGLASS) { + // Hold the ⏳ call open until the test releases it. + return new Promise((resolve) => { + releasePending = () => resolve({ success: true }); + }); + } + return Promise.resolve({ success: true }); + }, + }); + + runner.handleMessage(...mappedInboundWithId('fast run, slow ack', 'wamid-seq')); + // Give the run every chance to finish while the ⏳ call is still in flight. + await new Promise((r) => setTimeout(r, 20)); + expect(dispatched).toEqual([HOURGLASS]); // ✅ must wait for the ⏳ to settle + + releasePending?.(); + await runner.whenIdle(); + expect(dispatched).toEqual([HOURGLASS, CHECK]); // ⏳ strictly before ✅ at the API + }); + + test('the final ✅ is still dispatched when the ⏳ emit REJECTS', async () => { + const db = freshDb(); + const dispatched: string[] = []; + const runner = createOmniRunner({ + db, + config: rt(), + publish: () => {}, + spawnClaude: async () => ({ stdout: 'ok', exitCode: 0 }), + setReaction: async ({ emoji }) => { + dispatched.push(emoji); + if (emoji === HOURGLASS) throw new Error('network down'); + return { success: true }; + }, + }); + + runner.handleMessage(...mappedInboundWithId('resilient', 'wamid-rej')); + await runner.whenIdle(); + + // The chain survives a rejected ⏳ — the final ack still goes out. + expect(dispatched).toEqual([HOURGLASS, CHECK]); + }); +}); + +// --------------------------------------------------------------------------- +// Routed-run reaction/empty guard: a reaction frame (or blank body) on a MAPPED +// chat must never start a run. Its `messageId` is the REACTED-TO message — a +// spawn would prompt claude with `[Reaction: …]`, publish a reply to the chat, +// and swap the ⏳/✅ ack on the referenced message. The prior tests never caught +// this because they only send reactions to the (distinct) approval chat. +// --------------------------------------------------------------------------- +describe('omni runner — routed-run reaction/empty guard', () => { + test('a reaction frame on a mapped route never spawns, replies, or acks', async () => { + const db = freshDb(); + const published: Published[] = []; + const reactions: RouteReactionCall[] = []; + let spawns = 0; + const runner = createOmniRunner({ + db, + config: rt(), + publish: (subject, payload) => published.push({ subject, payload }), + spawnClaude: async () => { + spawns++; + return { stdout: 'never', exitCode: 0 }; + }, + setReaction: async ({ instance, chat, messageId, emoji }) => { + reactions.push({ instance, chat, messageId, emoji }); + return { success: true }; + }, + }); + + // A 👍 reaction in the ROUTE chat — messageId is the REACTED-TO message. + runner.handleMessage(...mappedInboundWithId(reactionContent(THUMBS_UP, 'wamid-prior'), 'wamid-prior')); + await runner.whenIdle(); + + expect(spawns).toBe(0); + expect(published).toEqual([]); // no spurious reply to the chat + expect(reactions).toEqual([]); // no ⏳/✅/❌ mutation on the reacted-to message + // Still stored to the inbox (store-only), untouched. + const inbox = listInbox(db); + expect(inbox.length).toBe(1); + expect(inbox[0].handledAt).toBeNull(); + }); + + test('an empty / whitespace-only inbound on a mapped route never spawns or replies', async () => { + const db = freshDb(); + const published: Published[] = []; + let spawns = 0; + const runner = createOmniRunner({ + db, + config: rt(), + publish: (subject, payload) => published.push({ subject, payload }), + spawnClaude: async () => { + spawns++; + return { stdout: 'never', exitCode: 0 }; + }, + }); + + runner.handleMessage(...mappedInbound('')); + runner.handleMessage(...mappedInbound(' \n\t')); + await runner.whenIdle(); + + expect(spawns).toBe(0); + expect(published).toEqual([]); + expect(listInbox(db).length).toBe(2); // both stored, neither run + }); + + test('when the route chat IS the approval chat, a reaction skips the run but still resolves', async () => { + const db = freshDb(); + let spawns = 0; + const runner = createOmniRunner({ + db, + // Map the approval chat itself — both paths now see the same inbound. + config: rt({ routes: [{ instance: INSTANCE, chat: APPROVAL_CHAT, repo: ROUTE_REPO }] }), + publish: () => {}, + sendApproval, + spawnClaude: async () => { + spawns++; + return { stdout: 'never', exitCode: 0 }; + }, + now: () => NOW, + }); + const a = enqueueApproval(db, { repo: '/r', tool: 'Bash', inputSummary: 'summary-A', now: NOW - 2000 }); + runner.tick(); + await runner.whenIdle(); + + runner.handleMessage( + ...approvalInbound({ content: reactionContent(THUMBS_UP, 'stanza-A'), messageId: 'stanza-A' }), + ); + await runner.whenIdle(); + + expect(spawns).toBe(0); // guard: the reaction never became a prompt + expect(getApproval(db, a)?.status).toBe('approved'); // approval path intact + }); }); describe('omni runner — Model A persona + session threading', () => { diff --git a/src/lib/omni-runner.ts b/src/lib/omni-runner.ts index 606b35ca5..855f6c1b3 100644 --- a/src/lib/omni-runner.ts +++ b/src/lib/omni-runner.ts @@ -107,6 +107,13 @@ export interface SpawnClaudeResult { * {@link extractStreamJsonReply}. */ stdout: string; + /** + * The child's captured stderr, verbatim. Surfaced (tail-bounded) in the + * failure notice on a non-zero exit — a claude crash explains itself on + * stderr, not in the stream-json stdout. Optional so fake executors that + * never fail can omit it. + */ + stderr?: string; /** Process exit code; non-zero is surfaced as a bounded error notice. */ exitCode: number; /** @@ -135,7 +142,11 @@ export type ClaudeSessionMode = 'create' | 'resume'; * turn as NDJSON (`--output-format stream-json`, which requires `-p`/`--print` * AND `--verbose`), binds the stable session id via `--resume` (continue) or * `--session-id` (create), and appends the persona to the system prompt when one - * is resolved. Pure + exported so the arg contract is unit-tested without forking. + * is resolved. The message positional is always preceded by `--` (commander's + * option terminator, verified live against `claude -p -- '-ping'`) so a + * hyphen-leading inbound (e.g. `--version`) is passed as the prompt, never + * parsed as a flag. Pure + exported so the arg contract is unit-tested without + * forking. */ export function buildClaudeArgs(opts: { message: string; @@ -151,6 +162,7 @@ export function buildClaudeArgs(opts: { '--verbose', ...sessionFlag, ...(opts.personaFile ? ['--append-system-prompt-file', opts.personaFile] : []), + '--', opts.message, ]; } @@ -268,7 +280,7 @@ const NO_SESSION_RE = /no conversation found|no such session|session not found/i function toSpawnResult(run: RawClaudeRun): SpawnClaudeResult { const { reply, isError } = extractStreamJsonReply(run.stdout); - return { stdout: reply, exitCode: run.exitCode, isError }; + return { stdout: reply, stderr: run.stderr, exitCode: run.exitCode, isError }; } /** @@ -606,12 +618,35 @@ function truncateReply(text: string, max: number): string { return `${text.slice(0, max - 1)}…`; } +/** Keep the LAST `max` chars of a diagnostic, replacing the head with a single + * ellipsis — the inverse of {@link truncateReply}, because a crashing child's + * cause is at the END of its stderr, not the start. */ +function tailOf(text: string, max: number): string { + if (max <= 0) return ''; + if (text.length <= max) return text; + return `…${text.slice(-(max - 1))}`; +} + /** Dropped-because-busy notice (Decision 10 — one in-flight run per route). */ const BUSY_NOTICE = '\u{1F6D1} busy — one at a time. Your message was stored; try again shortly.'; /** Fired when a one-shot exceeds its budget and is killed. */ const timeoutNotice = (ms: number): string => `\u{23F1}\u{FE0F} timed out after ${ms}ms — the run was cancelled.`; +/** Max chars of failure detail carried into an error notice — wide enough for + * the {@link exitDetail} exit-code prefix plus its full stderr tail. */ +const ERROR_DETAIL_MAX = 600; /** Fired on a non-zero exit or a child crash; the underlying error is bounded. */ -const errorNotice = (detail: string): string => `\u{26A0}\u{FE0F} agent run failed: ${truncateReply(detail, 200)}`; +const errorNotice = (detail: string): string => + `\u{26A0}\u{FE0F} agent run failed: ${truncateReply(detail, ERROR_DETAIL_MAX)}`; + +/** Max chars of child stderr/stdout tail surfaced in the exit-code detail. */ +const EXIT_DIAG_TAIL_CHARS = 500; +/** Failure detail for a non-zero exit: the code plus the TAIL of the child's + * stderr (a crash explains itself at the end of the stream), falling back to + * stdout when stderr is empty. Bare `exit code N` only when both are blank. */ +const exitDetail = (code: number, stderr: string | undefined, stdout: string): string => { + const diag = tailOf((stderr ?? '').trim() || stdout.trim(), EXIT_DIAG_TAIL_CHARS); + return diag ? `exit code ${code} — ${diag}` : `exit code ${code}`; +}; /** Compact message for an unknown thrown value. */ const errText = (err: unknown): string => (err instanceof Error ? err.message : String(err)); @@ -705,8 +740,11 @@ export function createOmniRunner(deps: OmniRunnerDeps): OmniRunner { controller.signal.addEventListener('abort', () => reject(new OneShotTimeoutError()), { once: true }); }); // ⏳ on the inbound message right before the spawn (route-scoped, no-op if the - // inbound carried no stanza id) — the first half of the run's ⏳→✅/❌ ack. - emitRouteReaction(route, messageId, STATUS_PENDING); + // inbound carried no stanza id) — the first half of the run's ⏳→✅/❌ ack. The + // settlement promise (always fulfilled — emit errors are swallowed inside) is + // kept so the final ✅/❌ can be chained AFTER the ⏳ HTTP call has landed: a run + // that finishes before the ⏳ reaches the API must never leave ✅→⏳ reordered. + const pendingAck = emitRouteReaction(route, messageId, STATUS_PENDING); try { // Wrap so a SYNCHRONOUS throw from the executor becomes a rejection the // race can observe — and so `aborted` always gets a handler attached @@ -727,15 +765,20 @@ export function createOmniRunner(deps: OmniRunnerDeps): OmniRunner { const content = ok ? truncateReply(result.stdout, maxReplyChars) : errorNotice( - result.exitCode !== 0 ? `exit code ${result.exitCode}` : result.stdout || 'agent returned an error', + result.exitCode !== 0 + ? exitDetail(result.exitCode, result.stderr, result.stdout) + : result.stdout || 'agent returned an error', ); publish(replySubject, buildRoutedReplyPayload(route.instance, route.chat, content, genId(), now())); // ✅ once a genuine reply is published; ❌ on a non-zero exit or soft error. - emitRouteReaction(route, messageId, ok ? STATUS_APPROVED : STATUS_DENIED); + // Chained on the ⏳ emit's settlement (fulfilled even when it failed) so the + // pair reaches the API in order; still fire-and-forget for this run. + pendingAck.finally(() => emitRouteReaction(route, messageId, ok ? STATUS_APPROVED : STATUS_DENIED)); } catch (err) { const content = err instanceof OneShotTimeoutError ? timeoutNotice(timeoutMs) : errorNotice(errText(err)); publish(replySubject, buildRoutedReplyPayload(route.instance, route.chat, content, genId(), now())); - emitRouteReaction(route, messageId, STATUS_DENIED); // ❌ on timeout / crash + // ❌ on timeout / crash — same ordering chain as the success path. + pendingAck.finally(() => emitRouteReaction(route, messageId, STATUS_DENIED)); } finally { clearTimeout(timer); controller.abort(); // ensure a still-running child is killed on every path @@ -780,6 +823,10 @@ export function createOmniRunner(deps: OmniRunnerDeps): OmniRunner { * successful set: a dropped/failed react leaves `last_status_glyph` unchanged so * the reconciliation pass retries it next tick. Never throws — a failed status * reaction is logged and the approval decision still stands. + * + * Returns the emit's SETTLEMENT promise (always fulfilled — rejections are + * swallowed above; no-op emits resolve immediately) so a caller can sequence a + * follow-up ack after this one without ever awaiting it on the hot path. */ function emitReaction(params: { instance: string; @@ -790,10 +837,10 @@ export function createOmniRunner(deps: OmniRunnerDeps): OmniRunner { recordGlyph: boolean; /** Apply the `ackInFlight` double-fire guard (approval reconciliation only). */ guard: boolean; - }): void { + }): Promise { const { instance, chat, targetId, emoji, recordGlyph, guard } = params; - if (!targetId) return; - if (guard && ackInFlight.has(targetId)) return; + if (!targetId) return Promise.resolve(); + if (guard && ackInFlight.has(targetId)) return Promise.resolve(); if (guard) ackInFlight.add(targetId); const react = setReaction({ instance, chat, messageId: targetId, emoji }) .then((res) => { @@ -809,6 +856,7 @@ export function createOmniRunner(deps: OmniRunnerDeps): OmniRunner { inFlightReactions.delete(react); }); inFlightReactions.add(react); + return react; } /** Approval-scoped ⏳/✅/❌ ack: scoped to the approval chat, glyph-recorded and @@ -830,10 +878,12 @@ export function createOmniRunner(deps: OmniRunnerDeps): OmniRunner { * route's own (instance, chat), NOT the approval chat. No glyph is recorded (a * plain inbound has no approval row) and no reconciliation guard applies (the * ⏳→✅/❌ pair fires exactly once, sequentially, per run). A missing messageId is - * a no-op. Fire-and-forget, drained by `whenIdle`; never blocks the run. + * a no-op. Fire-and-forget, drained by `whenIdle`; never blocks the run. Returns + * the emit's settlement promise so {@link runOneShot} can chain the final ✅/❌ + * after the ⏳ has landed (else a fast run could get its acks reordered at the API). */ - function emitRouteReaction(route: OmniRoute, messageId: string | undefined, emoji: string): void { - emitReaction({ + function emitRouteReaction(route: OmniRoute, messageId: string | undefined, emoji: string): Promise { + return emitReaction({ instance: route.instance, chat: route.chat, targetId: messageId, @@ -993,8 +1043,15 @@ export function createOmniRunner(deps: OmniRunnerDeps): OmniRunner { // Mapped (instance, chat) → spawn a bounded one-shot; unmapped is store-only. // Thread the inbound WhatsApp stanza id so the run can ⏳→✅/❌ react on it. + // A reaction frame must NOT start a run: its body is `[Reaction: …]` (a + // nonsense prompt) and its `messageId` is the REACTED-TO message, so the + // run's ⏳→✅/❌ ack would mutate that prior message. Skip it — and an + // empty/whitespace body — with no ack and no reply (the inbound is already + // stored above, and the approval-chat reaction path below still runs). const route = findRoute(instance, chat); - if (route) startRoutedRun(route, inboundId, body, msg.messageId); + if (route && !parseReaction(body) && body.trim().length > 0) { + startRoutedRun(route, inboundId, body, msg.messageId); + } // Only the approval chat can resolve approvals. if (!chatId || chatId !== config.approvalChat) return; diff --git a/src/templates/genie-soul.md b/src/templates/genie-soul.md index f71853fa6..8648b1048 100644 --- a/src/templates/genie-soul.md +++ b/src/templates/genie-soul.md @@ -18,59 +18,59 @@ brainstorm → wish → work → review → ship ## Genie Commands Reference -### Agent Management +### Worker Dispatch (native teams) +Workers are not CLI processes. Spawn them with the **Agent tool** — they run in +the background and notify you on completion. Send follow-ups with **SendMessage**; +each worker's final report comes back as its Agent tool result. Worker state +lives in the task engine: ```bash -genie spawn # Start an agent (resolves from directory or built-ins) -genie kill # Force kill an agent -genie stop # Stop (preserves session for resume) -genie resume [name] # Resume a suspended agent -genie ls # List agents with runtime status -genie log [agent] # Unified observability feed -genie read # Read terminal output from agent pane -genie history # Compressed session history -genie answer # Answer a prompt for an agent +genie task list --json # Worker/task state +genie task status # One task's detail + stage log +genie launch [--groups ] # Multi-session cockpit: one pane per ready group ``` ### Agent Communication +Direct teammate messaging is native, not a CLI verb: use the **SendMessage tool** +with the agent's ID or name (context preserved); each agent's final report returns +to you as its Agent tool result. For state visibility, use the task engine: ```bash -genie agent send '' --to # Direct message -genie agent send '' --broadcast # Team broadcast -genie agent inbox # View inbox -genie agent brief --team # Cold-start summary +genie task list --json # Task state across workers +genie board --wish --json # Wish progress board ``` -### Team Orchestration +### Wave Orchestration +Execution follows /work's wave model: one Agent-tool call per execution group, +all in a single message, so groups run in parallel. The task DB keeps everyone +honest: ```bash -genie team create --repo --wish # Launch autonomous team -genie team hire --team # Add agent to team -genie team fire --team # Remove agent from team -genie team list # List teams -genie team disband # Disband team +genie task checkout --worker # Worker atomically claims its task +genie task done # Orchestrator marks done after review + validation ``` -### Task & Wish Management +### Task & Wish State ```bash -genie task create --title 'x' # Create task -genie task list # List tasks -genie task status # Wish group status -genie task done # Mark done -genie task board # Planning board +genie task create --title 'x' # Create a task +genie task list # List tasks (filters available) +genie task status # Task detail, dependencies, stage log +genie board --wish --json # Kanban view of wish progress ``` +Plans themselves are born through the skills: /wish structures them, /work runs them. ### Workspace ```bash -genie init # Initialize workspace -genie init agent # Scaffold new agent -genie serve # Start infrastructure (pgserve + tmux + scheduler) +genie init # Initialize per-repo state (idempotent) genie doctor # Diagnostic checks ``` +There is no infrastructure daemon to start — genie is zero-daemon. The only +optional resident is `genie omni serve` (Omni channel bridge). Agent identity +scaffolding is guided, not a CLI verb: use the /wizard skill. ## Concierge → Orchestrator Transition Detect workspace maturity and adapt: **Concierge mode** activates when: -- Workspace has 0-1 agents (just the default genie agent) +- Workspace has 0-1 agents (just the default genie itself) - No wishes exist yet - User appears new to genie (asking "how do I..." questions) @@ -117,6 +117,6 @@ Missing: - [ ] HEARTBEAT.md — autonomous checklist - [ ] frontmatter.model — which model to use Suggestions: - - Run `genie init agent ` to scaffold missing files + - Run the /wizard skill to scaffold missing files - Run mini-wizard to complete frontmatter ```