diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 2139c41..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.1" + "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 77cea69..382bd0e 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..268a80b 100644 --- a/.claude/knowledge/architecture/manager-worker-orchestration.md +++ b/.claude/knowledge/architecture/manager-worker-orchestration.md @@ -2,7 +2,9 @@ title: "Manager/Worker Orchestration (design)" createdAt: 2026-07-18 createdFrom: "session: design-manager-worker-orchestration 2026-07-18" -pluginVersion: 1.8.1 +updatedAt: 2026-08-19 +updatedFrom: "session: 2026-08-19 (task/offer-cc-harness-agents-at-kickoff)" +pluginVersion: 1.12.0 prime: false --- @@ -20,8 +22,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 708fd0f..aa4fff9 100644 --- a/.claude/knowledge/features/herdr-kickoff-automation.md +++ b/.claude/knowledge/features/herdr-kickoff-automation.md @@ -1,10 +1,10 @@ --- title: "herdr /kickoff + /continue-reopen Automation" createdAt: 2026-06-24 -updatedAt: 2026-08-11 +updatedAt: 2026-08-19 createdFrom: "PR #17" -updatedFrom: "session: 2026-08-11 (herdr 0.7.5+ dual launch contract)" -pluginVersion: 1.11.1 +updatedFrom: "session: 2026-08-19 (task/offer-cc-harness-agents-at-kickoff)" +pluginVersion: 1.12.0 prime: false reindexedAt: 2026-07-12 --- @@ -39,8 +39,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 68c751d..ef8a60e 100644 --- a/.claude/knowledge/features/kickoff-agent-selection.md +++ b/.claude/knowledge/features/kickoff-agent-selection.md @@ -1,10 +1,10 @@ --- title: "Kickoff Agent Selection: registry, per-repo default, honest degradation" createdAt: 2026-07-17 -updatedAt: 2026-08-11 +updatedAt: 2026-08-16 createdFrom: "session: 2026-07-17 (task/kickoff-agent-selection)" -updatedFrom: "session: 2026-08-11 (herdr transport metadata + kimi seed stop)" -pluginVersion: 1.11.1 +updatedFrom: "session: 2026-08-16 (task/offer-cc-harness-agents-at-kickoff, rebased onto 1.11.1)" +pluginVersion: 1.12.0 prime: false --- @@ -15,32 +15,146 @@ 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`). +**Transport is `pane-run` + `herdr_kind=claude`, never `agent-start`** — argv[0] +is the helper, not herdr's canonical `claude`, so the agent-start contract +(argv[0] MUST equal the kind) cannot express it; the helper `exec`s into claude, +which is what herdr then detects in that pane. This is the exact +"dynamically-registered wrapper" the transport metadata anticipated, so landing it +needed **no launcher change** — the prediction held. + +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`. + +**Don't build this passage out further.** The fix is expected on the *helper's* +side, not here: a resume shim that lets `claude -c` / `claude --resume ` +restore their own routing, so a session started directly by herdr stays routed. +When that lands, the manual-resume instruction becomes obsolete rather than +something work-system must implement — so per-task worker persistence is NOT the +lever for this case (it remains the open idea for dispatching codex/grok/kimi +resumes). Verify the shim shipped before deleting the caveat. + +**Helper output is untrusted input.** Rows are sanitized at ingest (C0 controls + +DEL stripped, over-long values elided IN THE MIDDLE) 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. Middle +elision is a **shape** argument, not a measurement: helper notes read +" () — ", so the actionable half sits at the END and +tail-truncation would drop exactly it. Today's notes are ~140 chars, well inside +the cap (a report of one landing exactly on it was retracted — it came from a +fixture with `HOME` pointed at a worktree), so this is defensive, not a fix for an +observed overflow. 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 a third +time in the **renderer**: `column -t -s $'\t'` folds consecutive separators too, so +an empty cell still collapsed on screen after the split was fixed. Every empty cell +is placeholdered before `column` sees it. + +**The name is a KEY, not a label — and that is where the last regress lived.** +Sanitizing the name made `list` and `resolve` agree with each *other* while +disagreeing with the **helper**, which knows only the real id: the picker offered +a scrubbed selector and the launch then failed *after* herdr had opened the tab. +Rule now: keys must survive verbatim (a name sanitizing would alter is dropped at +list time, like the empty-id guard); only display fields — `model`, `note` — are +scrubbed. Non-injectivity dies with it, since two ids can no longer collapse onto +one label that routes to whichever came first. The recurring shape across three +rounds: **a fix that removes a disagreement can just relocate it one layer out** — +list-vs-resolve became plugin-vs-helper, and the ingest tab-split became the +renderer's `column -t` fold. + +**Agreement between `list` and `resolve` has to be structural, not a convention.** +Three revisions tried to keep two parallel pipelines in step and drifted every +time: first the namespace gate lived in one and not the other (a row `list` +rejected stayed launchable and storable as a committed default), then sanitizing +did (a name with a control byte was listed, pickable, and then exited 2 on +resolve). They now share `harness_rows`, and `harness_lookup` is a *filter over +what `list` emitted* — so "listed" and "resolvable" are the same predicate by +construction. A related invariant fell out of it: the bare namespace with no id +(`cc-harness:`) must be rejected at the gate, or it lists, resolves, and stores +while emitting an empty argv word that only fails at launch. + +## 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. Ownership extends to **how the worker reaches herdr** (1.11.1): each entry declares `herdr_mode=agent-start|pane-run` + `herdr_kind`, so the launcher never infers transport from a selector name or by parsing `argv[0]`. `agent-start` asserts argv[0] *equals* the kind (herdr's canonical executable) and hands the untouched -tail to `--kind`; `pane-run` is for wrappers that no native kind can express — kimi -today, and a dynamically-registered cc-harness entry (`pane-run` + -`herdr_kind=claude`) tomorrow, with no launcher change. An entry whose mode the -launcher does not know fails closed before anything is created. See -[[herdr-kickoff-automation]] for the launch-side contract. +tail to `--kind`; `pane-run` is for wrappers that no native kind can express — kimi, +and (since 1.12.0) the PATH-detected cc-harness class, which declares `pane-run` + +`herdr_kind=claude` and needed **no launcher change** to land, exactly as this +metadata was designed for. An entry whose mode the launcher does not know fails +closed before anything is created. See [[herdr-kickoff-automation]] for the +launch-side contract. ## 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 97ba1c0..8682db3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -52,6 +52,35 @@ entries are grouped per plugin, newest first. ## work-system +### 1.12.0 — 2026-08-16 +- `/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). +- Transport: the harness entry declares `herdr_mode=pane-run` + `herdr_kind=claude`, never `agent-start` — argv[0] is the helper, not herdr's canonical `claude`, so the agent-start contract (argv[0] MUST equal the kind) cannot express it; the helper `exec`s into claude, which is what herdr then detects in that pane. This is exactly the "dynamically-registered wrapper" case 1.11.1's transport metadata anticipated, so the class landed with **no launcher change**. +- 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. The fix belongs on the helper's side — a resume shim letting `claude -c`/`--resume` restore their own routing — so the workaround is deliberately kept minimal rather than grown into a work-system feature. +- 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 an over-long value is elided IN THE MIDDLE rather than tail-truncated (notes read " () — ", so the actionable half sits at the end; defensive, not a response to an observed overflow) — 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). +- **The helper's `name` is treated as a key, not a label.** It is handed back as `exec `, so it must survive verbatim: a name containing control characters or exceeding the length cap is now **dropped from the listing** instead of shown in a sanitized form. The previous round's sanitize-then-match made `list` and `resolve` agree with each other while disagreeing with the *helper* — the picker offered a scrubbed selector and the launch failed after herdr had already opened the tab. Only `model` and `note` (display-only) are scrubbed, and exact duplicate names are deduped so one visible row means one reachable agent. +- Helper output is bounded at **read** time (`head -c`), not by truncating an already-buffered capture — a multi-GB writer finishing inside the time bound used to be fully materialized in shell memory first. A byte cut can land mid-row, so a trailing partial line is dropped rather than parsed as a record. +- `list --tsv` joins `--json` as a machine-readable view, and the picker's python3-less fallback uses it instead of whitespace-splitting the `column -t` table — helper names and notes may contain spaces, which could file a harness row as native and drop the aggregate entirely. +- Invalid UTF-8 from the helper no longer aborts `list --json`: the filter reads bytes and decodes with replacement, so one bad cell can't take down the whole listing including every native row. +- The picker reserves the aggregate slot only when a harness row is actually **available**, so a set of only-unavailable harness agents cannot evict an available native CLI from page 1. +- Sanitizing returns through a variable instead of `$( )`, so the per-field subshell fork the parameter-expansion rewrite was meant to remove is actually gone; `row_for_name`'s harness arm no longer fabricates an unread record; the single-call-site rename wrapper is inlined. +- **`list` and `resolve` now share one pipeline**, so "listed" and "resolvable" are the same predicate by construction rather than a convention two functions promised to keep. They had drifted twice: the list path sanitized while the lookup matched the RAW name (a name carrying a control byte, or one past the length cap, was shown in the picker and then exited 2 on resolve), and the list path dropped rows the lookup accepted. `harness_lookup` is now a filter over the canonical rows `list` emits. +- The bare namespace `cc-harness:` with no id no longer passes the gate. It used to list, resolve, and store as a committed default while emitting an **empty argv word** that `herdr-launch` cannot distinguish from a dropped one — so the failure surfaced at launch instead of at selection. +- The human table survives an empty helper cell too. The tab-collapse trap was fixed at ingest first, but `column -t -s $'\t'` treats consecutive separators as one, so an empty model still collapsed *in the renderer* — MODEL showing the availability value, i.e. a working agent reading as unavailable. Every empty cell is now placeholdered before `column` sees it, and a test asserts the table (not just `--json`). +- One TSV splitter (`harness_split …`) serves the helper's 4-column rows, the registry's 5-column canonical rows, and the display loop — the non-collapsing rule lives once instead of being re-derived per call site. +- Helper output is now size-bounded (64 KiB), not just time-bounded: everything it prints is held in shell memory and rendered through the picker. Truncated in-shell rather than through `head -c`, which would SIGPIPE the helper and turn a chatty-but-successful run into an apparent failure. +- Picker: page 1's ordering is deterministic (available CLIs in `REGISTRY` order, then unavailable ones; `claude` never dropped) instead of leaving *which* CLI falls outside the 4-option cap to run-to-run improvisation, and page 2 got the same explicit cap-and-name-the-rest rule. `list --json` now has a documented fallback to the plain table, since `--json` hard-requires `python3` on a path that previously had no such dependency. +- Honesty fix: "no change when the helper is absent" was false — the page-1 grouping rule applies to everyone, because the 7 shipped entries already exceeded the option cap before this feature. Said plainly now. +- `subcmd_resolve`'s two arms share one record emitter, and the fabricated record `row_for_name` hands back for existence checks carries the bare agent id in the slot `emit_argv` would read, so the "must never reach emit_argv" guard is a second line of defence rather than the only thing standing between it and a wrong argv. +- Sanitizing uses parameter expansion instead of a `printf | tr` fork per field. +- 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.1 — 2026-08-12 - Fix automated `/kickoff` and `/adopt` worker launches under **herdr 0.7.5+ (incl. 0.8)**, which failed with `herdr error: unknown option: --workspace` → "did not return a pane id". `agent start` no longer places the agent; it starts one in an **already-open pane** (`agent start --kind --pane [-- ]`), with placement moved to a preceding `tab create`. The launcher now creates the task's final tab first and starts the worker in its root pane. - The contract is **feature-detected** from `herdr agent start --help` (read-only, bounded) rather than compared against a version — 0.7.x spans both contracts, so a version check would route half of it wrong. herdr 0.7.0–0.7.4 keeps its exact previous sequence, diagnostics and stdout contract; an unrecognizable `agent start` fails before creating a tab or starting anything. 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..1bfa8db 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 (for a non-Claude worker that resume is a *new* Claude session, and for a cc-harness worker it silently drops the foreign-model routing — `/continue` says so inline); 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 25393c6..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.1", + "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 3b00125..874c785 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 @@ -256,7 +278,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..bbdf421 --- /dev/null +++ b/plugins/work-system/docs/cc-harness-agents.md @@ -0,0 +1,194 @@ +# 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. +Without the helper there are no harness rows and no aggregate picker entry — one +`command -v` is the whole cost. That is not the same as "byte-identical to the old +picker": the page-1 grouping rule applies to everyone, because the shipped entries +already exceeded `AskUserQuestion`'s 4-option cap before this feature existed. + +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. +Anything on the PATH that implements the two subcommands below is enough for +auto-detect — the plugin neither knows nor cares which project the binary comes +from, so it can be relocated without touching anything here. + +## 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:`) and is a **key, not a label**: + the plugin hands it straight back as `exec `, so it must be representable + verbatim. A name containing control characters, or longer than 200 characters, + is **dropped from the listing** rather than shown in a cleaned-up form — a + scrubbed name would resolve in the picker and then fail at the helper, after + herdr had already opened the tab. Exact duplicates are deduped (first wins). + `model` and `note` are display-only and *are* scrubbed. +- `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, and since work-system 1.11.1 it is load-bearing +**at launch time**, not just later: a harness entry declares +`herdr_mode=pane-run` + `herdr_kind=claude`, so the launcher sends the command into +a pane and then polls until herdr detects `claude` *in that exact pane*. A helper +that forks instead of `exec`ing leaves the wrapper as the pane's root process, that +poll never succeeds, and the launch reports `blocked=unverified` — the tab exists +but the worker is never confirmed. herdr's agent-state detection and `/close` +teardown key on the same fact afterwards. + +- `` 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 in **time and output size**) and merge rows as `cli=cc-harness`; helper absent or exit 3 → no rows. `--json` and `--tsv` are the machine-readable views; the plain table is display-only | +| `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 +``` + +This is expected to be fixed **on the helper's side**, by a resume shim that lets +`claude -c` / `claude --resume ` restore their own routing — at which point the +manual form above becomes unnecessary and this section can go. It is not something +the plugin needs to implement, and it needs no change to the contract below. + +## 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. +- **Detection executes the helper.** `command -v cc-harness-agents` succeeding is + enough for `/kickoff` to *run* `list` — before any picker or confirmation, on + every `--pick` or default-less kickoff. That is the price of auto-detection, and + it is the same posture as the other workers (`codex login status`, `grok models` + and `kimi provider list` are probed the same way). Installing this helper, or + letting an untrusted directory sit early on your `PATH`, is granting + user-equivalent code execution — an attacker who can place a binary there does + not need this plugin to run it. No opt-in flag guards this, deliberately: a flag + would defeat auto-detect while adding no boundary an attacker with PATH write + does not already have. +- **`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 fbf67d2..39f7fd3 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,24 @@ # grok -> grok -m # kimi -> sh -c 'if ; then exec kimi -c --auto; else ; fi' \ # kimi-worker (seed+continue) +# cc-harness -> cc-harness-agents exec -- claude [-n ] \ +# /work-system:continue +# (foreign model *inside* the CC harness, routed by an optional PATH +# helper — a full CC session, so lifecycle skills work unchanged. No +# --model: the helper env-sets it, then `exec`s into claude. argv[0] +# is the helper, so this entry is pane-run/kind=claude, never +# agent-start — see the transport note below.) # 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 rather than restating it here, where nothing checks the copies agree. +# # HERDR TRANSPORT metadata (`herdr_mode=` / `herdr_kind=`). # herdr 0.7.5+ starts agents in an ALREADY-OPEN pane (`agent start --kind # --pane -- `) instead of placing them itself. That needs @@ -75,6 +89,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. # @@ -114,6 +131,21 @@ 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" +# Transport: `pane-run`, never `agent-start`. The argv is a WRAPPER +# (`cc-harness-agents exec -- claude …`), so argv[0] is the helper, not +# herdr's canonical `claude` executable — the agent-start contract (argv[0] MUST +# equal the kind) cannot express it. The helper `exec`s into claude, so herdr +# detects kind=claude in that pane once it is up. This is exactly the +# "dynamically-registered wrapper" case the transport metadata was designed for. +HARNESS_HERDR_MODE="pane-run" +HARNESS_HERDR_KIND="claude" + # 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.' @@ -197,6 +229,201 @@ usage() { # Emit one `flag|cli|model|supports|herdr_mode|herdr_kind` record per line. 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). +# Everything the helper prints is held in shell memory and rendered through the +# picker, so the SIZE needs a bound too — run_bounded caps only runtime. Truncated +# in-shell rather than via `head -c`: a pipe would SIGPIPE the helper and turn a +# successful-but-chatty run into an apparent failure, i.e. a silent no-op. +HARNESS_MAX_BYTES=65536 +harness_list_raw() { + harness_on_path || return 1 + local tmp rc + tmp="$(mktemp)" || return 1 + # Bound at READ time. Capturing first and truncating after (`${out:0:N}`) still + # buffers the helper's ENTIRE stdout in shell memory, so a multi-GB writer that + # finishes inside the time bound OOMs the session before one byte is discarded. + # `head -c` closes the pipe at the limit instead. It also counts BYTES; the + # substring form counted characters, which is a different limit in a UTF-8 + # locale. errexit/pipefail are lifted for the pipeline so PIPESTATUS can be + # read; 141 is head closing the pipe (SIGPIPE) on a truncated-but-fine run and + # must not read as a helper failure. + set +e +o pipefail + run_bounded 10 "$HARNESS_BIN" list 2>/dev/null | head -c "$HARNESS_MAX_BYTES" >"$tmp" + rc=${PIPESTATUS[0]} + set -e -o pipefail + case "$rc" in + 0|141) ;; + *) rm -f "$tmp"; return "$rc" ;; + esac + # A byte cut can land mid-row, so drop a trailing partial line rather than + # letting harness_rows parse half a record as a real one. + if [ -s "$tmp" ] && [ "$(tail -c 1 "$tmp" | od -An -c | tr -d ' ')" != "\\n" ]; then + sed '$d' "$tmp" + else + cat "$tmp" + fi + rm -f "$tmp" +} + +# harness_split ... — split LINE on TABs into the named variables in +# order; the LAST variable absorbs whatever remains (so a trailing note may itself +# contain tabs). Used for BOTH the helper's 4-column raw rows and this file's +# 5-column canonical rows, so the rule lives once. +# +# 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. A short field list simply leaves +# the tail empty. +harness_split() { + local rest="$1"; shift + local v + while [ "$#" -gt 1 ]; do + v="$1"; shift + case "$rest" in + *$'\t'*) printf -v "$v" '%s' "${rest%%$'\t'*}"; rest="${rest#*$'\t'}" ;; + *) printf -v "$v" '%s' "$rest"; rest="" ;; + esac + done + printf -v "$1" '%s' "$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). Byte-safe for UTF-8: every stripped byte is +# < 0x80 and can never be part of a multi-byte sequence. +# +# Over-long values are ELIDED IN THE MIDDLE, never tail-truncated. Helper notes +# are shaped " () — ", so the ACTIONABLE half sits +# at the END: tail-truncation would leave the user knowing something is broken +# and not what to do, while head+tail loses only the middle of a path — the least +# load-bearing part. This is defensive, not a fix for an observed overflow: the +# current helper's longest note measures ~140 chars, comfortably inside the cap. +# (An earlier report of a note landing exactly on the cap was retracted — it came +# from a fixture with HOME pointed at a worktree; real notes derive the path from +# $HOME.) The shape argument holds regardless of today's lengths, which is why +# the elision stays. +# +# 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_FIELD_MAX=200 +# Result lands in HS_OUT, not on stdout: every call site is per-field per-row, and +# `$(harness_sanitize …)` forks a subshell each time — which is the cost the +# parameter-expansion rewrite was supposed to remove, so returning through stdout +# would have kept the fork it claimed to delete. +harness_sanitize() { + local s head tail keep + # Parameter expansion, not `printf | tr`: `[[:cntrl:]]` covers C0 + DEL and + # leaves UTF-8 multi-byte sequences intact (verified on bash 3.2). + s="${1//[[:cntrl:]]/}" + if [ "${#s}" -le "$HARNESS_FIELD_MAX" ]; then HS_OUT="$s"; return 0; fi + keep=$(( HARNESS_FIELD_MAX - 3 )) + tail=$(( keep / 2 )) + head=$(( keep - tail )) + HS_OUT="${s:0:head}...${s: -tail}" +} + +# 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"). +# Same HS_OUT contract as harness_sanitize (see there). +harness_map_note() { + case "$1" in -|"") HS_OUT="" ;; *) 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 + # The bare prefix with NO id is not an agent: it used to list, resolve, and + # store as a committed default while emitting an EMPTY argv word, which + # herdr-launch cannot tell from a dropped one — so the failure surfaced at + # launch instead of at selection. Require at least one char after the ns. + "$HARNESS_NS":?*) return 0 ;; + *) return 1 ;; + esac +} + +# THE one normalization pipeline: raw helper rows -> canonical +# `name\tcli\tmodel\tavail\tnote`, sanitized and namespace-gated. Both `list` +# and `resolve` consume THIS, never the raw output, so the name shown in the +# picker is byte-for-byte the selector that resolves. +# +# Two separate pipelines drifted apart twice before this was unified: `list` +# sanitized while the lookup matched RAW (a name carrying a control byte was +# listed and pickable but exited 2 on resolve), and `list` dropped empty names +# while the lookup accepted them. Agreement has to be structural, not a +# convention two functions promise to keep. +harness_rows() { + local raw hrc=0 line seen=$'\n' + raw="$(harness_list_raw)" || hrc=$? + [ "$hrc" -eq 0 ] || return 0 + while IFS= read -r line; do + [ -n "$line" ] || continue + harness_split "$line" HR_NAME HR_MODEL HR_AVAIL HR_NOTE + # THE NAME IS A KEY, NOT A LABEL — it is handed straight back to the helper + # as `exec `. So it must survive verbatim: sanitizing it (as an earlier + # revision did) made `list` and `resolve` agree with each OTHER while + # disagreeing with the HELPER, which knows only the real id — the launch then + # failed after herdr had already opened the tab. Reject any name sanitizing + # would alter instead, exactly like the empty-id guard: an unrepresentable id + # is not a usable selector, and dropping it at list time is the honest + # failure. Non-injectivity dies with it — two ids can no longer collapse into + # one label that routes to whichever came first. + harness_sanitize "$HR_NAME" + [ "$HS_OUT" = "$HR_NAME" ] || continue + harness_row_in_ns "$HR_NAME" || continue + # Exact duplicates from the helper would still shadow each other; keep the + # first and drop the rest so one visible row means one reachable agent. + case "$seen" in *$'\n'"$HR_NAME"$'\n'*) continue ;; esac + seen="$seen$HR_NAME"$'\n' + # model/note are DISPLAY ONLY — never keys — so they are scrubbed, not gated. + harness_sanitize "$HR_MODEL"; local dmodel="$HS_OUT" + harness_map_note "$HR_NOTE"; local dnote="$HS_OUT" + printf '%s\t%s\t%s\t%s\t%s\n' \ + "$HR_NAME" "$HARNESS_NS" "$dmodel" \ + "$(harness_map_avail "$HR_AVAIL")" "$dnote" + done <<<"$raw" +} + +# Filter harness_rows down to ONE agent; prints that canonical row (5 fields) or +# returns 1. Matching the FIRST field of an already-canonical row is what makes +# "listed" and "resolvable" the same predicate by construction. +harness_lookup() { + local want="$1" full line name + full="$HARNESS_NS:${want#"$HARNESS_NS":}" + while IFS= read -r line; do + name="${line%%$'\t'*}" + [ "$name" = "$full" ] || continue + printf '%s\n' "$line" + return 0 + done < <(harness_rows) + return 1 +} + # find_row — the ONE reader over the registry table. `how` picks what # `want` is matched against: `name` (canonical cli:model), `flag` (shorthand, `-` # rows skipped), or `cli` (first row of that CLI = its default model). Prints the @@ -224,16 +451,40 @@ find_row() { return 1 } -row_for_name() { find_row name "$1"; } row_for_flag() { find_row flag "$1"; } row_for_cli_default() { find_row cli "$1"; } +# Canonical name lookup. 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() { + find_row name "$1" && return 0 + case "$1" in + "$HARNESS_NS":*) + # EXISTENCE CHECK ONLY, and deliberately output-free. Both callers + # (validate_name, `default get`) branch on the exit code and discard stdout, + # and row_for_selector returns 1 for `cc-harness:*` so no record of ours can + # reach emit_argv. An earlier revision fabricated a pipe record here whose + # model slot meant something different from every other record's — a trap + # kept harmless only by a comment. Emitting nothing removes the trap instead + # of documenting it. + harness_lookup "$1" >/dev/null || return 1 + return 0 + ;; + esac + return 1 +} + # 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 — the name is an agent +# id, not cli:model, and the fabricated record's model slot means something else +# on that path (see row_for_name). subcmd_resolve handles them via harness_lookup. 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 @@ -403,6 +654,15 @@ 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. $2 is the BARE agent id (grok), not + # a model slug — the helper env-sets the model, so no --model here. It then + # `exec`s into claude, which is why the entry declares herdr_kind=claude + # (via pane-run: argv[0] is the helper, so agent-start cannot express it). + 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. @@ -418,6 +678,27 @@ emit_argv() { printf 'argv_shell=%s\n' "${shell_cmd% }" } +# Emit one resolved entry's key=value record, then its argv. ONE definition — +# the harness arm and the native arm differ only in where the values come from, +# and a second copy is where a later field lands in one path and not the other. +# $1=name $2=cli $3=model $4=available $5=supports $6=herdr_mode $7=herdr_kind +# $8=note $9=argv-model (the emit_argv slot: the bare agent id for cc-harness, +# the model slug for everyone else) ${10}=session +emit_record() { + printf 'name=%s\n' "$1" + printf 'cli=%s\n' "$2" + printf 'model=%s\n' "$3" + printf 'available=%s\n' "$4" + printf 'supports=%s\n' "$5" + # Modern-herdr transport (see the header). Always emitted — a consumer that + # cannot interpret the mode must fail closed rather than guess from the name. + printf 'herdr_mode=%s\n' "$6" + printf 'herdr_kind=%s\n' "$7" + [ -n "$8" ] && printf 'note=%s\n' "$8" + emit_argv "$2" "$9" "${10}" + [ "$4" = yes ] || exit 3 +} + subcmd_resolve() { local selector="" session="" while [ $# -gt 0 ]; do @@ -439,12 +720,42 @@ 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, 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 "$hline" HR_NAME HR_CLI HR_MODEL HR_AVAIL HR_NOTE + bare="${HR_NAME#"$HARNESS_NS":}" + # `$bare` (not $HR_MODEL) is the emit_argv slot: this cli's argv needs the + # helper's agent id, while `model=` still reports the display model. + emit_record "$HR_NAME" "$HARNESS_NS" "$HR_MODEL" "$HR_AVAIL" \ + "$HARNESS_SUPPORTS" "$HARNESS_HERDR_MODE" "$HARNESS_HERDR_KIND" \ + "$HR_NOTE" "$bare" "$session" + 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 mode kind @@ -453,25 +764,20 @@ subcmd_resolve() { local avail note IFS=$'\t' read -r avail note < <(entry_status "$cli" "$model") - printf 'name=%s\n' "$cli:$model" - printf 'cli=%s\n' "$cli" - printf 'model=%s\n' "$model" - printf 'available=%s\n' "$avail" - printf 'supports=%s\n' "$supports" - # Modern-herdr transport (see the header). Always emitted — a consumer that - # cannot interpret the mode must fail closed rather than guess from the name. - printf 'herdr_mode=%s\n' "$mode" - printf 'herdr_kind=%s\n' "$kind" - [ -n "$note" ] && printf 'note=%s\n' "$note" - emit_argv "$cli" "$model" "$session" - - [ "$avail" = yes ] || exit 3 + emit_record "$cli:$model" "$cli" "$model" "$avail" "$supports" \ + "$mode" "$kind" "$note" "$model" "$session" } subcmd_list() { - local as_json="" + local as_json="" as_tsv="" case "${1:-}" in --json) as_json=1 ;; + # Machine-readable without python3. `--json` is the picker's first choice but + # exits 1 when python3 is missing, and the fallback must NOT be "split the + # column -t table on whitespace" — that is the ambiguous parse the JSON path + # exists to avoid (helper names and notes may contain spaces). The rows are + # already unpadded TSV internally; this just prints them. + --tsv) as_tsv=1 ;; "") ;; *) echo "Unknown flag: $1" >&2; exit 2 ;; esac @@ -483,13 +789,28 @@ subcmd_list() { 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 hrows + hrows="$(harness_rows)" + [ -n "$hrows" ] && rows+="$hrows"$'\n' if [ -n "$as_json" ]; then command -v python3 >/dev/null 2>&1 || { echo "python3 required for --json" >&2; exit 1; } printf '%s' "$rows" | python3 -c ' import json, sys out = [] -for line in sys.stdin: +# Read BYTES and decode leniently: harness_sanitize strips control characters +# but not invalid UTF-8, and a text-mode read raises on the first bad byte — +# killing the ENTIRE listing, native rows included, over one bad helper cell. +for line in sys.stdin.buffer.read().decode("utf-8", "replace").splitlines(): line = line.rstrip("\n") if not line: continue @@ -502,10 +823,37 @@ print() return fi + if [ -n "$as_tsv" ]; then + printf '%s' "$rows" + return + fi + # Human table. Use column when present; else a plain TSV still renders. + # Human table — DISPLAY ONLY. The picker classifies from `list --json` (see + # skills/kickoff/SKILL.md step 12), because this output is padded through + # `column -t` and note cells contain spaces. + # + # Split with harness_split, not `IFS=$'\t' read`: a harness row may carry an + # empty model, and the collapsing read shifted every later column — MODEL + # rendering as the availability value, which reads as "unavailable" for an + # agent that is fine. The ingest was fixed for this first; the display loop is + # the same trap one layer later. + # + # `|| [ -n "$line" ]` keeps a final line lacking a trailing newline (`read` + # returns non-zero there even though it filled the variable). The + # newline-terminated accumulation above should make it unreachable — it keeps + # the table correct anyway if a future change stops terminating `rows`, which + # 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\t%s\t%s\t%s\t%s\n' "$name" "$cli" "$model" "$avail" "${note:--}" + printf '%s' "$rows" | while IFS= read -r line || [ -n "$line" ]; do + [ -n "$line" ] || continue + harness_split "$line" _n _c _m _a _note + # Placeholder EVERY empty cell, not just the note: `column -t -s $'\t'` + # treats consecutive separators as ONE, so an empty model would collapse in + # the renderer even though the split above kept it — the same column shift, + # one layer further out (MODEL showing the availability value). + printf '%s\t%s\t%s\t%s\t%s\n' \ + "${_n:--}" "${_c:--}" "${_m:--}" "${_a:--}" "${_note:--}" done } | { command -v column >/dev/null 2>&1 && column -t -s $'\t' || cat; } } @@ -543,6 +891,12 @@ subcmd_default() { # so a stale/removed/garbage (or attacker-supplied) name must NOT route the # launch: an unknown name is treated as "no default" → the caller shows the # picker, rather than failing every kickoff on a bogus committed value. + # A `cc-harness:` default is validated against the LIVE helper (native + # names check the in-file REGISTRY), so this can cost a bounded subprocess + # and, if the helper is transiently down, reports "no default" — which the + # caller turns into the picker rather than a wrong launch. That is the + # intended fail-closed direction, but it is neither free nor silent: say so + # where a reader would otherwise assume a pure file read. local v; v="$(_kv_get "$PROJECT_STATE" default)" if [ -n "$v" ] && row_for_name "$v" >/dev/null 2>&1; then printf '%s\n' "$v"; fi ;; diff --git a/plugins/work-system/scripts/herdr-launch.sh b/plugins/work-system/scripts/herdr-launch.sh index b5460de..6df6900 100755 --- a/plugins/work-system/scripts/herdr-launch.sh +++ b/plugins/work-system/scripts/herdr-launch.sh @@ -69,7 +69,8 @@ # the plain label (see the stamping block below). # worktree absolute path to the worktree (becomes the new pane's cwd) # workspace-id herdr workspace to open the tab in (e.g. $HERDR_WORKSPACE_ID) -# agent-selector (launch only) agent-registry selector: a shorthand flag +# agent-selector (launch only) agent-registry selector: cc-harness: (an +# optional PATH-detected wrapper entry), a shorthand flag # (--fable/--opus/--codex/--sol/--grok/--kimi), a name # (claude:opus), or a bare cli. Empty → legacy claude default. # session-name (launch only) `claude -n` name; defaults to