diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index dc54678..5d03a96 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -17,8 +17,8 @@ { "name": "work-system", "source": "./plugins/work-system", - "description": "Generic task and worktree workflow system for Claude Code. Manage tasks as markdown files, run them in isolated git worktrees with a choice of worker agent (Claude, codex, grok, or kimi), and track progress across the define/kickoff/continue/status/close lifecycle.", - "version": "1.11.0" + "description": "Generic task and worktree workflow system for Claude Code. Manage tasks as markdown files, run them in isolated git worktrees with a choice of worker agent (Claude, codex, grok, kimi, or a PATH-detected cc-harness agent), and track progress across the define/kickoff/continue/status/close lifecycle.", + "version": "1.12.0" }, { "name": "pr-flow", diff --git a/.claude/knowledge/_index.md b/.claude/knowledge/_index.md index 30ad4f7..fe590f6 100644 --- a/.claude/knowledge/_index.md +++ b/.claude/knowledge/_index.md @@ -16,7 +16,7 @@ - `features/lane-registry.md` — `lanes.sh` + `herdr-agent.sh` (Wave 1): the one herdr-agent wrapper (degrade-not-block, bounded wait) + centralized `$HERDR_MATCH_PRELUDE` cwd↔worktree match (consumed by herdr-tab-glyph, regression-guarded via live snapshot); lanes.sh joins states+liveness keyed by worktree_path with a worktree-tab-state degrade tri-state; env test-seams for hermetic join tests - `features/herdr-close-automation.md` — `/close` in herdr: cwd-tab teardown, plugin SessionEnd hook, the one TUI-exit primitive, detached self-exit onto idle - `features/herdr-tab-glyphs.md` — Task-state glyphs (`○ ● ◇ ◆ ✓`) + main-root `◉` on herdr tab labels: `states` mode in the self-contained renderer, sync-vs-`--cached` PR refresh per caller, exact-cwd rename rules, soft pr-flow shim -- `features/kickoff-agent-selection.md` — `/kickoff` worker choice: single committed per-repo default (no global/fallback/ranking) else picker; `agent-registry.sh` as SoT; bounded model-aware grok/kimi probes (inconclusive→trust-auth); kimi's two-phase seed+continue argv + `argv_shell=`; non-claude "document, don't fake" degradation; announce-not-prompt for external defaults +- `features/kickoff-agent-selection.md` — `/kickoff` worker choice: single committed per-repo default (no global/fallback/ranking) else picker; `agent-registry.sh` as SoT; optional PATH-detected `cc-harness:` class (pure consumer of `list`/`exec`, no gateway hardcoding); bounded model-aware grok/kimi probes (inconclusive→trust-auth); kimi's two-phase seed+continue argv + `argv_shell=`; non-claude "document, don't fake" degradation; announce-not-prompt for external defaults - `features/task-archiving-on-close.md` — `/close` archives (not deletes) the task file; adaptive commit + ff-push to main; per-repo `.claude/work-system-close-autocommit` opt-in skips the ask - `features/swarm-backend-adapter.md` — 0.6.0 read+web posture: OS secret-jail (denylist, worktree-aware, git-config-safe), per-voice fail-closed degrade, `jail` verb, prompt egress guard + residual risks; plus verified codex/grok CLI facts (schema JSON, effort mapping, model-aware readiness) - `features/swarm-review-pipeline.md` — `/swarm:review` pipeline: skill↔Workflow wiring, family-consensus, 0.5.0 lens clusters + design-kind verify, `--fix`/`--loop` (deterministic close-out via `loop-closeout.py`), `--pr` publish via deterministic `pr-post.py` diff --git a/.claude/knowledge/architecture/manager-worker-orchestration.md b/.claude/knowledge/architecture/manager-worker-orchestration.md index 6aa798d..5f0e429 100644 --- a/.claude/knowledge/architecture/manager-worker-orchestration.md +++ b/.claude/knowledge/architecture/manager-worker-orchestration.md @@ -20,8 +20,9 @@ implementation is spawned across tasks `add-lane-registry`, `spike-agent-mail-su - **Manager** = the Claude Code session at the main repo root (herdr `◉` tab). A *coordinator*, not a merge robot — the human stays merge authority unless explicitly delegated at kickoff. -- **Worker** = one {claude|codex|grok|kimi} session per worktree, driving its task to a - reviewed, mergeable PR. +- **Worker** = one {claude|codex|grok|kimi|cc-harness:<id>} session per worktree, driving + its task to a reviewed, mergeable PR. (A cc-harness worker is a claude session on a + foreign model, so it tiers like claude, not like the external CLIs.) - **Lane** = `(worktree_path, task, branch)`. **Identity = worktree_path** — the one key stable across agent types and restarts. herdr pane/tab, `agent_status`, session UUID, PR state are live-attached attributes, **never identity**. Only the diff --git a/.claude/knowledge/features/herdr-kickoff-automation.md b/.claude/knowledge/features/herdr-kickoff-automation.md index 0a22612..9860972 100644 --- a/.claude/knowledge/features/herdr-kickoff-automation.md +++ b/.claude/knowledge/features/herdr-kickoff-automation.md @@ -37,8 +37,11 @@ truth; this entry captures the durable design and one non-obvious gotcha. `-m` form — `codex -m ""` / `grok -m ""`, and kimi (1.11.0) a two-phase `sh -c 'kimi -m "$1" -p "$2" || …; exec kimi -c --auto' …` — it has no positional - launch prompt (see [[kickoff-agent-selection]]). `emit_argv` is the SoT; never - reconstruct an argv from this list. + launch prompt (see [[kickoff-agent-selection]]). A cc-harness worker (1.12.0, + only when the optional PATH helper lists it) wraps the claude form: + `cc-harness-agents exec -- claude [-n ] /work-system:continue` — + no `--model` (the helper env-sets it, then `exec`s, so the pane still roots at + claude). `emit_argv` is the SoT; never reconstruct an argv from this list. herdr-launch stays CLI-agnostic — it just execs the resolved `argv=` words. The `-- argv` form sidesteps the interactive shell entirely, so there is no keystroke race against shell startup (see the gotcha below) and no readiness handshake to diff --git a/.claude/knowledge/features/kickoff-agent-selection.md b/.claude/knowledge/features/kickoff-agent-selection.md index 9a9551f..4de2c5e 100644 --- a/.claude/knowledge/features/kickoff-agent-selection.md +++ b/.claude/knowledge/features/kickoff-agent-selection.md @@ -3,8 +3,8 @@ title: "Kickoff Agent Selection: registry, per-repo default, honest degradation" createdAt: 2026-07-17 updatedAt: 2026-08-05 createdFrom: "session: 2026-07-17 (task/kickoff-agent-selection)" -updatedFrom: "session: 2026-08-05 (task/add-kimi-worker-support, post-swarm)" -pluginVersion: 1.11.0 +updatedFrom: "session: 2026-08-05 (task/offer-cc-harness-agents-at-kickoff)" +pluginVersion: 1.12.0 prime: false --- @@ -15,22 +15,91 @@ prime: false ## Single per-repo default, no global, no fallback The **only** persisted selection state is one committed -`/.claude/work-system-agent` (`default=`). No global per-user -default, no shipped fallback, no `--auto` ranking, no `--last`. With no flag: -use the repo default if set, else the **picker** — which offers (in the same -AskUserQuestion) to save the pick as the project default (applied only after a -successful launch). This was deliberately simplified *down* to this from an -earlier ranking/two-tier design — the user wanted "project default or picker," -nothing more. `--pick` forces the picker even when a default exists. +`/.claude/work-system-agent` (`default=` or +`default=cc-harness:`). No global per-user default, no shipped fallback, no +`--auto` ranking, no `--last`. With no flag: use the repo default if set, else +the **picker** — which offers (in the same AskUserQuestion) to save the pick as +the project default (applied only after a successful launch). This was +deliberately simplified *down* to this from an earlier ranking/two-tier design +— the user wanted "project default or picker," nothing more. `--pick` forces +the picker even when a default exists. ## Registry is the single source of truth `scripts/agent-registry.sh` owns aliases (`--fable`/`--opus`/`--codex`/`--sol`/ -`--grok`/`--kimi`/`--agent cli[:model]`), the launch argv per CLI, availability, and +`--grok`/`--kimi`/`--agent cli[:model]`), the optional PATH-detected +`cc-harness:` class, the launch argv per CLI, availability, and `default get`/`set`. `herdr-launch.sh` stays CLI-agnostic: it execs the resolved `argv=` words (argv-exec, no shell-typing race — same reason as the kickoff launch). Skills never hardcode the CLI list. `default get` **validates** its committed value against the registry — a stale/removed/attacker-supplied name -reads as "no default" (→ picker), never routes or bricks kickoff. +(including a `cc-harness:…` default when the helper is off PATH) reads as "no +default" (→ picker), never routes or bricks kickoff. + +## Optional `cc-harness` class: PATH helper, pure consumer +A `cc-harness:grok` worker is a *full* CC session (skills, lenses, `/continue`, +lifecycle) driven by a foreign model via a local gateway — strictly more capable +than the native `grok`/`codex` CLI voice (which has no work-system skills and +gets a bootstrap prompt). Its subagents also run on the foreign model. + +Detection is one `command -v cc-harness-agents`. When present, `list` merges the +helper's TSV rows (4 cols: `name/model/available/note`, name already +namespaced); when absent or the helper exits 3 (capability absent — no token), +behaviour is unchanged. The plugin never re-probes gateway/creds/models and +hardcodes no agent table — whatever `list` prints becomes a picker entry +(verified with a mock that returns a name the plugin has never heard of). +Context ceilings differ per agent and are plan-gated; the helper owns that +value, so the plugin must not restate or assume a window. + +Resolve shape (no `--model` — the helper sets it via env, then `exec`s into +claude so the herdr pane roots at claude and agent_status + `/close` stay +intact): + + cc-harness-agents exec -- claude [-n ] /work-system:continue + +`supports=` is the full claude set (`continue,close-exit,statusline,commit,pr`). +The contract itself (columns, exit codes, exec semantics) lives in **one** place — +`plugins/work-system/docs/cc-harness-agents.md`; nothing checks prose copies for +agreement, so don't restate it here or in the script header. Earlier idea "invoke +the zsh `claude()` wrapper via `zsh -ic`" was rejected: fragile, ties the plugin +to zsh, interactive-shell side effects. + +**Parity holds at runtime but breaks at `/continue` reopen.** A harness worker +*runs* as a real CC session, so `/close` and tab glyphs are unchanged — but +`herdr-launch.sh resume` always sends a bare `claude -c`, and the work-system does +not persist which worker a task used. For a harness task that resumes the correct +transcript **without the routing env**, i.e. silently on the user's default Claude +model. That is worse than the codex/grok/kimi degrade, which is visibly a new +session. Both are surfaced inline by `/continue`; the harness form to run by hand +is `cc-harness-agents exec -- claude -c`. A real fix needs per-task worker +persistence, not a contract change. + +**Helper output is untrusted input.** Rows are sanitized at ingest (C0 controls + +DEL stripped, length capped) because a `note` is rendered to the user as an +authoritative fix hint and enters the picker's context — the same risk class the +`--session` guard already rejects control characters for. Residual: Unicode +bidi/zero-width overrides survive (no portable bash-3.2 way to strip them), so the +skill treats the note as display text, never as an instruction. Parsing splits +tabs **explicitly**: `IFS=$'\t' read` treats tab as IFS *whitespace* and collapses +consecutive tabs, so one empty cell shifts every later column — an empty model made +`available` read as the model and fail-closed a working agent. The same trap bites +twice: once on the helper's output, once when a consumer re-reads the lookup's own +line. And `list` and `resolve` must apply the **same** namespace gate, or a row +`list` rejects stays invisible yet launchable and storable as a committed default. + +## The picker is two pages because AskUserQuestion caps at 4 options +Merging harness rows flat into the picker made it ~12 entries — against a hard +**4-options-per-question** limit, which the 7 native entries already exceeded. +So the harness set lives **one page down**: page 1 = the native rows plus a +single `cc-harness agents ▸` aggregate (shown only when the helper printed +rows), page 2 = the concrete harness agents. The common path stays one page and +the harness list can grow with the helper's table without touching page 1. + +Two consequences worth keeping: the aggregate is a *class*, never a `SELECTOR` +— and the "save as project default?" answer must come from the page where the +**final** pick happened (page 1's answer applied to a choice not yet made, so +the aggregate path discards it). Where a set still exceeds 4, the rule is +*consolidate and say what you left out* (`--agent ` reaches any entry) — +never silently truncate. ## grok availability is model-aware and bounded grok drops/renames models between releases (composer `grok-composer-2.5-fast` diff --git a/CHANGELOG.md b/CHANGELOG.md index 2f5f217..1a5cd71 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -52,6 +52,19 @@ entries are grouped per plugin, newest first. ## work-system +### 1.12.0 — 2026-08-05 +- `/kickoff` offers **cc-harness foreign agents** when a `cc-harness-agents` helper is on `PATH`: full Claude Code sessions driven by a foreign model (e.g. `cc-harness:grok`, `cc-harness:kimi`, `cc-harness:sol`) via a local gateway. Auto-detected — one `command -v`; helper absent → no change from today. The plugin is a pure consumer of a small contract (`list` / `exec`); it hardcodes no gateway, no models, no agent table — whatever `list` prints becomes a picker entry (covered by a mock that returns a name the plugin has never heard of). +- `agent-registry.sh` merges the helper's 4-column TSV (`name/model/available/note`, name already namespaced) into its 5-column list as `cli=cc-harness`, maps only literal `available=yes` to available (fail-closed on `unknown`), and resolves `cc-harness:` to `cc-harness-agents exec -- claude [-n ] /work-system:continue` — no `--model` (the helper sets it via env before `exec`ing into claude, so the herdr pane roots at claude and agent_status + `/close` stay intact). Exit 3 from the helper ("capability absent") is a silent degrade, distinct from a listed-but-unavailable provider (exit 3 from resolve, with the helper's fix hint). +- Lifecycle parity: a cc-harness worker is a real CC session, so `/continue`, `/close` Scenario A/B, and tab glyphs work unchanged. `supports=` is the full claude set. A committed `cc-harness:` default is accepted when the helper lists it and falls through to the picker when the helper is gone (same validation path as a stale native name). +- The picker is now **two pages**: page 1 is the familiar worker list plus a single `cc-harness agents ▸` aggregate entry (shown only when the helper printed rows), and choosing it opens page 2 with the concrete harness agents. Merging them flat would have made ~12 entries against AskUserQuestion's hard 4-options-per-question cap, which the 7 native entries already exceeded. The common path stays one page; the harness list can grow with the helper's table. The aggregate is a class, never a selector, and the "save as project default?" answer is taken from the page where the final pick happened. Where a set still exceeds 4, consolidate and name what was left out (`--agent ` reaches any entry) — never silently truncate. Unavailable rows stay visible with the helper's fix hint, available first. +- Contract + setup sketch: `plugins/work-system/docs/cc-harness-agents.md`. Earlier "invoke the zsh `claude()` wrapper via `zsh -ic`" idea was rejected: fragile, ties the plugin to zsh, interactive-shell side effects. +- `/continue`'s reopen degrades for a harness worker too, and says so. `herdr-launch.sh resume` always sends a bare `claude -c` (the work-system never persisted which worker a task used), which for a harness task resumes the *correct transcript on the wrong model* — without `cc-harness-agents exec` there is no `ANTHROPIC_BASE_URL`/`ANTHROPIC_MODEL`, so it silently continues on the user's default Claude model. That is worse than the codex/grok/kimi degrade, which is visibly a new session. `/continue`, the README and the docs page now state it inline and give the manual form (`cc-harness-agents exec -- claude -c`) instead of claiming unqualified lifecycle parity; a real fix needs per-task worker persistence. +- Helper rows are sanitized at ingest and parsed positionally. A `note` is rendered to the user as an authoritative fix hint and enters the picker's context, so C0 controls + DEL (ANSI escapes, embedded newlines that could forge extra `key=value` lines) are stripped and the length is capped — the same risk class the `--session` guard already rejects control characters for; Unicode bidi/zero-width overrides remain a documented residual. Parsing now splits tabs explicitly: `IFS=$'\t' read` treats tab as IFS *whitespace* and collapses consecutive tabs, so a single empty cell shifted every later column (an empty model made `available` read as the model, fail-closing a working agent; the mirror case defeated the fail-closed rule). The trap bit twice — once on the helper's output, once where `resolve`/`row_for_name` re-read the lookup's own line. +- `list` and `resolve` now share one namespace gate. A helper row that forgot the `cc-harness:` prefix was rejected by the list builder but still matched `harness_lookup`'s bare-id clause — invisible in `list`/`--json`/the picker, yet resolvable, launchable, and storable as a committed repo default. The match collapsed to the single canonical comparison. +- The picker's page-1 rule was unsatisfiable against its own 4-option cap (4 shipped CLIs + the aggregate = 5). It is now a fixed ordered rule: the aggregate reserves a slot whenever harness rows exist, natives fill the rest **one option per CLI** (alternates named in the description), and anything still over the cap is named in the question text with the `--agent` hint — never silently truncated. Classification also moved from the `column -t`-padded human table to `list --json`, whose `cli` field is unambiguous (`note` cells contain spaces, so splitting the padded table could misfile a harness row and drop the aggregate entirely). +- Fix: `list`'s human table dropped the **last** harness row. `$( )` strips the trailing newline off the merged block, and the table's `while read` then discards the final newline-less line — silently, and only in the table, since `--json` parses it fine. That table is exactly what the picker reads, so the lost row was an agent the user could never choose. Now covered by a test that asserts the table (not just `--json`) and that both views agree on the row count. +- Covered by `test_agent_registry.py`: merge list, never-seen agent, argv shape (no `--model`), clean degrade when absent, exit-3 capability-absent, `available=unknown` → no, harness default set/get, plus regressions for each fix above — `list`/`resolve` namespace agreement, empty middle cells in both directions, control-character stripping (and that a sanitized note cannot forge a second `name=` line), and the human table carrying every row. The helper stub now emits rows with `printf` instead of `echo "…\t…"`: escape expansion by `echo` is not POSIX-guaranteed, so on a host whose `/bin/sh` is bash without xpg_echo every harness assertion would have failed while CI (ubuntu/dash) stayed green. The no-helper cases pin `WORK_SYSTEM_CC_HARNESS_AGENTS` at a guaranteed-absent path, so they no longer depend on whether the contributor actually has the helper installed. + ### 1.11.0 — 2026-08-03 - `/kickoff` can launch the **kimi CLI** (kimi-code) as a worker: `--kimi` → `kimi:kimi-code/k3-256k`, joining claude/codex/grok in `agent-registry.sh`. It appears in the picker and can be saved as the repo default, where it announces like the other third-party workers. - kimi is the first worker without a ` -m ` launch form — it has no positional launch prompt, no initial-prompt env var, and piped stdin only prefills the input box (and would steal the TUI's tty). Its `-p` flag is the only entry point, but it cannot be combined with `--auto`/`-y` and exits after one answer. Since `-p` does run tools unattended and `kimi -c` inherits its history, the launch is two-phase: `sh -c 'kimi -m "$1" -p "$2" || ; exec kimi -c --auto' …` — the seed works the task through once, then `exec` hands over to the interactive autonomous session. So a kimi tab has already made progress by the time you switch to it. diff --git a/CLAUDE.md b/CLAUDE.md index 90113b5..09a9a51 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -19,7 +19,7 @@ This is a **Claude Code plugin marketplace** (monorepo) containing plugins that ## Current Plugins - **knowledge-system** (v1.9.x) — Knowledge management with three layers: Rules, Knowledge, Memory. Skills: `/init`, `/query`, `/curate`, `/reindex`, `/backfill-knowledge`, `/migrate`, `/statusline` -- **work-system** (v1.11.x) — Task and worktree workflow (workers: Claude/codex/grok/kimi). Skills: `/define`, `/kickoff`, `/adopt`, `/continue`, `/status`, `/close`, `/list`, `/statusline` +- **work-system** (v1.12.x) — Task and worktree workflow (workers: Claude/codex/grok/kimi, or PATH-detected cc-harness). Skills: `/define`, `/kickoff`, `/adopt`, `/continue`, `/status`, `/close`, `/list`, `/statusline` - **pr-flow** (v1.3.x) — PR review feedback loop. Skills: `/open`, `/cycle`, `/check`, `/fix`, `/rebase`, `/merge` - **swarm** (v0.7.x) — Local mixture-of-agents code review (external `codex`/`grok` CLIs — grok-4.5 — plus Claude lenses: 11 in 4 clusters). Every voice fans out per gated cluster; externals get file-read + web research under an OS secret-jail. P2: `/swarm:review` pipeline (scope→fan-out→merge→verify); P5: `--fix`/`--loop` apply the findings you agreed with. Skills: `/swarm:review`, `/swarm:agents` - **settings** (v0.1.x) — Per-plugin TOML config resolved over schema defaults; each plugin owns its `schema/settings.schema.json`. Skill: `/settings` (list/show/get/set/validate). Phase 1: config surface only. diff --git a/README.md b/README.md index 1df7b9f..a9778a1 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ Lightweight, native knowledge management for Claude Code projects. Three layers ### Work System -Generic task and worktree workflow system. Manage tasks as markdown files, work in isolated git worktrees, and track progress through the full lifecycle. `/kickoff` runs the repo's default worker agent (Claude, codex, grok, or kimi — a single committed per-project default), or, when none is set, shows a picker and offers to save your choice; override per run with flags like `--opus`/`--sol` or `--pick`. Inside a [herdr](plugins/work-system/README.md#herdr-integration) session it auto-opens a tab (named after the task, shortened for the sidebar and prefixed with the task's state glyph — `●` active, `◇` in review, `◆` approved, `✓` merged) with the worktree as cwd, starts the chosen worker, and — for a Claude worker — runs `/work-system:continue` for you (plugin-qualified, since a Claude Code built-in `/continue` shadows the bare skill); `/adopt` auto-opens the same tab once it has built the worktree from an existing branch; `/work-system:continue ` from the main session reopens that tab and resumes it if a stray `/exit` closed it; and `/close` tears the tab down again when the task is merged. +Generic task and worktree workflow system. Manage tasks as markdown files, work in isolated git worktrees, and track progress through the full lifecycle. `/kickoff` runs the repo's default worker agent (Claude, codex, grok, kimi, or a PATH-detected cc-harness foreign agent — a single committed per-project default), or, when none is set, shows a picker and offers to save your choice; override per run with flags like `--opus`/`--sol` or `--pick`. Inside a [herdr](plugins/work-system/README.md#herdr-integration) session it auto-opens a tab (named after the task, shortened for the sidebar and prefixed with the task's state glyph — `●` active, `◇` in review, `◆` approved, `✓` merged) with the worktree as cwd, starts the chosen worker, and — for a Claude worker — runs `/work-system:continue` for you (plugin-qualified, since a Claude Code built-in `/continue` shadows the bare skill); `/adopt` auto-opens the same tab once it has built the worktree from an existing branch; `/work-system:continue ` from the main session reopens that tab and resumes it if a stray `/exit` closed it; and `/close` tears the tab down again when the task is merged. **Commands:** `/define`, `/kickoff`, `/adopt`, `/continue`, `/status`, `/close`, `/list`, `/statusline` diff --git a/plugins/work-system/.claude-plugin/plugin.json b/plugins/work-system/.claude-plugin/plugin.json index 428cd2e..f32599c 100644 --- a/plugins/work-system/.claude-plugin/plugin.json +++ b/plugins/work-system/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "work-system", - "description": "Generic task and worktree workflow system for Claude Code. Manage tasks as markdown files, run them in isolated git worktrees with a choice of worker agent (Claude, codex, grok, or kimi), and track progress across the define/kickoff/continue/status/close lifecycle.", - "version": "1.11.0", + "description": "Generic task and worktree workflow system for Claude Code. Manage tasks as markdown files, run them in isolated git worktrees with a choice of worker agent (Claude, codex, grok, kimi, or a PATH-detected cc-harness agent), and track progress across the define/kickoff/continue/status/close lifecycle.", + "version": "1.12.0", "author": { "name": "gering" }, diff --git a/plugins/work-system/README.md b/plugins/work-system/README.md index 5534680..0f25736 100644 --- a/plugins/work-system/README.md +++ b/plugins/work-system/README.md @@ -28,7 +28,7 @@ Generic task and worktree workflow system for Claude Code. Manage tasks as markd | Command | Description | |---------|-------------| | `/define` | Create a new task (markdown file with Goal/Context/Requirements) | -| `/kickoff` | Start a task in an isolated git worktree, with a choice of worker agent (Claude/codex/grok/kimi) | +| `/kickoff` | Start a task in an isolated git worktree, with a choice of worker agent (Claude/codex/grok/kimi, or a PATH-detected cc-harness agent) | | `/adopt` | Adopt an existing branch into the work system | | `/continue` | Resume the current task (in a worktree); or `/continue ` from the main session reopens the task's herdr tab and resumes it | | `/status` | Check task status (PRs, branches, commits) | @@ -190,6 +190,7 @@ flag picks another: | `--grok` | grok-4.5 | | `--kimi` | kimi-code on k3-256k (launches in two phases — see below) | | `--agent ` | any registry entry, e.g. `--agent claude:sonnet` or `--agent codex` | +| `--agent cc-harness:` | foreign model *inside* the CC harness (only when `cc-harness-agents` is on PATH) | **The default is a single per-repo setting** — no global default, no shipped fallback. It lives in a committed `.claude/work-system-agent` file, so it travels @@ -199,18 +200,39 @@ in a repo with no default yet. Everything is registry-driven — no ranking, no call; the default is a simple, explicit choice (the hook where future task-aware routing can plug in). +**Optional cc-harness agents (PATH-detected).** When a `cc-harness-agents` helper +is on `PATH`, `/kickoff` offers its foreign agents — e.g. `cc-harness:grok`, +`cc-harness:kimi`, `cc-harness:sol`. These are full Claude Code sessions driven by +a foreign model via a local gateway (skills, lenses, `/continue`, `/close` all +work), not the native CLI voice. The picker keeps them **one page down**: page 1 is +the usual worker list plus a single `cc-harness agents ▸` entry, and only choosing +that opens a second page with the concrete agents — so the familiar path stays one +page and the harness list can grow freely. The plugin is a pure consumer of a small +contract (`list` / `exec`); it hardcodes no gateway, no models, no agent table. +Helper absent → one `command -v`, no aggregate entry, no change. Setup + contract: +[docs/cc-harness-agents.md](docs/cc-harness-agents.md). + **Non-Claude workers degrade honestly.** codex/grok/kimi have no work-system skills, so a launched worker gets a bootstrap prompt (read `TASK.md`, commit, open -a PR) instead of `/continue`. Everything git/PR-derived (`/status`, `/list`, the -`[ws]` statusline, `/close`'s tab teardown) works for any worker; only -claude-session concepts differ. `/continue`'s reopen **always sends `claude -c`** -— the work-system doesn't persist which worker a task used (per-task agent memory -is a later idea), so it can't dispatch per CLI. That resumes a claude worker; for -a codex/grok/kimi task it's a *new* Claude session, so you resume the real worker -yourself in the tab (`codex resume --last` / `grok -c` / `kimi -c`) — `/continue` -surfaces this caveat inline. Since codex and grok read `AGENTS.md`, dropping a -short `AGENTS.md` note into the worktree is an optional way to give them standing -task guidance. +a PR) instead of `/continue`. A `cc-harness:…` worker *runs* as a full CC session — +skills, lenses and `/close` all work, because the helper only routes the model. +Everything git/PR-derived (`/status`, `/list`, the `[ws]` statusline, `/close`'s +tab teardown) works for any worker; only claude-session concepts differ. + +**The one place every non-claude worker degrades is `/continue`'s reopen**, which +**always sends `claude -c`** — the work-system doesn't persist which worker a task +used (per-task agent memory is a later idea), so it can't dispatch per CLI: + +- **codex/grok/kimi** → a *new* Claude session, not your worker. Resume it + yourself in the tab: `codex resume --last` / `grok -c` / `kimi -c`. +- **cc-harness** → `claude -c` resumes the right transcript but **without the + gateway routing**, so the session silently continues on your default Claude + model instead of the foreign one. Resume it properly with + `cc-harness-agents exec -- claude -c`. + +`/continue` surfaces both caveats inline. Since codex and grok read `AGENTS.md`, +dropping a short `AGENTS.md` note into the worktree is an optional way to give +them standing task guidance. **kimi launches in two phases.** It has no positional launch prompt, and its one-shot `-p` flag can't be combined with the autonomous `--auto`/`-y` modes — so @@ -246,7 +268,8 @@ Inside herdr, `/kickoff` doesn't just create the worktree and print manual instructions — it opens a new herdr **tab** in the *same* workspace, with the worktree as its cwd, and starts the task there for you. `/adopt` does exactly the same once it has created the worktree from an existing branch — same helper, same -tab, same worker selection (`--opus`/`--sol`/`--grok`/`--kimi`/`--pick`, or the repo default); +tab, same worker selection (`--opus`/`--sol`/`--grok`/`--kimi`/`--pick`, a +[cc-harness agent](#worker-agent-selection), or the repo default); its tab label comes from the *resolved* task name, so it's sensible even when `/adopt` keeps the original branch name rather than renaming it to `task/`: diff --git a/plugins/work-system/docs/cc-harness-agents.md b/plugins/work-system/docs/cc-harness-agents.md new file mode 100644 index 0000000..9ade2d0 --- /dev/null +++ b/plugins/work-system/docs/cc-harness-agents.md @@ -0,0 +1,168 @@ +# CC-Harness foreign agents at `/kickoff` + +When a `cc-harness-agents` helper is on `PATH`, `/kickoff` offers its foreign +agents as workers — a foreign model running *inside* the Claude Code harness +(full skills, lenses, `/continue`, `/close`), routed through a local gateway. +When the helper is absent, behaviour is unchanged (one `command -v`). + +This page is the **plugin-side contract**. The helper itself is machine-local +(gateway URL, credentials, model ceilings) and is *not* shipped with the plugin. +A reference implementation lives in the paired dotfiles change that extracts the +helper as a PATH binary; anything that implements the two subcommands below is +enough for auto-detect. + +## Why a PATH helper (and not a shell function) + +Earlier idea "invoke the interactive `claude()` wrapper via `zsh -ic`" was +rejected: fragile, ties the plugin to zsh, interactive-shell side effects. A +shell-agnostic PATH binary keeps detection a cheap `command -v`, keeps the +launch argv-exec-native (no shell), and keeps all machine-local config +(gateway / creds / models / context ceilings) out of the public plugin. + +## Contract + +### `cc-harness-agents list` + +Probe without starting a session. Print one TSV row per foreign agent, **exactly +four columns**: + +``` +namemodelavailablenote +``` + +Example: + +``` +cc-harness:grok grok-4.5 yes - +cc-harness:sol gpt-5.6-sol no run: cliproxyapi -codex-login +``` + +- `name` is already namespaced (`cc-harness:`). +- `available` is `yes` | `no` | `unknown`. The plugin treats only the literal + `yes` as available; everything else is fail-closed for launch. +- `note` is `-` (or empty) when there is nothing to say; otherwise a short fix + hint the picker shows next to a greyed entry. +- Unavailable agents are listed too. Order is the helper's table order. +- Exit codes (list): + - `0` — the probe ran (regardless of per-agent availability). + - `3` — capability absent (no usable gateway token at all). The plugin treats + this as "helper not configured here" and silently adds no rows — distinct + from "configured, but this provider is not logged in" (`available=no`). + - `2` — usage error. + +> **Not the same shape as `agent-registry.sh list`.** The registry emits five +> columns (`name/cli/model/available/note`). The helper has no `cli` field; the +> plugin maps deliberately when merging. Do not feed helper rows into a +> five-column parser. + +### `cc-harness-agents exec [--] ` + +Set the routing environment for ``, then **`exec "$@"`** so the calling +process *becomes* the target (typically `claude`) — no lingering wrapper in the +process tree. That is load-bearing: herdr's agent-state detection and +work-system's `/close` teardown both key on the pane's root process being +`claude`. + +- `` accepts either form: `grok` or `cc-harness:grok`. +- No `--model` on the `claude` side — the helper sets `ANTHROPIC_MODEL` (and the + tier defaults / context ceiling) via env before the exec. +- Exit codes (exec, *before* the target runs): + - never returns on success (process replaced). + - `1` — requested agent not available (not logged in / gateway down). + - `2` — usage / unknown agent. + - `3` — capability absent (no token). +- Once the target is running, its own status comes back unchanged; a consumer + that needs a reliable availability answer asks `list`, which never execs. + +### What the plugin does with this + +| surface | behaviour | +|---------|-----------| +| `agent-registry.sh list` | if `command -v cc-harness-agents` succeeds, run `list` (bounded) and merge rows as `cli=cc-harness`; helper absent or exit 3 → no change | +| `agent-registry.sh resolve cc-harness:` | availability + note from the helper (no re-probe); argv = `cc-harness-agents exec -- claude [-n ] /work-system:continue` | +| `/kickoff` picker | harness rows labelled "foreign model in the Claude Code harness, routed via a local gateway"; unavailable greyed with the helper's fix hint; available first | +| lifecycle | runs as a full CC session → `/close` Scenario A/B and tab glyphs unchanged; `supports=` is the same set as a native claude worker. **Exception:** `/continue`'s reopen sends a bare `claude -c`, which resumes the transcript *without* the routing env — see below | +| default | a committed `cc-harness:` default is accepted when the helper lists it, and falls through to the picker when the helper is gone (same validation as a stale native name) | + +Nothing gateway-specific is hardcoded in the plugin. A sixth foreign model is a +new row in the helper's table — the plugin has no per-agent code path, which is +why the tests assert a name the plugin has never shipped (`cc-harness:never-seen`). + +### Known gap: `/continue` reopen loses the routing + +`herdr-launch.sh resume` always sends a bare `claude -c`, because the work-system +does not persist which worker a task used. For a harness task that resumes the +right transcript **on the wrong model**: without `cc-harness-agents exec` the +session has no `ANTHROPIC_BASE_URL`/`ANTHROPIC_MODEL`, so it silently continues on +the user's default Claude model. Nothing looks broken — which is why `/continue` +states it inline rather than claiming parity. + +Resume a harness worker by hand in the tab: + +```sh +cc-harness-agents exec -- claude -c +``` + +Closing this properly needs per-task worker persistence (a deliberate later idea), +not a change to the helper contract. + +## Setup sketch (reference) + +Exact install steps live with the helper. The shape is: + +1. Install and run a local Anthropic-compatible gateway (e.g. CLIProxyAPI) that + fronts the foreign providers, with a client token on disk. +2. Log each provider into the gateway so it holds OAuth / API credentials. +3. Put a `cc-harness-agents` binary on `PATH` that implements `list` / `exec` + against that gateway. +4. `/kickoff --pick` (or `agent-registry.sh list`) now shows the harness rows. + +Context ceilings differ per agent and are often plan-gated (e.g. a marketed 1M +window may serve 256k on the current plan). The helper owns that value; the +plugin never restates or assumes a window — another reason to stay a pure +consumer. + +## Security notes (for helper authors) + +- `exec` hands the gateway token to whatever argv it runs. Deny both the token + file *and* `cc-harness-agents exec` in Claude Code permission rules if you + don't want a session to exfiltrate the token; `list` is safe (prints no + secret). +- **Know what those deny rules do *not* cover**, or you will trust a boundary + that isn't there. They evaluate Claude Code **tool calls**, so they miss both + ends of the realistic path: + - **This plugin's own launch path is unaffected.** `/kickoff` calls + `agent-registry.sh resolve` (a command string containing no + `cc-harness-agents` token, so no rule matches), and `herdr-launch.sh` then + spawns `herdr agent start … -- cc-harness-agents exec -- claude …` + inside the herdr server — never as a Bash tool call the permission system + sees. That is by design (it is how the worker starts), but it means the rule + is not what stops a launch. + - **A worker session can still call it.** `exec` accepts arbitrary argv, so a + malicious instruction reaching a worker (e.g. via an `/adopt`-generated + `TASK.md`) can run `cc-harness-agents exec -- sh -c '…$ANTHROPIC_AUTH_TOKEN…'` + within the contract. + - Cron, a package postinstall, or any plain shell is outside Claude Code + entirely. Bash rules also match on command *text*, so an unusual spelling of + the path misses them. + + Net: the deny rules reduce casual exposure in an interactive session. The real + boundary is **who may execute the helper at all** (file permissions, PATH + hygiene) — treat installing it as granting user-equivalent code execution. +- An argv allow-list was considered and rejected: it would break the contract + ("run this routed"), and the sanctioned target `claude` can run arbitrary + commands itself. A helper author who wants a tighter boundary can pin `exec` + to a fixed `claude` target — the plugin only ever asks for that shape — at the + cost of the general contract. +- Gateway liveness should be a *plausibility* check that withholds the token + from a bare socket listener (e.g. an unauthenticated request must be + refused), not a bare TCP connect. +- **`list` output is rendered to users as authoritative hints.** The plugin + strips control characters and caps field length at ingest, but a helper should + not emit instruction-shaped notes: they are displayed, never executed. + +## Related + +- `scripts/agent-registry.sh` — PATH detect, list merge, resolve/emit_argv +- `skills/kickoff/SKILL.md` step 12 — picker presentation +- `.claude/knowledge/features/kickoff-agent-selection.md` — design decisions diff --git a/plugins/work-system/scripts/agent-registry.sh b/plugins/work-system/scripts/agent-registry.sh index b9b94eb..998328d 100755 --- a/plugins/work-system/scripts/agent-registry.sh +++ b/plugins/work-system/scripts/agent-registry.sh @@ -14,8 +14,9 @@ # Selectors: a shorthand flag (--fable, --opus, # --codex, --sol, --grok, --kimi), a # canonical name (claude:opus), a bare CLI -# (codex -> that CLI's default model), or -# cli:model (the --agent escape hatch). +# (codex -> that CLI's default model), cli:model +# (the --agent escape hatch), or cc-harness: +# when the optional PATH helper lists it. # Emits key=value lines incl. one `argv=` line # per exec word. Exit 3 if the entry's CLI is # unavailable (still prints available=no + note). @@ -33,11 +34,28 @@ # grok -> grok -m # kimi -> sh -c 'kimi -m "$1" -p "$2"; exec kimi -c --auto' \ # kimi-worker (seed+continue) +# cc-harness -> cc-harness-agents exec -- claude [-n ] \ +# /work-system:continue +# (foreign model *inside* the Claude Code harness, routed by a PATH +# helper — full CC session, so lifecycle skills work unchanged. No +# --model: the helper sets ANTHROPIC_MODEL via env before exec'ing. +# The helper `exec`s into claude, so the herdr pane roots at claude.) # The bootstrap prompt (codex/grok/kimi have no work-system skills) tells the # agent to read TASK.md and drive the task to a PR. `supports=` metadata records # which lifecycle hooks each agent honors, so /close and /continue can degrade # for non-claude workers instead of faking claude-only behavior. # +# Optional PATH helper `cc-harness-agents`. When present, `list` merges its rows; +# when absent, one `command -v` is the only cost and behaviour is unchanged. The +# helper owns gateway/creds/models — this registry never re-probes them. +# +# THE CONTRACT LIVES IN plugins/work-system/docs/cc-harness-agents.md — read it +# there. Restating the columns/exit codes here would be a second copy nothing +# checks for agreement, and a stale "4 cols" comment next to code parsing five is +# exactly what the next reader would trust. What this file needs to know is only: +# rows are namespaced TSV, a non-zero `list` means "no harness rows" (silent +# degrade), and `exec` re-roots the process at its argv. +# # Why kimi needs the two-phase seed+continue shape (all probed live, 0.31.1): # kimi has NO positional launch prompt (`kimi "text"` -> "unknown command"), no # initial-prompt env var, and piped stdin only prefills the input box without @@ -55,6 +73,9 @@ # State & config (override for tests / relocation): # WORK_SYSTEM_AGENT_PROJECT_STATE the repo's default-agent file # default: /.claude/work-system-agent +# WORK_SYSTEM_CC_HARNESS_AGENTS path to the optional cc-harness-agents +# helper; default: `cc-harness-agents` on PATH. +# Point it at a stub to test without PATH munging. # No global state and no shipped fallback — a repo with no default gets the # picker instead. # @@ -88,6 +109,13 @@ GROK_AUTH_FILE="${GROK_AUTH_FILE:-$HOME/.grok/auth.json}" # probing that path would report every authenticated install as logged out. KIMI_CREDENTIALS_FILE="${KIMI_CREDENTIALS_FILE:-$HOME/.kimi-code/credentials/kimi-code.json}" +# Optional foreign-model-in-CC-harness helper (PATH binary; absent = no-op). +# Overridable for tests that want a specific path without PATH munging. +HARNESS_BIN="${WORK_SYSTEM_CC_HARNESS_AGENTS:-cc-harness-agents}" +HARNESS_NS="cc-harness" +# Full CC session → same lifecycle hooks as a native claude worker. +HARNESS_SUPPORTS="continue,close-exit,statusline,commit,pr" + # The bootstrap prompt for CLIs without work-system skills (codex, grok, kimi). One # argv word; the launch helper passes it verbatim. BOOTSTRAP_PROMPT='Read TASK.md in this worktree and continue the task. Commit on the current branch as you go, and open a PR when the work is complete.' @@ -139,13 +167,154 @@ usage() { # Emit one `flag|cli|model|supports` record per line (skips blank lines). registry_rows() { printf '%s\n' "$REGISTRY"; } +# ---------- optional cc-harness-agents helper ---------- +# Discovery is a cheap `command -v`. The helper owns availability (gateway + +# per-provider creds) and the routing env; we never re-probe those here. +# Names are already namespaced by the helper (`cc-harness:grok`) and do NOT +# follow the native `cli:model` shape — the id is an agent key, not a model. +harness_on_path() { command -v "$HARNESS_BIN" >/dev/null 2>&1; } + +# Run `cc-harness-agents list` bounded. Prints its stdout; returns its exit +# code (0 = ok, 3 = capability absent, 124 = timed out, other = fail). Callers +# treat non-zero as "no harness rows" (silent degrade). +harness_list_raw() { + harness_on_path || return 1 + run_bounded 10 "$HARNESS_BIN" list 2>/dev/null +} + +# Split ONE helper TSV line into HR_NAME/HR_MODEL/HR_AVAIL/HR_NOTE. +# +# NOT `IFS=$'\t' read`: tab is an IFS *whitespace* character, so bash collapses +# consecutive tabs into one delimiter and an EMPTY cell silently shifts every +# later column — `nameyes-` (empty model) parses as model="yes", +# avail="-", which fail-closes a working agent to unavailable with a nonsense +# model; the mirror case (empty avail, note "yes") would mark an unavailable +# agent available, defeating harness_map_avail. Splitting explicitly keeps every +# position meaningful. A short field list simply leaves the tail empty. +harness_split_row() { + local rest="$1" f + HR_NAME=""; HR_MODEL=""; HR_AVAIL=""; HR_NOTE="" + for f in HR_NAME HR_MODEL HR_AVAIL; do + case "$rest" in + *$'\t'*) printf -v "$f" '%s' "${rest%%$'\t'*}"; rest="${rest#*$'\t'}" ;; + *) printf -v "$f" '%s' "$rest"; rest="" ;; + esac + done + HR_NOTE="$rest" +} + +# Neutralize an untrusted helper field before it reaches a terminal, the picker +# prompt, or a `resolve` consumer. The helper is third-party code on the PATH and +# its rows are rendered to the user as authoritative hints — the same risk class +# the `--session` guard already rejects control characters for. +# Strips ALL C0 controls + DEL (ANSI/ESC sequences, CR, embedded newlines that +# could forge extra key=value lines) and caps the length so a wall of text can't +# flood the picker. Byte-safe for UTF-8: every stripped byte is < 0x80 and can +# never be part of a multi-byte sequence. +# RESIDUAL, deliberately not handled here: Unicode bidi/zero-width overrides +# (U+200B–200F, U+202A–202E, U+2066–2069) survive — stripping them portably needs +# a multibyte-aware tool this bash-3.2 path cannot assume. The consuming skill +# therefore treats the note as untrusted display text, never as an instruction. +harness_sanitize() { + local s + s="$(printf '%s' "$1" | LC_ALL=C tr -d '\000-\037\177')" + printf '%s' "${s:0:200}" +} + +# Map a helper available cell to yes|no. Only the literal "yes" is available; +# "no", "unknown", empty, and anything else → no (fail closed for launch). +harness_map_avail() { + case "$1" in yes) printf 'yes' ;; *) printf 'no' ;; esac +} + +# Normalize helper note: a lone "-" means empty (the helper's "nothing to say"). +harness_map_note() { + case "$1" in -|"") printf '' ;; *) harness_sanitize "$1" ;; esac +} + +# True iff a helper row's name carries the required namespace. ONE definition, +# used by both the list builder and the lookup: when these disagree, a row can be +# invisible in `list`/`--json`/the picker yet still resolve, launch, and validate +# as a committed repo default — breaking the invariant that whatever `list` +# prints is exactly what can be chosen. +harness_row_in_ns() { + case "$1" in "$HARNESS_NS":*) return 0 ;; *) return 1 ;; esac +} + +# Look up one harness agent by selector (`cc-harness:grok`). Prints +# `name\tmodel\tavailable\tnote` (sanitized + mapped) on hit; returns 1 if the +# helper is absent, list fails, or the name is not listed. +harness_lookup() { + local want="$1" full raw hrc=0 line + full="$HARNESS_NS:${want#"$HARNESS_NS":}" + raw="$(harness_list_raw)" || hrc=$? + [ "$hrc" -eq 0 ] || return 1 + while IFS= read -r line; do + [ -n "$line" ] || continue + harness_split_row "$line" + harness_row_in_ns "$HR_NAME" || continue + # ONE canonical comparison. Earlier revisions also matched a bare id and a + # namespace-stripped form, which let a row `list` had rejected still resolve. + [ "$HR_NAME" = "$full" ] || continue + printf '%s\t%s\t%s\t%s\n' \ + "$(harness_sanitize "$HR_NAME")" "$(harness_sanitize "$HR_MODEL")" \ + "$(harness_map_avail "$HR_AVAIL")" "$(harness_map_note "$HR_NOTE")" + return 0 + done <<<"$raw" + return 1 +} + +# Append harness rows to the list builder as name\tcli\tmodel\tavail\tnote. +# Silent on helper-absent / exit-3 / timeout — today's behaviour is unchanged. +harness_append_list_rows() { + local raw hrc=0 line + raw="$(harness_list_raw)" || hrc=$? + [ "$hrc" -eq 0 ] || return 0 + while IFS= read -r line; do + [ -n "$line" ] || continue + harness_split_row "$line" + [ -n "$HR_NAME" ] || continue + # Same namespace gate as harness_lookup — see harness_row_in_ns. + harness_row_in_ns "$HR_NAME" || continue + printf '%s\t%s\t%s\t%s\t%s\n' \ + "$(harness_sanitize "$HR_NAME")" "$HARNESS_NS" "$(harness_sanitize "$HR_MODEL")" \ + "$(harness_map_avail "$HR_AVAIL")" "$(harness_map_note "$HR_NOTE")" + done <<<"$raw" +} + # Print the whole record for a canonical name (cli:model), or nothing. +# Also accepts a harness name (`cc-harness:grok`) when the helper lists it — +# used by `default get`/`set` validation so a committed harness default is +# accepted when the helper is present and rejected (→ picker) when absent. row_for_name() { local want="$1" flag cli model supports while IFS='|' read -r flag cli model supports; do [ -n "$cli" ] || continue [ "$cli:$model" = "$want" ] && { printf '%s|%s|%s|%s\n' "$flag" "$cli" "$model" "$supports"; return 0; } done < <(registry_rows) + case "$want" in + "$HARNESS_NS":*) + local hline + # Split via harness_split_row, NOT `IFS=$'\t' read`: harness_lookup's own + # output can carry an empty cell, and the collapsing read would shift the + # columns right back — the exact bug the splitter exists to prevent. + IFS= read -r hline < <(harness_lookup "$want") || return 1 + harness_split_row "$hline" + # EXISTENCE CHECK ONLY — the values are deliberately discarded. + # + # This record must NEVER reach emit_argv: its third field is the helper's + # DISPLAY model (grok-4.5), while emit_argv's cc-harness arm reads that + # slot as the bare agent ID (grok) for the helper's `exec `. Routing + # a harness selector through the native arm would therefore build + # `cc-harness-agents exec grok-4.5 -- claude …`, which the helper rejects as + # an unknown agent. row_for_selector returns 1 for `cc-harness:*` precisely + # to keep that from happening; subcmd_resolve has its own harness arm. + # Only validate_name / `default get` call this — both use the exit code. + # flag `-` = no shorthand (dynamic entries are name/--agent only). + printf -- '-|%s|%s|%s\n' "$HARNESS_NS" "$HR_MODEL" "$HARNESS_SUPPORTS" + return 0 + ;; + esac return 1 } @@ -170,10 +339,14 @@ row_for_cli_default() { # Resolve any selector to a registry record. Order: shorthand flag, canonical # cli:model name, bare CLI (its default model). Prints the record or fails (1). +# Harness selectors (`cc-harness:…`) are NOT resolved here — they don't fit the +# flag|cli|model|supports record (the name is an agent id, not cli:model). +# subcmd_resolve handles them via harness_lookup instead. row_for_selector() { local sel="$1" case "$sel" in --*) row_for_flag "$sel" && return 0 ;; + "$HARNESS_NS":*) return 1 ;; *:*) row_for_name "$sel" && return 0 ;; *) row_for_cli_default "$sel" && return 0 ;; esac @@ -352,9 +525,26 @@ shell_quote() { esac } +# Emit argv= lines + one argv_shell= line for an already-built words array. +# Shared by emit_argv (native CLIs) and the harness path so quoting lives once. +_emit_words() { + # Guard the expansion: under `set -u` a bash 3.2 `"${words[@]}"` on an EMPTY + # array is an unbound-variable error, which an unknown cli would hit. + [ "$#" -gt 0 ] || return 0 + printf 'argv=%s\n' "$@" + # A ready-to-paste command line. Quoting is load-bearing for kimi (`;`/`exec`) + # and for the harness wrapper (spaces in the session name). Skills print this + # verbatim instead of re-deriving it. + local shell_cmd="" w + for w in "$@"; do shell_cmd="$shell_cmd$(shell_quote "$w") "; done + printf 'argv_shell=%s\n' "${shell_cmd% }" +} + emit_argv() { # Print `argv=` lines for a resolved entry, then ONE `argv_shell=` line # with the same words shell-quoted. $1=cli $2=model $3=session. + # For cli=cc-harness, $2 is the bare agent id (e.g. grok), NOT a model slug — + # the helper sets ANTHROPIC_MODEL itself; we must not pass --model. local cli="$1" model="$2" session="$3" local words=() case "$cli" in @@ -374,19 +564,18 @@ emit_argv() { # flags or be absorbed by `-p`. words=(sh -c "$KIMI_LAUNCH_SCRIPT" kimi-worker "$model" "$BOOTSTRAP_PROMPT") ;; + cc-harness) + # Foreign model inside the CC harness. The helper sets the routing env + # then `exec`s into claude, so the herdr pane roots at claude (agent_status + # + /close Scenario B stay intact). No --model — the model is env-set. + # Bare id (not the namespaced form) for the helper's arg; it + # accepts both, but bare is the stable half of the contract. + words=("$HARNESS_BIN" exec "$model" -- claude) + [ -n "$session" ] && words+=(-n "$session") + words+=(/work-system:continue) + ;; esac - # Guard the expansion: under `set -u` a bash 3.2 `"${words[@]}"` on an EMPTY - # array is an unbound-variable error, which an unknown cli would hit. - [ "${#words[@]}" -gt 0 ] || return 0 - printf 'argv=%s\n' "${words[@]}" - # A ready-to-paste command line, quoted by printf %q rather than by whoever - # renders the manual-launch block. That rendering used to be a prose rule, and - # for kimi a mis-quote is not cosmetic: its argv carries `;` and `exec`, so an - # unquoted paste would replace the USER'S OWN interactive shell with an - # unattended agent. Skills print this verbatim instead of re-deriving it. - local shell_cmd="" w - for w in "${words[@]}"; do shell_cmd="$shell_cmd$(shell_quote "$w") "; done - printf 'argv_shell=%s\n' "${shell_cmd% }" + _emit_words "${words[@]+"${words[@]}"}" } subcmd_resolve() { @@ -410,12 +599,46 @@ subcmd_resolve() { done [ -n "$selector" ] || { echo "resolve: missing selector" >&2; exit 2; } + # Harness path: name is an agent id (cc-harness:grok), NOT cli:model. Availability + # comes from the helper — we never re-probe gateway/creds. Helper absent or the + # name not listed → "unknown selector" (exit 2), same as any other miss; an + # explicitly listed-but-unavailable agent still resolves and exits 3. + case "$selector" in + "$HARNESS_NS":*) + local hline bare + # harness_split_row, not `IFS=$'\t' read` — see row_for_name. + if ! IFS= read -r hline < <(harness_lookup "$selector"); then + # One shared line, arm-specific hint only — so the two paths can't drift + # into reporting the same condition with different wording. + echo "Unknown agent selector: $selector" >&2 + if harness_on_path; then + echo "Try: cc-harness-agents list (or a native flag/name/cli)" >&2 + else + echo "cc-harness agents require the \`cc-harness-agents\` helper on PATH — see plugins/work-system/docs/cc-harness-agents.md" >&2 + fi + exit 2 + fi + harness_split_row "$hline" + bare="${HR_NAME#"$HARNESS_NS":}" + printf 'name=%s\n' "$HR_NAME" + printf 'cli=%s\n' "$HARNESS_NS" + printf 'model=%s\n' "$HR_MODEL" + printf 'available=%s\n' "$HR_AVAIL" + printf 'supports=%s\n' "$HARNESS_SUPPORTS" + [ -n "$HR_NOTE" ] && printf 'note=%s\n' "$HR_NOTE" + # emit_argv takes the bare agent id in the model slot for this cli. + emit_argv "$HARNESS_NS" "$bare" "$session" + [ "$HR_AVAIL" = yes ] || exit 3 + return 0 + ;; + esac + local record record="$(row_for_selector "$selector")" || { echo "Unknown agent selector: $selector" >&2 # Derive the flag list from REGISTRY rather than restating it — a new entry # must not need a second edit here to appear in the hint. - echo "Try: $(registry_rows | cut -d'|' -f1 | grep -v '^-$' | tr '\n' ' ')— a name (claude:opus), or a cli (codex)" >&2 + echo "Try: $(registry_rows | cut -d'|' -f1 | grep -v '^-$' | tr '\n' ' ')— a name (claude:opus), a cli (codex), or cc-harness: when the helper is on PATH" >&2 exit 2 } local flag cli model supports @@ -444,12 +667,27 @@ subcmd_list() { esac # Build rows: name cli model available note (TAB-separated internally). + # Native registry first, then optional harness rows (available first is a + # presentation concern for the picker skill, not for this raw table — keep + # registry order stable so tests and consumers can rely on it). local rows="" flag cli model supports avail note while IFS='|' read -r flag cli model supports; do [ -n "$cli" ] || continue IFS=$'\t' read -r avail note < <(entry_status "$cli" "$model") rows+="$cli:$model $cli $model $avail $note"$'\n' done < <(registry_rows) + # Append harness rows when the helper is present and healthy. A missing + # helper, exit 3 (no token), or a timed-out list is a silent no-op — one + # `command -v` is the only cost of the absent case. + # + # Re-add the trailing newline `$( )` strips, and only when there ARE rows (an + # unconditional append would add a blank line). Without it the LAST harness + # row has no newline, and the human table's `while read` drops it — silently, + # and only in the table, since the --json path parses it fine. That is the + # picker's input, so a dropped row is an agent the user can never choose. + local harness_rows + harness_rows="$(harness_append_list_rows)" + [ -n "$harness_rows" ] && rows+="$harness_rows"$'\n' if [ -n "$as_json" ]; then command -v python3 >/dev/null 2>&1 || { echo "python3 required for --json" >&2; exit 1; } @@ -470,8 +708,14 @@ print() fi # Human table. Use column when present; else a plain TSV still renders. + # `|| [ -n "$name" ]` is the ONE guard against dropping a final line that lacks + # a trailing newline (`read` returns non-zero there even though it filled the + # variables). The newline-terminated accumulation above should make that + # unreachable — this keeps the table correct anyway if a future change to how + # `rows` is assembled stops terminating it, which is the failure that already + # cost a silently-unselectable agent once. { printf 'NAME\tCLI\tMODEL\tAVAILABLE\tNOTE\n' - printf '%s' "$rows" | while IFS=$'\t' read -r name cli model avail note; do + printf '%s' "$rows" | while IFS=$'\t' read -r name cli model avail note || [ -n "$name" ]; do printf '%s\t%s\t%s\t%s\t%s\n' "$name" "$cli" "$model" "$avail" "${note:--}" done } | { command -v column >/dev/null 2>&1 && column -t -s $'\t' || cat; } diff --git a/plugins/work-system/scripts/herdr-launch.sh b/plugins/work-system/scripts/herdr-launch.sh index c76fa04..a15e3f8 100755 --- a/plugins/work-system/scripts/herdr-launch.sh +++ b/plugins/work-system/scripts/herdr-launch.sh @@ -11,10 +11,13 @@ # the kickoff knowledge entry) — a fresh interactive shell # can eat the leading keystrokes of a typed command. # The worker argv is resolved from an agent SELECTOR via -# agent-registry.sh (claude/codex/grok/kimi × model); with no -# selector it stays the legacy `claude … /work-system:continue`. The +# agent-registry.sh (claude/codex/grok/kimi × model, plus +# optional PATH-detected cc-harness:); with no selector +# it stays the legacy `claude … /work-system:continue`. The # registry owns every per-CLI launch detail, so this script -# is CLI-agnostic — it just execs the resolved argv. +# is CLI-agnostic — it just execs the resolved argv (so a +# harness worker's `cc-harness-agents exec … -- claude …` is +# fine; argv[0] need not be claude). # # resume (/continue) — reopen a tab at the worktree and run `claude -c` INSIDE a # shell pane, then focus it. Because Claude runs inside a @@ -39,7 +42,8 @@ # workspace-id herdr workspace to open the tab in (e.g. $HERDR_WORKSPACE_ID) # agent-selector (launch only) agent-registry selector: a shorthand flag # (--fable/--opus/--codex/--sol/--grok/--kimi), a name -# (claude:opus), or a bare cli. Empty → legacy claude default. +# (claude:opus / cc-harness:grok), or a bare cli. Empty → +# legacy claude default. # session-name (launch only) `claude -n` name; defaults to