diff --git a/skills/codeman/SKILL.md b/skills/codeman/SKILL.md index 30945dda..2afdf5f1 100644 --- a/skills/codeman/SKILL.md +++ b/skills/codeman/SKILL.md @@ -138,14 +138,14 @@ spawn_worker() { # NOT retryable in a loop: every quick-start failure code is terminal (§5.1). [ -n "$sid" ] || { jq -c '{error,errorCode}' <<<"$q" >&2; return 1; } [ "$mode" = claude ] || { printf '%s\n' "$sid"; return 0; } # only claude draws a composer - # quick-start RESOLVES the name before creating: a linked case or an existing dir - # wins over a fresh scratch case, so "created => hooks" is only true after this one - # local grep (the same marker the server itself checks for). No marker means sendwait - # would false-resolve on flapping idle, possibly inside the user's REAL repo: refuse - # rather than run the job there. + # The server installs hooks into every claude workspace now, so this grep normally + # passes; it stays because the install is gated on a setting the operator can turn + # off, remote sessions never get hooks, and a session created by an older server + # still has none. No marker means sendwait would false-resolve on flapping idle, + # possibly inside the user's REAL repo: refuse rather than run the job there. cp=$(jq -r '.data.casePath // empty' <<<"$q") grep -qs '/api/hook-event' "$cp/.claude/settings.local.json" || { - echo "case '$name' resolved to '$cp', which has no Codeman hooks (linked or pre-existing?): pick an unused name, or work §5.1+§5.5 by hand" >&2 + echo "case '$name' resolved to '$cp', which has no Codeman hooks (workspaceHooksEnabled off, remote, or an older server?): turn the setting on, or work §5.1+§5.5 by hand with markers" >&2 delete_session "$sid" >/dev/null; return 1; } # Short composer wait FIRST, then the trust-dialog probe: a case still showing the # dialog can never pass the composer wait, so probing early keeps a cold case from @@ -343,7 +343,8 @@ Four things this block leans on, each one link away, no detour needed to run it: `~/codeman-cases/`, not your repo. A name that already means something (a linked case, a pre-existing directory) is refused by `spawn_worker` rather than silently reused. Spawning where the work actually is (a linked case, a git worktree) - is a different call with **no hooks**, and the costliest mistake in this skill: §5.1. + is a different call, and picking the wrong one is the costliest mistake in this + skill: §5.1. Those workspaces do get hooks now, unless the operator disabled it. - `sendwait` supplies the `\r`, picks a fresh `seq`, and self-heals a stranded Enter. A prompt without the `\r` is never submitted (§3), a reused `seq` is silently swallowed as an already-applied duplicate, and an Enter eaten by an Ink repaint @@ -358,9 +359,9 @@ One row per job. Acting on this table alone is correct; the §5 links are the de | I want to | Call | Detail | |-----------|------|--------| -| start a worker **where the work is** | `POST /api/v1/quick-start {"caseName":…}`, which **creates** `~/codeman-cases/` unless the name is already a case: full signals there. Any other path (a git worktree): `POST /api/v1/sessions {"workingDir":…}` then `POST /api/v1/sessions/:id/interactive`, and expect **no hooks**. N workers means N worktrees | [§5.1](reference/verbs.md#51-where-to-spawn) | +| start a worker **where the work is** | `POST /api/v1/quick-start {"caseName":…}`, which **creates** `~/codeman-cases/` unless the name is already a case. Any other path (a git worktree): `POST /api/v1/sessions {"workingDir":…}` then `POST /api/v1/sessions/:id/interactive`. Both install hooks by default, so expect full signals in either, and **verify** rather than assume. N workers means N worktrees | [§5.1](reference/verbs.md#51-where-to-spawn) | | know a new worker can accept a prompt | `GET .../wait-output?match=shift+tab&from=buffer` (urlencode the `+`) | [§5.2](reference/verbs.md#52-readiness) | -| deliver a task **and** know when it finished | `POST .../input` with `"input":"…\r"`, `clientId`, `seq`, `"wait":true`. Resolves on `stop`, so it is only trustworthy in a **case Codeman created** (claude mode + hooks present). Costs the worker one billed turn | [§5.3](reference/verbs.md#53-send-a-task-and-wait) | +| deliver a task **and** know when it finished | `POST .../input` with `"input":"…\r"`, `clientId`, `seq`, `"wait":true`. Resolves on `stop`, so it is trustworthy only where the workspace **has hooks** (claude mode; installed by default, but the operator can disable it and remote sessions never get them). Costs the worker one billed turn | [§5.3](reference/verbs.md#53-send-a-task-and-wait) | | know a hook-less worker finished | it has no `stop`, and `wait:true` there resolves on flapping `idle` **without erroring**: make it print a split, unique marker and `wait-output` on that instead | [§5.5](reference/verbs.md#55-markers-for-hook-less-workers) | | read the answer | `GET .../last-response`, **polled** (claude/codex only; empty for the other modes) | [§5.4](reference/verbs.md#54-read-the-answer) | | know if it is alive | `GET .../wait?until=exit&timeout=1000`: an immediate `signal:"exit"` means dead. `status` and `pid` both lie | [§5.6](reference/verbs.md#56-alive-and-stuck) | diff --git a/skills/codeman/preamble.sh b/skills/codeman/preamble.sh index a3aa4bf9..b69e0e04 100644 --- a/skills/codeman/preamble.sh +++ b/skills/codeman/preamble.sh @@ -60,14 +60,14 @@ spawn_worker() { # NOT retryable in a loop: every quick-start failure code is terminal (§5.1). [ -n "$sid" ] || { jq -c '{error,errorCode}' <<<"$q" >&2; return 1; } [ "$mode" = claude ] || { printf '%s\n' "$sid"; return 0; } # only claude draws a composer - # quick-start RESOLVES the name before creating: a linked case or an existing dir - # wins over a fresh scratch case, so "created => hooks" is only true after this one - # local grep (the same marker the server itself checks for). No marker means sendwait - # would false-resolve on flapping idle, possibly inside the user's REAL repo: refuse - # rather than run the job there. + # The server installs hooks into every claude workspace now, so this grep normally + # passes; it stays because the install is gated on a setting the operator can turn + # off, remote sessions never get hooks, and a session created by an older server + # still has none. No marker means sendwait would false-resolve on flapping idle, + # possibly inside the user's REAL repo: refuse rather than run the job there. cp=$(jq -r '.data.casePath // empty' <<<"$q") grep -qs '/api/hook-event' "$cp/.claude/settings.local.json" || { - echo "case '$name' resolved to '$cp', which has no Codeman hooks (linked or pre-existing?): pick an unused name, or work §5.1+§5.5 by hand" >&2 + echo "case '$name' resolved to '$cp', which has no Codeman hooks (workspaceHooksEnabled off, remote, or an older server?): turn the setting on, or work §5.1+§5.5 by hand with markers" >&2 delete_session "$sid" >/dev/null; return 1; } # Short composer wait FIRST, then the trust-dialog probe: a case still showing the # dialog can never pass the composer wait, so probing early keeps a cold case from diff --git a/skills/codeman/reference/endpoints.md b/skills/codeman/reference/endpoints.md index 75b66989..05566671 100644 --- a/skills/codeman/reference/endpoints.md +++ b/skills/codeman/reference/endpoints.md @@ -251,10 +251,12 @@ that is expected, not a failure: read `terminal?tail=` and strip ANSI instead. **It means** that session has no Codeman hooks, so `stop` can never fire and the wait silently degraded to `idle`, which flaps mid-turn. Nothing rejected your request: `wait:true` (and even an explicit `until=stop`) is accepted because the 400 is about -session **mode**, and the mode really is `claude`. Hooks are written only when Codeman -**creates** the directory; a linked case or a raw `workingDir` gets none (an existing -case that Codeman created earlier keeps the block it was given), see the table under -[Signals by mode](#signals-by-mode). Measured: on a +session **mode**, and the mode really is `claude`. Hooks are installed into every +claude workspace at session create (synced `workspaceHooksEnabled`, default ON) and +swept across recovered sessions at boot, so a linked case or a raw `workingDir` gets +them too; with the setting off, on a remote session, or on a session from an older +server, they are absent, see the table under +[Signals by mode](#signals-by-mode). Measured before that changed: on a linked case whose `.claude/settings.local.json` carries env/model/permissions/statusLine and no `hooks` block, a `wait?until=stop,exit` parked for twelve consecutive 60 s rounds never resolved although the worker finished its turn. @@ -360,10 +362,10 @@ loop. ⚠️ `caseName` resolves through the linked-cases registry first, so a name that happens to match a case the user linked in lands in that **real repo**, not a fresh scratch directory. Pick distinctive scratch names, and use a linked name deliberately when you -do want a worker in an existing checkout. ⚠️ It also decides whether you get hooks: -Codeman writes them only when it **creates** the directory, so a linked case or a raw -path gives you a worker with no `stop` signal, while a scratch case Codeman created -earlier keeps working signals ([Signals by mode](#signals-by-mode)). +do want a worker in an existing checkout. It no longer decides whether you get hooks: +every claude create path installs them, so a linked case and a raw path both get a +`stop` signal unless the operator turned `workspaceHooksEnabled` off +([Signals by mode](#signals-by-mode)). **The two-step alternative, `POST /api/v1/sessions`.** Use it when you need a session in a directory that is not a case (body takes `workingDir`, `mode`, `name`, `effort`, @@ -614,35 +616,27 @@ Three bounded long-polls. Shared semantics: | `exit` | PTY exited or session deleted | every mode | ⚠️ **`claude` mode is necessary for `stop`/`blocked`, not sufficient. The real -precondition is that the session's working directory has a Codeman hooks block**, and -whether it does depends on who created the directory: +precondition is that the session's working directory has a Codeman hooks block**, which +is now installed by default rather than depending on who created the directory: | The worker's directory | Hooks | `stop` / `blocked` | Synchronize with | |------------------------|-------|--------------------|------------------| -| Codeman created it (`quick-start` with a NEW `caseName`, `POST /api/cases`, clone, docker quickcreate) | written at create | fire | send-and-wait on `stop` | -| Codeman never created it (a linked case pointing at your own checkout, a raw `workingDir`) | none written | never fire | `wait-output` markers only | +| any claude workspace, with `workspaceHooksEnabled` ON (the default) | installed at session create, add-only merge | fire | send-and-wait on `stop` | +| the same, with the setting OFF and no block already on disk | none added | never fire | `wait-output` markers only | +| a remote SSH session, a docker case that opted out, a workspace Codeman cannot write | none | never fire | `wait-output` markers only | +| a session created by a pre-1.18.x server and never restarted since | whatever it had | only if present | check, then choose | -⚠️ **Docker cases are the one exception.** For a docker case, quick-start writes hooks -whenever `.claude/settings.local.json` is *missing* (`session-routes.ts:2836-2845`: -absent means write, present means refresh), regardless of who created that host -directory. There the discriminator really is "does the settings file exist". No -downstream advice changes, since docker quickcreate is already on the create side. +The install is an add-only merge, so a user's own hook entries survive and a malformed +settings file is left untouched. Sessions recovered at server boot get the same sweep, +which is what heals sessions created before this behavior existed. When in doubt, test +it rather than reason about it: grep for `/api/hook-event` in +`/.claude/settings.local.json`. -⚠️ For every non-docker case the discriminator is **who created the directory, not -whether it exists now**. A -scratch case Codeman created last week still has its hooks block on disk, so -`quick-start` against that existing name gets working `stop` signals. Only a directory -Codeman never created lacks them. When in doubt, test it rather than reason about it: -grep for `/api/hook-event` in `/.claude/settings.local.json`. - -`writeHooksConfig()` runs only on the create paths (`case-routes.ts:341`, `:520`, -`:869`, `ralph-routes.ts:318`, `session-routes.ts:2799` inside -`if (!existsSync(resolvedCasePath))`, `:2841` for docker). Quick-start against a -directory that already exists takes the else-if branch and calls -`refreshStaleCodemanHooks()`, which returns immediately when there is no -`settings.local.json` and again when the hooks it finds are not ours -(`hooks-config.ts:706-731`); it never *adds* a hooks block. `POST /api/cases/link` is -not on that list at all: it only records a name-to-path entry. See +Before 1.18.x, `writeHooksConfig()` ran only on the create paths and `quick-start` +against an existing directory called `refreshStaleCodemanHooks()`, which never *adds* a +block, so a linked case or a raw `workingDir` had no hooks at all. `POST +/api/cases/link` still only records a name-to-path entry; what changed is that the +session-create path installs hooks regardless of how the directory got there. See [symptom 8](#8-send-and-wait-resolves-instantly-with-signalidle-and-the-answer-is-last-turns). Default `until` set: `stop,idle,exit`. On non-claude modes the server silently drops diff --git a/skills/codeman/reference/messaging.md b/skills/codeman/reference/messaging.md index 3edea7d2..ac7e391c 100644 --- a/skills/codeman/reference/messaging.md +++ b/skills/codeman/reference/messaging.md @@ -196,12 +196,14 @@ idle: The contract an orchestrator follows for any fleet of two or more messaging workers. Every topology in the next section is this protocol plus a wiring diagram. -1. **Spawn with a name, and with hooks.** Use `quick-start` with `sessionName` (the - `--name` gate above), and let it **CREATE** the case. ⚠️ Linking does NOT install - hooks (`POST /api/cases/link` writes only the name-to-path entry), and neither does a - bare `POST /api/sessions`; a worker in a directory Codeman did not create has no - `stop`/`blocked` signals at all and every synchronization below degrades to output - markers. The discriminator is who created the directory, not whether it exists now. +1. **Spawn with a name, and confirm hooks.** Use `quick-start` with `sessionName` (the + `--name` gate above). Session create installs the hooks block into the workspace + whatever kind it is, so a linked case and a raw `POST /api/sessions` path both get + `stop`/`blocked` by default. ⚠️ Not unconditionally: the operator can turn + `workspaceHooksEnabled` off, remote SSH sessions never get hooks, and a session from + an older server may have none, and without them every synchronization below degrades + to output markers. Grep `/.claude/settings.local.json` for + `/api/hook-event` at spawn rather than inferring it from how the directory got there. 2. **Readiness before addressing.** Flow 1's ladder per worker, then the availability probe. A worker that fails the probe is an HTTP worker for the rest of the run; that is a routing decision, not an error. diff --git a/skills/codeman/reference/recipes.md b/skills/codeman/reference/recipes.md index 31d61e05..c5c4f985 100644 --- a/skills/codeman/reference/recipes.md +++ b/skills/codeman/reference/recipes.md @@ -492,9 +492,9 @@ What breaks if you use send-and-wait anyway: `wait:true` is accepted (the 400 is *mode*, not about hooks, and these are claude-mode sessions), so the call falls back to the default set's `idle`, which is a heuristic that flaps mid-turn. You get a "finished" answer for a turn still running, and `last-response` then hands you the *previous* -turn's text. The contrast is the lesson: a worker in a case Codeman created (Flow 1) has -the hooks, so `stop` there is definitive and free. In a worktree you pay one marker per -worker instead. +turn's text. The contrast is the lesson: a worker whose workspace carries the hooks +block (Flow 1, and by default any other workspace too) has a `stop` that is definitive +and free. Where the block is absent you pay one marker per worker instead. ```bash declare -A TOK diff --git a/skills/codeman/reference/verbs.md b/skills/codeman/reference/verbs.md index e44b4897..d00a8114 100644 --- a/skills/codeman/reference/verbs.md +++ b/skills/codeman/reference/verbs.md @@ -30,28 +30,40 @@ wrong directory.** `quick-start` with a new `caseName` does not find your repo: | Where the work is | Call | Hooks, and therefore signals | |-------------------|------|------------------------------| | a fresh scratch dir (throwaway experiments) | `POST /api/v1/quick-start {"caseName":"scratch-1","mode":"claude"}` with a **new** case name | Codeman creates the directory and **writes hooks**: `stop` and `blocked` fire, send-and-wait is trustworthy | -| a linked case (a real repo in the linked-cases registry) | same call with the linked name | **no hooks**, unless that repo already carries a Codeman hooks block from some earlier path. Check before relying on `stop` | -| any other absolute path, e.g. a git worktree you made | `POST /api/v1/sessions {"workingDir":"/abs/path","mode":"claude"}` then `POST /api/v1/sessions/:id/interactive` | **no hooks**: no `stop`, no `blocked`, synchronize with markers ([§5.5](#55-markers-for-hook-less-workers)) | +| a linked case (a real repo in the linked-cases registry) | same call with the linked name | **hooks installed at session create**, so `stop` fires here too. Not guaranteed: the operator can turn it off. Check | +| any other absolute path, e.g. a git worktree you made | `POST /api/v1/sessions {"workingDir":"/abs/path","mode":"claude"}` then `POST /api/v1/sessions/:id/interactive` | same: **hooks installed at session create**, subject to the same setting. Check | Read `.data.casePath` back from the `quick-start` response and check it is where you meant. `caseName` accepts letters, digits, `-` and `_` only, and it resolves through the linked-cases registry **first**, so a name that collides with something the user linked in lands in that real repo rather than a scratch dir. -**The rule is who created the directory.** Codeman writes hooks only where it created -the workspace itself: `quick-start` on a NEW case name, `POST /api/cases`, the repo -clone, the docker quick-create. Those hooks persist, so a scratch case created last -week still has them today. A directory that already existed when Codeman first pointed -at it never gets them: `POST /api/cases/link` writes only the name-to-path entry in -`linked-cases.json`, and quick-start into an existing path runs -`refreshStaleCodemanHooks()`, which by design returns immediately when there is no -Codeman hooks block to refresh. Source-verified by exhaustive call-site grep, and -measured: a worker in a linked case never resolved a parked `wait?until=stop,exit` -across twelve consecutive 60 s rounds, although it had finished its turn. +**The rule is a setting, not who created the directory.** Every claude create path +(`POST /api/sessions`, `POST /api/quick-start`, and quick-start's docker branch) now +installs the hooks block into the workspace, and the server sweeps the workspaces of +sessions it recovers at boot. So a linked case, a cloned repo and a hand-made git +worktree all get `stop`/`blocked`, not just a scratch case Codeman scaffolded. The +install is an **add-only merge**: a user's own hook entries and every other settings +key survive, and a malformed settings file is left alone. -**Check, do not assume.** Read `/.claude/settings.local.json` with your own -file tools and look for `/api/hook-event`. Present means `stop`/`blocked` will fire; -absent means they never will. +The gate is the synced **`workspaceHooksEnabled`** setting, **default ON** (an absent +key counts as ON). Turned OFF, the old behavior returns exactly: an existing Codeman +block is still refreshed when stale, but one is never added, and the boot sweep is +skipped. Three cases stay hook-less regardless: **remote SSH sessions** (their +`workingDir` is a path on another host), **docker cases that opted out**, and any +workspace Codeman cannot write to. + +Until this landed, hooks existed only where Codeman created the directory, and the +gap was invisible: a worker in a linked case never resolved a parked +`wait?until=stop,exit` across twelve consecutive 60 s rounds, although it had finished +its turn. If you are driving an older server, assume that older rule. + +**Check, do not assume.** This is now the load-bearing habit, because you cannot tell +from the call which way the setting is set, and an old session created before the fix +on a server that has not restarted still has nothing. Read +`/.claude/settings.local.json` with your own file tools and look for +`/api/hook-event`. Present means `stop`/`blocked` will fire; absent means they never +will, whatever kind of workspace it is. ⚠️ **The hook-less failure is silent, and it is the worst one in this skill.** `"wait":true` is still **accepted** on a hook-less claude session: the 400 you may be @@ -59,9 +71,10 @@ expecting is about session *mode*, not about hooks. With no `stop` to resolve on default signal set falls back to the heuristic `idle`, which flaps mid-turn, so send-and-wait returns "finished" while the worker is still working, and the `last-response` you read next hands you the **previous** turn's text. No error is -raised anywhere. In any workspace Codeman did not create, use markers -([§5.5](#55-markers-for-hook-less-workers)) and treat send-and-wait's answer as -unreliable. +raised anywhere. Hooks are installed by default now, so this is rarer than it was, but +the failure is unchanged when it happens: in any workspace whose settings file has no +`/api/hook-event`, use markers ([§5.5](#55-markers-for-hook-less-workers)) and treat +send-and-wait's answer as unreliable. Spawning at a raw path: @@ -238,11 +251,13 @@ fi ### 5.3 Send a task and wait -⚠️ **Precondition: this is the call to prefer only for a claude worker in a workspace -Codeman created**, because it is trustworthy only when the `stop` hook exists. On a -linked case or a raw path it is accepted, resolves on flapping `idle`, and reports a -turn as finished while it is still running, with no error anywhere. Check hooks first -([§5.1](#51-where-to-spawn)); where they are absent, use markers +⚠️ **Precondition: a claude worker whose workspace has the hooks block**, because +this is trustworthy only when the `stop` hook exists. Every claude create path installs +it by default now, so that is the normal case, but where it is absent (the setting off, +a remote session, an older server) the call is still accepted, resolves on flapping +`idle`, and reports a turn as finished while it is still running, with no error +anywhere. Check hooks first ([§5.1](#51-where-to-spawn)); where they are absent, use +markers ([§5.5](#55-markers-for-hook-less-workers)). It registers the waiter *before* typing,