Skip to content

v0.11.0 — the flow engine, the policy hook, and a six-tool MCP surface - #190

Merged
firstintent merged 56 commits into
mainfrom
dev
Sep 4, 2026
Merged

v0.11.0 — the flow engine, the policy hook, and a six-tool MCP surface#190
firstintent merged 56 commits into
mainfrom
dev

Conversation

@firstintent

@firstintent firstintent commented Aug 30, 2026

Copy link
Copy Markdown
Owner

Release PR for v0.11.0 — the post-v0.10.5 cycle. 58 commits, 285 files, +29981 / −19255 since v0.10.5.

Three tracks landed in this cycle, plus the governance move.

1. ccteam flow — the dynamic workflow engine

ccteam-flow is rebuilt as a runner core: a user-written JS script (executed in a QuickJS child process) drives real cross-harness hires through the existing MCP surface. The script API is agent() / parallel() / pipeline() / phase() / log() / args / budget — deliberately the same shape as Claude Code's workflows, so a script moves over with no new vocabulary.

  • Every leaf is a real session on the ledger; sid follow-ups reuse context, role behaves like an agent type.
  • Durable: each run journals under ~/.ccteam/runs/<id>, and the workers outlive the runner — --resume <run-dir> replays the journal and re-attaches to hires still in flight instead of re-hiring them. A daemon or machine restart does not lose a run.
  • Explicit brakes: --parallel (32), --max-agents (100), --max-cost / --budget, --watchdog.
  • CLI: flow new (scaffold + print the script API), flow run, flow eval (grade a finished run with an evaluator flow — the evaluation half of the loop).
  • Runs are attributed to the managed session that launched them ($CCTEAM_CHAT_SID, --parent to override), recorded on progress.jsonl as run-envelope kinds and read back over GET /api/v1/projects/{slug}/flow-runs.
  • Web: the Team page gains a third tab (编排 / Runs) — paginated by run rather than by journal row, expandable to the sessions a run actually hired, and an on-ramp (three copyable commands) when there are no runs yet.

The runner is not a second engine: it is a scripted caller of the same tools an agent uses, so the delegation red lines hold unchanged.

2. The pre-agent policy hook — a programmable guardrail

<project>/.ccteam/hooks/pre-agent, falling back to ~/.ccteam/hooks/pre-agent; neither present means the call proceeds. Any executable, re-exec'd on every delegation, so editing the file is the whole deployment step — nothing to register, nothing to restart. A project that states a policy states all of it (replaces, never merges — the routing.md rule). ccteam init never seeds one.

  • stdin is one line of JSON: caller, request, counts, and the live per-harness quota map — so a policy needs no token and no callback into the daemon.
  • Exit 0 allows; exit 2 denies and relays your stderr verbatim (≤2000 B) to the calling agent; anything else, a timeout, or a non-executable file is a distinct policy_script_error that still refuses. A guardrail that opens when its script breaks is not a guardrail.
  • Every denial lands on the ledger (delegation_policy_denied) and in the daemon's counters.

Hires made by a flow go through the same hook as hires made by a human or an agent.

3. MCP surface — token discipline and self-knowledge

  • 8 → 6 tools: agent / agent_read / agent_stop / status (+ the bare-name discovery alias grok_claude_codex_kimi) / chat_send_file. No aliases kept.
  • Menu, not manual: tiered response bodies, a per-caller tool face, and the initialize instructions cut down to cross-tool policy plus one line of identity. No summarization anywhere — the engine still runs no LLM.
  • agent_read gains wait (0–240 s long-poll). This was the missing primitive: callers were tailing a child's turns.jsonl because there was no way to await an in-flight turn.
  • Positive identity contract: a session can now answer "who am I / where am I / what happens after I hire someone" from ambient context alone, in statements. A missing field marks a deviation; it never carries a fact you have to infer.
  • notify:"all" deleted (it had been identical to final in the engine for a release); notify:"brief" added; notification and inline-result excerpts halved by default.
  • Usage-aware scheduling: account quota and context occupancy are exposed through status and handed to the hook.
  • New reference doc: docs/mcp.md (+ 中文).

4. Fixes found by using the tools, not by reading them

#195 an inline wait and the completion notification now resolve to one delivery — the decision moved from "win a 1 ms race at the turn boundary" to "claim it when the wait is declared"; a bare agent_read stopped being 73% pointer · #194 completion notifications are read once, with frugal defaults · #193 a slash command sent mid-turn defers to the turn boundary instead of landing as text · #192 the turn ledger keeps every text block, not just the last · #189 graceful Codex app-server termination, writer-lock holder diagnostics, 409 on a held thread · #186 web history seed merge, visible load failure, busy heartbeat · #14 a chat's focus is the chat, and a re-spawn keeps the model the vendor reported (fixed with a generation stamp after three rounds of clearing/gating failed) · McpFlowClient hardening, run/mcp-url re-recorded from the bound address, released sessions dispatchable across a daemon restart · pricing for the Claude 5 and gpt-5.6 families.

5. Governance

.loop/ retired, design docs moved to a gitignored local area, plugins/dsh-plugins/. The repo no longer carries governance state files.

Ship gate

  • Cargo.toml 0.10.50.11.0; @ccteam/ccteam-ui version + ccteam.engine + the four @ccteam/engine-* optionalDependencies + lockfile + PACKAGE_VERSION / ENGINE_VERSION in lockstep; embedded ccteam-ui.tgz repacked (two-path reproducible).
  • README.md gains orchestration-in-code as a fifth way to drive ccteam; docs/usage.md (+ 中文) gain the flow command group. Both describe current capability, not a changelog.
  • One stale test fixed: mcp_session_bearer_test still asserted that a declared parent_sid is inert for a verified principal, a contract 4be69a11 deliberately replaced with "refine within your own project, refuse loudly on a miss". CI does not run the ccteam-web suite, so it rotted unnoticed on dev.

🤖 Generated with Claude Code

The IM `/status` reply for a released focus (💤) stopped after the resume
line: no `⚡ 用量` row, no `↓ 所有 N 个项目 → /projects` footer, no
delegated children — because the released case was rendered by a second,
hand-copied function (`render_released_status`) that never reached the
shared tail of the live path.

Fixed at the rendering layer, not the symptom: one fact model, one layout.

- `StatusCard` (+ `StatusRunState`, `StatusChild`) holds every fact the
  card shows; `StatusCard::render` is the ONLY layout. Every optional block
  is keyed on its fact being present, never on residency.
- `Gateway::status_card` gathers the facts once for both residencies. The
  focus is a `SessionRowSource` — the same Resident/Released source the
  `/sessions` rows use — so `thread_status` is read through the shared
  `row_thread_status` (live adapter vs persisted status.json). Residency
  only decides where the session's own facts come from and leaves running
  tasks empty for a released session; account usage (borrowed from a
  visible same-harness resident session), direct children and the
  /sessions + /projects footer are gathered regardless.
- `render_status` = resolve focus (`released_focus`) → `status_card` →
  `render`; `render_released_status` is deleted.
- `SessionRowSource` gains `vendor_kind` / `vendor_uuid`; the effort
  fallback (`status.effort → meta.effort`) is now the same for both
  residencies (the live card previously had no meta fallback).
- FakeAdapter gains an `account_usage` seam. Tests: the released-focus
  card asserts the footer; a new test pins the borrowed usage row + child
  row + both footer lines on a released focus; a pure `StatusCard` layout
  test locks the block order once. Re-attached the `humanize_dur` doc
  comment that had drifted onto `format_running_tasks`.
- docs/usage*.md: fold the released-state card into the `/status` entry.

Gates: cargo fmt --check · make check · cargo test -p ccteam-im --lib
(640 passed) · make test-baseline (all green).

Co-Authored-By: firstintent <239836187+firstintent@users.noreply.github.com>
…ssues/#2)

Second half of the same report: with the focus released AND nothing else
resident, the `⚡ 用量` row was still missing. The previous fix unified the
CARD; this one fixes the FACT it could not obtain.

Account usage is account-scoped but only READABLE through a live session,
so it existed only while some process happened to be resident. Every
consumer therefore had to go scavenging — the status card looped over
whatever sessions were alive hunting for one of the right vendor, and
showed nothing when none answered. That is exactly the state a reader is
in when the session has been idle-released, or right after a daemon
restart (where nothing is resident by design).

So the fact gets a home, next to the one its sibling already had:

- `ccteam_harness::usage_catalog` — a per-vendor last-seen account-usage
  cache, mirroring `model_catalog` in shape (`{vendor: {observed_at,
  source, usage}}` at `~/.ccteam/account-usage.json`), atomicity, and
  advisory-never-fatal contract. `last_known_usage_in` drops each window
  once the VENDOR's own declared `resets_at` has passed (falling back to
  the window's natural length when it declared none), so a snapshot is
  never presented as current past its usefulness — no arbitrary staleness
  cutoff and no caller-side policy.
- `Gateway::account_usage_for(vendor, sessions)` — the ONE way any surface
  asks. Asks any live session of that vendor (they all report the same
  account, so the first answer wins), records what it learns, and falls
  back to the catalog. Callers get one `Option` and never branch on
  residency; `status_card`'s prefer-own-then-scavenge block is gone.
- The observation is captured where it is made: on a `/status` read, and
  on the release path, where an account-scoped fact would otherwise be
  lost with the process that could report it. The release capture is
  bounded by `RELEASE_USAGE_CAPTURE_TIMEOUT` — a wedged child must still
  be released promptly; the capture is the bonus, not the job.
- Gateway gains an injected `state_root` (set by `enable_persistence`,
  never re-derived from the environment) so tests write into a tempdir and
  can never touch a real home.

Tests: usage_catalog covers round-trip, per-window expiry at the vendor's
declared reset, the natural-length fallback, corrupt/missing files, and
that an empty capture cannot erase a good entry. The gateway test asserts
the reported scenario end to end — released focus, `sessions` empty, the
usage row still there — and was confirmed to fail without the fallback.

Gates: cargo fmt --check · make check · cargo test -p ccteam-im --lib
(641 passed) · make test-baseline (green; see note below).

Note on `delegation_reconcile_delivers_missed_notification_exactly_once`:
it failed twice under a loaded workspace-parallel run while this branch
was in progress, then passed 9/9 (3 crate-only + 6 workspace) once the box
was quiet. It is a 3-worker-thread test polling a 4s deadline, and this
change is inert on its path (that fixture never enables persistence, so
no capture and no write occur). Recorded rather than silenced; CI is the
clean-environment arbiter.

Co-Authored-By: firstintent <239836187+firstintent@users.noreply.github.com>
@firstintent

Copy link
Copy Markdown
Owner Author

Second commit on this cycle: 4f30686 — im: give account usage a home so /status never loses it.

The first fix unified the card; this one fixes the fact it could not obtain. AccountUsage is account-scoped but only readable through a live session, so it existed only while some process happened to be resident — the card scavenged through live sessions and showed nothing when none answered, which is exactly the state after an idle release or a daemon restart.

  • New ccteam_harness::usage_catalog: per-vendor last-seen cache mirroring model_catalog (shape, atomicity, advisory-never-fatal). Each window expires at the vendor's own declared resets_at, so a snapshot is never shown as current past its usefulness.
  • Gateway::account_usage_for(vendor, sessions) is the one interface: ask any live same-vendor session → record → else read the catalog. Callers never branch on residency.
  • Captured where observed: on a /status read and on the release path (timeout-bounded, so a wedged child still releases promptly).
  • Gateway gains an injected state_root, so tests can never touch a real home.

Gates: fmt ✓ · make check ✓ · cargo test -p ccteam-im --lib 641 passed ✓ · make test-baseline green. One load-sensitive flake observed and recorded in the commit message rather than silenced.

…live

The usage entry still described the live-only read that 4f30686 replaced:
the windows are read from any live session of the same harness AND kept
per harness, so they show when nothing is resident, and each window is
dropped at the vendor's own reset rather than shown stale.

Co-Authored-By: firstintent <239836187+firstintent@users.noreply.github.com>
…s-local/issues/#3)

- agent (spawn+dispatch merged, task required) / agent_read (list+collect,
  newest-first, n=10, max_chars 4k) / agent_stop; status tiered
  brief|models|vendors|routing|full; bare-name beacon returns the brief body;
  every response is compact JSON (no pretty printing)
- per-caller tool face for tools/list AND initialize.instructions: full/read/
  none (spawn-time `tools` facet, persisted in session meta), depth-capped
  children default to read, chat_send_file listed only for chat-capable
  callers; instructions carry cross-tool policy + one declarative identity
  line (static 775 B, was 2601 B); hiding a tool is a listing decision only —
  tools/call gates are unchanged
- protocol: real version negotiation (2025-06-18/2025-03-26/2024-11-05;
  unknown → latest, never an error), strict MCP-Protocol-Version header
  (present-but-invalid → 400), read/destructive annotations, discovery
  (initialize/tools-list) INFO logs
- notify gains brief (500-char excerpt); final excerpt 4000→2000 chars;
  inline result cap 10000→4000; truncation markers point at agent_read;
  idempotent replays say idempotent_replay:true; agent{sid} distinguishes
  a stopped session from an unknown sid
- docs (en/cn) + README + SPA copy rewritten for the six-tool surface; the
  DSH plugin mirrors the engine definitions character-for-character; Pi
  bridge ready-gate accepts any subset of the known face
- measured: orchestrator ambient 14351→~5.8k B (−60%), leaf 1621 B (−89%),
  tools/list 4967 B, status brief ≤104 B, 10-row roster 1179 B

Co-Authored-By: firstintent <239836187+firstintent@users.noreply.github.com>
One home for the tool-surface facts: the six tools with parameters and
response shapes, the per-caller tool face, completion-notification tiers,
credential families (session principal / enrollment), protocol details
(version negotiation, strict MCP-Protocol-Version header, annotations,
compact bodies, discovery logs), guardrails and the honest trust scope.
The orchestration guide's appendix moves there and leaves a pointer;
README links the new page.

Co-Authored-By: firstintent <239836187+firstintent@users.noreply.github.com>
- agent_read{sid,wait}: long-poll an in-flight turn to its boundary (0-240s,
  shared waiter with dispatch-wait incl. the final-turn settle; read deadline
  budget 15+wait); a parent that collects the boundary inline disarms its own
  watch, third-party readers never touch it
- identity line states delivery: managed sessions read 'completion
  notifications arrive here', client-run/enrolled ones read the opposite fact
  with the agent_read{sid,wait} alternative — delivery is stated, not left to
  be inferred from a missing notify_deliverable
- notify 'all' removed (schema, NotifyMode, dead interim paths); persisted
  watches saying 'all' deserialize leniently to final
- tool_face is a PRINCIPAL property minted before the child process starts
  (meta stays the restart audit copy) and discovery accepts a Spawning
  principal — the measured race where a tools:none child fetched the full
  face once and kept it is closed at both root causes
- one shared unreachable-session refusal for agent/agent_read/agent_stop:
  unknown and invisible answer identically, existence stays undisclosed
- ambient roster is scoped to the caller's own project; foreign names get an
  explicit refusal instead of a silent empty filter
- unbound status says 'no project named yet' (not 'could not be
  authenticated'); non-descendant agent_stop refusal names the web/REST way
  out; unknown tool names refuse as unknown even before a project is named
- read face gains status (leaf ambient gate re-pinned 2200 B); AGENTS.md
  stale 8-tool/session_* mentions fact-synced; docs + DSH plugin mirror
  resynced character-for-character

Co-Authored-By: firstintent <239836187+firstintent@users.noreply.github.com>
…local/issues/#6)

status{detail:"usage"} answers the two scheduling questions in one call:
'you' (the caller's own context_pct) and per-vendor account windows from the
usage catalog (live same-vendor adapters asked in-memory first, cache after;
per-window expiry at the vendor's own reset). GET /api/v1/usage serves the
same map to scripts; session list rows carry context_pct. AccountUsage gains
per-model windows (claude model_scoped → seven_day_* → weekly_scoped limits);
grok captures via its own ACP ext billing method (fail-closed, provenance in
the header, revisit on upgrades); kimi stays honestly absent (its quota lives
behind a vendor HTTP API, out of contract).

Co-Authored-By: firstintent <239836187+firstintent@users.noreply.github.com>
Every MCP agent call (hire and dispatch) now runs an optional user script
before any side effect: <project>/.ccteam/hooks/pre-agent, falling back to
~/.ccteam/hooks/pre-agent (replace, never merge — the routing.md rule).
exit 0 allows; exit 2 denies with the script's stderr relayed verbatim
(UTF-8-safe 2000-byte cap); every other outcome is a distinct fail-closed
policy_script_error naming the script — a broken guardrail must not open,
and a fault must never read like a verdict. stdin hands the script the
facts a policy decides on (caller, request, account usage windows, counts)
so hooks need no token and no callback into the daemon; the subprocess
runs lock-free in its own process group, killed whole at the 3s budget,
and the stderr drain is joined-or-aborted on every path, never detached.

Spec: docs-local/versions/v0-x-flow/prd.md §1 (Card H).
Gates: ccteam-im --lib 681→702, ccteam-harness --lib 580, clippy clean.
Deletes the dormant decision-engine (orchestrator/watchers, a retired
concept with one stale caller) and rebuilds the crate as the runner that
executes user workflow scripts: QuickJS (rquickjs, no loader/no Intl)
runs plain JS with top-level await; agent/parallel/pipeline/phase/log/
args/budget/usage are host functions; Date.now, Math.random, argless
new Date throw so a resume replays deterministically. A scheduler
admits calls through a per-run cap, per-vendor slot pools, a spawn-rate
token bucket and pool-level backoff, with brakes (max_agents atomic,
max_cost_usd, wall clock, budget) that refuse new work and never cancel
a running worker. Every call lands in an append-only journal keyed by
content hash; resume replays the unchanged prefix, invalidates from the
first mismatch, and re-attaches to in-flight sids. All behind an async
FlowClient trait — the MCP implementation is the next card; tests run
on a deterministic FakeClient under tokio's virtual clock.

Also excises the retired agent-team lookup from ccteam-cli's attach
path (dead concept; attach falls through its existing ladder), its 3
tests, and the ccteam-flow dep — sanctioned deletions: cli bins 109→106,
TODO-anchor count test updated 4→3 with the removed anchor named.

Spec: docs-local/versions/v0-x-flow/prd.md §2 (Card F0a).
Gates: ccteam-flow 49→95(+1 doc), clippy -D warnings clean, fmt clean.
McpFlowClient implements the FlowClient trait over the daemon's ordinary
MCP face: hire = agent{task,vendor,...,idempotency_key}, the wait loop =
agent_read{sid,wait,since}, stop = agent_stop, usage = status — so every
workflow leaf passes the same guardrails, budget and pre-agent policy
hook as any other delegation, and lands on the ledger as a session.
Auth reuses the existing machine enrollment credential (ensure_user_
credential, the same slot ccteam config mcp writes) — no new bearer
family. Session cost arrives cumulative; the client charges deltas.
follow_up carries no idempotency key, so transport retry is Once there
and Idempotent elsewhere.

ccteam flow run <file.js> executes a workflow against the daemon:
--project/--args/--parallel/--max-agents/--max-cost/--budget/--run-dir/
--resume/--watchdog; progress lines on stderr, RunReport JSON on stdout;
a run dir that already holds a journal resumes instead of re-paying.
runs/ joins canonical_home_dirs so doctor stays drift-clean.

Integration: two real-daemon tests with the fake vendor (fresh run
hires two; a resumed run replays from cache — proven by vendor spawn
count), HOME/CCTEAM_HOME pinned per child process with a stop guard.

Spec: docs-local/versions/v0-x-flow/prd.md §2 (Card F0b).
Gates: flow 98→112+5+1doc, cli bins 106→113 + flow_run_test 2,
clippy -D warnings clean, fmt clean.
One page for the two deterministic-code surfaces: the pre-agent policy
hook (path resolution, stdin facts contract, exit-code dialect, the
quota-routing example, honest scope) and dynamic workflows (script
surface, brake-vs-failure contract, determinism rules, journal resume
with re-attach, scheduling and the honest edges). Linked from the
mcp.md doc family header, both languages.
The feature formerly called 'dynamic workflows' is now ccteam Flow —
the CLI and crate already said flow; only prose changes. The doc gains
a three-mode frame (policy hook / ccteam Flow / Claude-native bridge),
answers where flow scripts live (.agents/flows/ committed — NOT the
gitignored .ccteam/), and documents bridge mode: Claude Code's native
dynamic workflows hiring ccteam agents over MCP, with the honest
glue-cost / survivability trade table. examples/ ships runnable
companions: two pre-agent hooks (quota routing, depth vendor
allowlist), three flows (fan-out audit, quota-routed schema review,
ralph-until-green), and the Claude-native bridge workflow. Examples
live in-repo by owner's explicit instruction (2026-09-01), a scoped
relaxation of the no-prompt-content rule for documentation samples.
The bridge-mode sample blocks in both languages carried a literal
${'$'}{ where ${ belonged — a leftover from the doc generator, not
present in the runnable examples/ scripts. The guard grep that should
have caught it was itself quoting-mangled; repaired by asserting on the
exact byte sequence instead.
The common case is a managed session triggering a flow, but the runner
is an enrolled client, so every leaf mounted under the anonymous
runner node and the delegation tree lost the edge that exists. Now:
the CLI defaults --parent from CCTEAM_CHAT_SID (present inside every
managed session; --parent overrides; blank counts as absent), threads
it through RunConfig/HireSpec into the hire payload, and the server's
Ambient tier honors a declared parent_sid — validated live and only
within the caller's own project, because a child's completion
notification lands on its parent, so a cross-project edge would be an
injection channel, not attribution. Unknown and cross-project
declarations fail loud; nothing declared keeps today's caller-as-parent.

Also repairs two doc drifts the first real flow run itself found
(branch-review, run on this very branch): the stderr relay wording now
names the refusal prefix, and title is no longer listed as an agent()
option (the allow-list never had it — label is the ledger title).

Gates: flow 114(+attribution test), cli bins 114(+parent precedence),
im 702->705(+3 dispatch tests: own-project edge, unknown loud,
cross-project refused), flow_run_test 2, clippy clean, fmt clean.
branch-review is the exact script the three-mode live test ran: four
harnesses (grok, codex, dsh cs/deepseek-v4-pro, claude sonnet) each
review one face of this very branch, a sonnet leaf merges. Its first
run produced real findings (issue #9; three doc drifts fixed on this
branch). flow-review is the evaluation loop the docs describe — it
graded that run and proposed the next edits (schema per leaf, vendor
swap on the weakest face, per-leaf retry), kept as-run; improvements
land with their own evidence. They live under examples/ because this
repo gitignores .agents/ (the zero-prompt-content rule); user projects
follow the .agents/flows/ convention the docs state.
The R-M3 ownership gate resolved agent/agent_stop targets against the
LIVE map only ('a thread you can write to is exactly what a stopped
session does not have') — but that conflated stopped with not-in-the-
live-map, so after a daemon restart every pre-restart released session
answered 'unknown session' while the roster still listed it (issue #8;
red line: resume-by-sid survives restarts; predates this branch, from
the v0.10.6 face rework).

One resolver for every tool now (session_resolve_any: live map, then
on-disk meta), and the state refusals become explicit meta-driven
gates that run AFTER the scope check so their wording can never probe
a foreign sid: assert_target_not_external (live index OR persisted
managed_by — an external node stays refusable across restarts, and
agent_read refuses it too, unchanged) and assert_target_not_stopped
(the explicit-stop contract keeps its exact words; a merely-released
session falls through to the existing cold_resume machinery). The old
order also let the external wording fire before the scope check —
reordering closes that existence probe for free.

Three restart-shaped tests rebuild a fresh Gateway over the same
on-disk state: released → dispatchable; stopped → its own words;
external-from-meta → refused.

Fixes docs-local/issues/#8.
Gates: ccteam-im --lib 705→708, clippy clean, fmt clean.
The launcher records run/mcp-url from the REQUESTED --web-bind before
the listener exists, so a :0 request (any free port) — or any gap
between asked-for and bound — left MCP clients dialing a port nobody
listens on. The process that owns the port now re-records the URL from
listener.local_addr(), inside the same publish_endpoint daemon-only
gate that already publishes daemon-endpoint.json from the bound
address (so a standalone web never clobbers the daemon's record). The
flow_run_test daemon harness now asserts the recorded URL carries the
port it actually reserved.

Fixes docs-local/issues/#7.
Gates: flow_run_test 2 green end-to-end, clippy clean, fmt clean.
The branch's own dogfood flow reviewed this client and an adversarial
verify pass confirmed all six findings; each fix was proven by
reverting it and watching its test go red:

1. server: agent_read paging kept only rows with assistant text, so a
   FAILED turn that said nothing was paged out and the client returned
   the previous successful turn — a terminal outcome now keeps its row
   (content stays empty; outcome has exactly one writer).
2. the awaiting cursor was consumed before the wait and never restored
   on timeout/transport error, so the next await re-attached from
   since=None and could hand back a stale tail — the cursor now
   retires only at a real boundary, and follow_up registers its
   pre-dispatch anchor (the only path that has one).
3. stuck (a silent LIVE turn) now counts as in-flight; stale keeps its
   existing promote-back guard.
4. turn_timeout is enforced per poll: each agent_read wait is capped
   to the remaining deadline and the spin guard never sleeps past it.
5. initialize and tools/call envelopes are structurally validated —
   a missing or empty content[0].text is a named failure, never a
   silent null that polls forever; textless refusals stay refusals.
6. session establishment is single-flight (async mutex; the -32001
   re-initialize path shares it), so concurrent first hires can no
   longer mint several enrolled ledger nodes for one run.

Fixes docs-local/issues/#9.
Gates: flow 114→120(+11 integration), im 708→710, flow_run_test 2,
clippy clean, fmt clean; mcp_client_test 3x back-to-back no flake.
…sage

Backlog LEDGER-1, both halves. Live probes showed every codex leaf and
every Claude-5/gpt-5.6 hire reporting cost 0 — two independent gaps:

1. Price rows were missing, so estimate_cost answered None (honestly
   excluded from sums, silently read as free). Added claude-sonnet-5 /
   claude-opus-5 / claude-fable-5 (published in/out; cache read 0.1x,
   1h write 1.25x — the ratios every existing row encodes) and the
   gpt-5.6 standard tier (sol/terra/luna/cyber; reasoning = output as
   for all reasoning models). Sources cited in the sheets; rows marked
   OWNER-VERIFY per the card (prices are owner-checked before ship).

2. Codex's real turn/completed wire carries no usage field, so
   TurnCompleted folded zero tokens into the ledger even though the
   tracker held the turn's accounting (tokenUsage/updated last). The
   pump now fills the completed event from that bucket — the exact twin
   of the existing TurnFailed enrichment; inline fixture values win.

Gates: cost 30->31, harness 580->581, clippy clean, fmt clean.
…fallback

The flow-review pass over run 1 proposed three edits; runs 2-3 are the
evidence (reports archived in the PRD dir):

- schema per leaf: four vendors had rendered the prose VERDICT
  convention four incompatible ways; every leaf now returns a validated
  {verdict, findings} object, and a nonconforming reply is a NAMED
  failure (run 3 caught codex answering outside the enum after its one
  retry — bounded and honest, never a silent null).
- the weakest face moved grok -> claude/sonnet, matching the sibling
  face that verified claims against the built binary with file:line.
- retry {max:1} on non-claude leaves.

Run 2 then fired the resident quota-route hook for real — the account's
5h window sat at 84-86% and every claude hire was refused with the
hook's own words, nulling three faces. That incident became the fourth
edit: policy-aware fallback seats. A refused primary resolves null, the
script catches it DETERMINISTICALLY and re-asks on codex — run 3 shows
both fallback seats seating (s518, s520) and the merge producing
ranked structured findings while the hook kept steering claude traffic
away. The hook constrains, the flow adapts, nothing is silently lost.
The v3 dogfood review's one confirmed candidate (two refuted with
named guards + covering tests, recorded in the issue): after the F0d
gates, a tool body of null was refused but arrays and bare scalars
still parsed into Body(...) — and every downstream read (activity,
turns, sid) answers 'absent' on those, which is exactly the
forever-poll class the envelope validation exists to kill. Every
ccteam tool body is an object; anything else is now a named failure
carrying the offending JSON type (one unified wording — the dedicated
null arm folded into it, its test updated).

Gates: flow 121->122, mcp_client_test 11, clippy clean, fmt clean.
…nce bump

Two follow-ups the three-mode live test itself earned:

- The honest-edges section (both languages) now warns that dogfooding
  a flow against a daemon that also serves real chats shares its
  gateway — every hire and lock goes through the one process (this
  branch's own test run put 452 MCP calls on the production daemon).
  Point --home / CCTEAM_HOME at an isolated ccteam home for
  exploratory or load-heavy runs; same discipline as the checker
  scripts elsewhere in the repo.

- branch-review's fallback seats get retry {max:2}: run 3 showed a
  codex fallback exhausting its single retry still outside the enum —
  one more shot before the schema gives up in its own words.
…es the judge

Claude Code makes "just ask and it writes the workflow" work by baking a full
authoring manual into the Workflow tool's JSON-schema description — free,
always in context the moment the tool exists. ccteam has no such channel:
injecting anything into a session is the no-prompt-injection red line, and MCP
tool schemas tax every session whether or not it ever writes a flow. So the
manual is earned explicitly instead.

`ccteam flow new <name>` writes `<slug>.flow.js` (ccteam_core::slugify, the
same helper project slugs use) and prints the script surface to stdout — the
globals, the agent opts, brakes-vs-failures, determinism. An agent that ran the
command asked for the manual and gets it where its shell already looks. The
destination is the project's `.agents/flows/` when the cwd is inside an
initialized project, else the cwd; `--dir` wins over both. An existing file is
a named error, never an overwrite. The template is CODE — a meta block and one
TODO call — with no persona or task wording in it, and a test asserts that.

`ccteam flow eval <run-dir>` resolves WHICH script judges a finished run, on
the same two rungs the pre-agent policy hook uses (ccteam_im::policy::
resolve_hook): the project's `.agents/flows/_eval.flow.js` replaces the global
`~/.ccteam/flows/_eval.flow.js`, never merges with it, and neither existing is
an error naming what to copy where. Then it hands the job to `run` unchanged
with args {"run_dir": <absolute path>} — an evaluation IS a flow run, so it
gets its own journal, resume and report for free and there is exactly one
runner to keep honest. The engine still judges nothing itself.

`<run-dir>` takes a path or the bare run id under `~/.ccteam/runs/`, always
resolved absolute because the evaluating agent reads it from its own cwd.
`flows` joins canonical_home_dirs() so `ccteam doctor` does not report the new
home as an orchestrator-era leftover, and the two inline journal.jsonl checks
in resolve_run_dir now share one `is_run_dir` predicate with eval.

`run`'s body is untouched on purpose — a concurrent change to its progress
plumbing merges cleanly, and `eval` inherits it by calling `run`.

Gates: ccteam-cli flow tests 8 -> 17, clippy clean, fmt clean.

Co-Authored-By: firstintent <239836187+firstintent@users.noreply.github.com>
- lib/flowRunsApi.ts: client for GET /api/v1/projects/{slug}/flow-runs coded
  against the agreed DRAFT contract (backend lands in a parallel track;
  reconcile the shape here if the real route drifts), plus pure derivations:
  a run's leaves off the already-fetched delegation graph (descendants of the
  trigger sid, time-window bounded with 60s grace), compact duration tokens,
  status->badge mapping (ok->ok, error/brake->warn with distinct labels,
  running->brand + busy pulse).
- AgentsView: TeamTab type + third seg button; FlowRunsPanel (hook-free —
  flat newest-first rows on .flow-rows, project badge only when runs span
  more than one project, expand -> leaf sub-rows linking to /chat/s/<sid>);
  FlowRunsTab (visible-project poll via usePolledSnapshot, per-project
  fail-soft, polled only while mounted; SSE liveness is a documented
  fast-follow). Tab label is deliberately 编排/Runs, never 工作流/Flow:
  /flow (WorkflowView) is the unrelated content-management page.
- i18n zh/en keys; small CSS block for run rows / leaf sub-rows / pulse
  (reduced-motion opt-out included).
- Tests: flowRunsApi suite (fetch fail-soft, leaf derivation windows,
  durations, badge map) + panel/seg/shell coverage. make web-check green
  (62 files / 716 unit tests), tsc -b + vite build green.

Co-Authored-By: firstintent <239836187+firstintent@users.noreply.github.com>
Adds a third seg tab to AgentsView.tsx (团队 view), alongside 拓扑/分工,
listing ccteam Flow runs. Leaves are derived from the already-fetched
delegation graph (parent_sid + time window) — zero new fetch. Envelope
data (name/status/cost/agents) comes from the planned
GET /api/v1/projects/{slug}/flow-runs (flowRunsApi.ts carries a dated
CONTRACT NOTE; degrades to the honest empty state until the backend
track lands and is reconciled).

Tab labeled 编排/Runs, not "Flow" — dodges the pre-existing unrelated
/flow route (WorkflowView.tsx: skills/roles/plugins/MCP/自进化).

make web-check green (62 files / 716 tests), tsc+vite build green.
The evaluation loop was already expressible as a flow; what it lacked was an
output a script could gate on. flow-review's two leaves now carry schemas:
`grade` returns {scores:{clarity,vendor_fit,waste}, notes[]} and `patch`
returns {edits:[{what,why}]}, both validated, both null if the worker never
complies (structured output is extraction, not enforcement). Every score is
1-10 and HIGHER IS BETTER, `waste` included — a gate has to read the direction
off the schema, not off each grader's prose. Same two-phase shape as before,
plus retry {max:1}, the same fix branch-review earned live.

self-review-loop.sh is write -> run -> evaluate -> improve as a RECIPE, not an
automatic loop, and the header says so: script space has no filesystem or
process access by design, so a flow cannot edit itself. The default stops at
the improve hop with the edits on stderr and exit 3, printing the exact
`RESUME=<run-dir>` command to continue the same journal. Setting IMPROVE_CMD
hands the edits to an explicitly-delegated agent instead and lets ROUNDS
actually iterate — the hand-off is a decision the operator makes, never one
the script makes for them.

Verified against a deterministic fake `ccteam` (seven cases: low score, good
enough, RESUME threading, IMPROVE_CMD iteration, no-edits, worst-dimension
gating, no-grade) in a sandbox with HOME and CCTEAM_HOME both pinned. That
harness found two real defects in the script, both fixed here:

- `set -e` does not fire inside an `if`, so an empty $score sailed past
  `[ -ge ]` as a non-fatal "Illegal number" and the run reported phantom
  edits. A non-numeric score is now exit 2.
- `min // 0` collapsed an ABSENT grade to 0, which reads as "graded terrible"
  rather than "no verdict". Empty scores now yield "" and take the exit-2 path.

The gate is the WORST of the three dimensions, not `waste` alone: one bad
dimension is what you want to be told about.

Gates: node --check flow-review.flow.js, sh -n self-review-loop.sh.

Co-Authored-By: firstintent <239836187+firstintent@users.noreply.github.com>
Two additions to both language versions, kept in sync in meaning.

"Writing one" states the asymmetry plainly rather than papering over it:
Claude Code bakes its authoring manual into the Workflow tool's JSON-schema
description and it rides into every session free; ccteam has no such channel
and will not grow one, because injecting into a session is the standing red
line and tool schema taxes every session whether or not it writes flows. So
`ccteam flow new` earns the manual on stdout instead. The other half — turning
a plain-language ask into the right SHAPE — is a `flow-creator` skill, and the
doc says where the convention puts one (`~/.ccteam/skills/flow-creator/` or a
project's `.agents/skills/flow-creator/`) without shipping its content: skill
content is user-space, zero exceptions.

The evaluation section now describes `ccteam flow eval`: the four-step
resolution precedence, the pre-agent hook's replace-never-merge rule applied
to evaluators, and that it is sugar over `flow run` with args.run_dir set — one
runner, not two. It points at flow-review's new validated shapes and at
self-review-loop.sh, and keeps the register the rest of the doc uses: the loop
is a RECIPE, the last hop is a hand-off, and a flow cannot edit itself because
script space has no filesystem — the same property that makes --resume exact.
No overselling "improve" as automatic.

Quick start gains the two new verbs; the flags line now says which verb it
describes and points at `--help` as authoritative for all three.

Co-Authored-By: firstintent <239836187+firstintent@users.noreply.github.com>
A ccteam Flow's leaves are already visible — every agent() call is an
ordinary delegation with a real sid, joined to its run by parent_sid.
The envelope around them was not: which hires belonged to one run, what
the run was called, whether it is still going and how it ended lived
only in the run directory of the machine that typed `ccteam flow run`,
where neither the daemon nor the web UI can see it.

Three kinds, envelope only (flow_run_started / flow_run_finished /
flow_brake_tripped). Deliberately no per-agent row: duplicating leaf
detail here would put the same fact in two places.

Classified Fact, like the delegation_* family. LatestState would be
wrong twice over — two concurrent runs of one project would suppress
each other, and losing the started row leaves a finished row no reader
can attribute. Script-supplied free text (name, description, brake
reason, script path) is capped per field so one row cannot grow without
bound. started_at / finished_at are the RUN's clock and are kept
alongside ts (when the ledger accepted the row): a CLI process submits
these over HTTP, so the two are different facts.

ccteam-core only re-exports, per the schema-authority rule.

Gates: harness lib 581->585, clippy clean, fmt clean.

Co-Authored-By: firstintent <239836187+firstintent@users.noreply.github.com>
The flow runner is a short-lived CLI process, so its envelope rows have
to reach the daemon over HTTP. The daemon already exposes exactly one
door for "an outside process has an event for the ledger" — POST
/internal/hook/{kind}/{action}, behind the same auth layer as
everything else — so this reuses that route rather than adding a second
door to the same room. The action segment carries started|finished|
brake, the way progress-append and chat-progress already use it.

Not the existing progress-append kind: that handler is a Claude Code
hook-shape translator. It derives the project from a `cwd` field and
forwards a fixed tool-call vocabulary (tool_name, file_path, command,
exit_code), so every field of a run envelope would be silently dropped
— and cwd is the wrong resolver anyway, since the flow CLI already
knows its project explicitly (--project, else the slug in the cwd's
.ccteam/state.json) and a run may be driven from a directory that is
not the project's.

The slug arrives over the wire and is joined into a filesystem path, so
it is shape-checked before it can name a file: a separator or a parent
hop is refused, never sanitized — quietly rewriting a caller's project
name would file a run under the wrong workspace.

Gates: hooks lib 10->14, clippy clean, fmt clean.

Co-Authored-By: firstintent <239836187+firstintent@users.noreply.github.com>
firstintent and others added 25 commits September 2, 2026 00:20
`ccteam flow new <name>` scaffolds a flow.js skeleton and prints the
authoring cheat-sheet to stdout (ccteam's earned equivalent of CC's
Workflow-tool-schema-embeds-the-manual trick — no prompt injection
means ccteam has no zero-cost channel to piggyback on).

`ccteam flow eval <run-dir>` resolves an evaluator script by the same
project-overrides-global precedent as the pre-agent policy hook, and
is implemented as sugar over `run()` (untouched) so it composes
cleanly with the concurrent progress-bridge track.

examples/flows/flow-review.flow.js now returns schema-validated
{grade, patch} instead of prose; examples/flows/self-review-loop.sh
closes write->run->evaluate, hands "improve" to a human/agent on
purpose (documented, not oversold as automatic).

docs/hook-dynamic-workflows.md + -cn.md gain the authoring-story and
evaluation-loop sections.

Tests 2213->2222 (baseline, clean worktree), fmt/clippy clean.
The run-level envelope (which sids belong to which flow run, name,
status, brake, cost) previously lived only in the triggering CLI's
run-dir. Now:

- progress_bridge gains flow_run_started/finished + flow_brake_tripped
  event kinds (schema authority, closed enum, persistence policy chosen)
- ccteam flow run submits the envelope best-effort via the existing
  POST /internal/hook/flow-run/{action} path on a dedicated worker
  thread (LedgerBridge; a wedged daemon can never change a RunReport)
- GET /api/v1/projects/{slug}/flow-runs folds envelope rows into a
  newest-first run list (evolution.rs precedent: can_see_project gate,
  honest empty state); contract matches the 编排/Runs tab's
  flowRunsApi.ts draft verbatim

Verified in the maker worktree: fmt/clippy clean, harness 581->585,
hooks 10->14, cli +5, web lib 188, flow_runs_test 6/6 green.
…racks

BLOCKs:
- flow new template carried a placeholder agent('TODO: first task') — a
  prompt string an unedited run would send to a model. The scaffold now
  ships NO agent call; the zero-prompt-content test tightened to forbid
  agent() outside comments.
- write_scaffold was check-then-write; now O_EXCL create_new (atomic,
  refuses dangling symlinks).
- POST /internal/hook/flow-run/* named its project in the BODY, invisible
  to the URL-shaped project_acl_layer — any authenticated tenant could
  append envelope rows to another owner's journal. dispatch() now gates
  kind=flow-run with can_see_project + the same non-disclosing 404;
  tenant-vs-owner integration test proves refusal + untouched journal.

WARNs:
- ledger worker: first transport failure drains the rest of the queue
  without HTTP, bounding CLI exit to ONE timeout, not one per row.
- flow eval bare id resolves under runs/ FIRST (a same-named cwd dir no
  longer shadows the id) and canonicalizes.
- flow-runs response gains `truncated` (scan window hit SCAN_LIMIT);
  SPA announces it under the list instead of silently dropping runs.
- SPA marks failed fetches (error:true) — an all-projects-errored cycle
  says "endpoint unreachable" instead of faking the empty state.
- React key + expansion state scope by slug:run_id — cross-project twin
  run ids no longer collide.

Gates: fmt/clippy clean; baseline cli 128 · core 645 · flow 122 ·
harness 585 · hooks 14 · im 710; flow_runs_test 8/8 (2 new),
internal_hook 7/7, openapi 4/4, flow_run_test 2/2; web-check 719/719.
…ct truncation, zero-run truncation hint

- internal_hook flow-run gate now runs ensure_ledger_slug (made pub —
  one validator, two gates, same order) BEFORE can_see_project, so a
  traversal-shaped body slug never reaches filesystem path resolution;
  test locks four bad shapes to non-disclosing 404s.
- collect_recent_events_with_more surfaces the tail reader's has_more
  probe (it was computed and discarded); flow-runs `truncated` is now
  exact — a journal of exactly SCAN_LIMIT rows reads complete, one row
  past it reads truncated (boundary pair in one test).
- FlowRunsPanel zero-row branch prefers unavailable > truncated > empty,
  so a window that truncated away every run says "incomplete", never
  "no runs"; SPA test added.

Gates: fmt/clippy clean; baseline unchanged; flow_runs_test 9/9,
internal_hook 7/7, flow_run_test 2/2; web-check 720/720.
docs-local/issues/#14①. A focused session's answer to an INTERNAL turn — a
delegation completion notification — never reached the owner's IM thread: it
appeared on the web console (turns.jsonl is the SoT) and nowhere else. 15
replies lost on 09-01, 3 on 09-02.

`current_project` / `current_session` were keyed by the whole `(channel,
chat_id, user_id)` triple, so ONE chat could hold TWO routes. An inbound
message writes `user_id = <sender>`; everything that reconstructs a chat from
a session's persisted `meta.owner` goes through `ChatKey::from_identity`,
whose input is deliberately `"channel:chat_id"` with no `user_id`, and so
writes/reads `user_id = chat_id`. The live `routing.json` held both shapes for
one Telegram chat, the stale one pointing at a long-dead sid.

A human turn still landed, because `submit_resolved` points `reply_to` at the
chat that actually spoke. An internal turn goes through `plan_unlocked_turn`,
which resets `reply_to` to the OWNER-shaped key — which matched no route, so
the egress focus check read the chat's own session as out-of-focus and
v0.10.1's "second speaker" guard dropped the IM leg.

Fixed at the layer, not the symptom: `ChatKey::focus_key` normalizes `user_id`
away, exactly as `ChatKey::identity` already does for ownership, and both
tables move behind one `FocusRoutes` type whose inner map is private — every
reader and writer normalizes by construction, so a call site added later
cannot reintroduce the split. `has_addressee` is untouched; a genuinely
out-of-focus session is still guarded. `/use`'s hand-patch for the same class
is no longer needed and its comment now records why.

`load_saved` collapses a pre-fix `routing.json`'s duplicate rows onto one
route, preferring a sid in `live_sids` and then the higher (later-created)
sid ordinal — a stale route must never shadow the live one.

Tests (both red on the unfixed key): a restart-rebuild round trip asserting
the focused session's internal answer reaches the IM sink (was `Elapsed`), and
a routing.json load asserting two member-keyed rows collapse onto the live sid
(was `["s408", "s487"]`).

Co-Authored-By: firstintent <239836187+firstintent@users.noreply.github.com>
docs-local/issues/#14②. A session the owner had put on
`claude-fable-5-1[1m] · max` came back from an idle release running
`claude-sonnet-5 · xhigh` — a live sid, a resumed conversation, and a
different brain, with nothing to say so.

Every re-spawn path sourced both tuning axes from `meta.json`, which is
written at spawn and never again (except by `/role`). A mid-session `/model`
switch, or an effort the vendor resolved for itself, only ever reaches
`status.json` — the harness status tap writes it at every turn boundary with
what the vendor ITSELF reported, and it is the only persisted home of the
`[1m]` tag (the translator carries the bare API id into `meta.observed_model`).
`ccteam_harness::persisted_session_model` exists for exactly this and had no
caller: the pre-Wave-2 restore path read it, and `084d17d6` dropped that read
as collateral when it retired `gateway-state.json`'s sessions vec and
re-sourced the model from the new `meta.json` SoT.

One precedence, one helper (`respawn_tuning`), all three re-spawn paths —
resume-a-dead-session, the rebuild-from-meta core (daemon-restart restore,
`/use` cold resume, external adopt) and the `/role` switch:

    model  = status.json -> meta.model -> role frontmatter
    effort = status.json -> meta.effort

The observation outranks the request because only the request is frozen at
spawn time; `status.json` never holds a wish, and `persisted_session_model`
already rejects claude's unresolved `"default"` picker label and legacy
`<synthetic>` markers, so no placeholder can reach `--model`.

`/role` keeps daef69b's rule: the MODEL re-derives from the NEW role, that
being the point of the command, while the effort belongs to the session and
carries across. It now also drops the retired thread's observed model, so a
release before the new thread's first turn rebuilds at the new role's model
rather than the one that just died — clearing is honest where writing the
requested model back would be an echo into an observe-only file.

Tests (three red on the unfixed precedence, with the production symptom
verbatim — `(claude-sonnet-5, xhigh)` where `(claude-fable-5-1[1m], max)` was
expected): the two rebuild planners, the daemon-restart leg end to end, and
the `/role` rule. A fourth locks the fallback: with no observation, or a blank
one, the spawn-time request still stands.

Co-Authored-By: firstintent <239836187+firstintent@users.noreply.github.com>
…read

Checker round 1 on docs-local/issues/#14. The `/role` half of the previous
commit cleared `status.json`'s model with a read-modify-write, to stop a
re-spawn coming back on the model the switch had just overridden. That clear
races the RETIRED thread's own status tap: the tap can be parked inside an
awaited vendor probe when `close_thread` lands, and `shutdown()` only closes
the transport — it does not stop a probe already in flight, and an atomic
rename prevents torn files, not stale overwrites. The late write would restore
exactly the model the switch replaced, reintroducing #14②.

Nothing competes for that file any more.

- `SessionMeta.awaiting_observation` records "no observation from THIS thread
  yet". `/role` sets it under the gateway lock; the per-turn meta refresh that
  already updates `turn_count` clears it at the new thread's first completed
  turn. `respawn_tuning` suspends the `status.json` rung while it is set, so
  the model falls to `meta.model` → the role. The retired thread cannot reach
  the flag, so the race is structurally gone rather than narrowed.
- Belt, in the harness: the status tap is gated on its own transport still
  being open before every write, and the transport now owns the tap's
  `JoinHandle` so `shutdown`/`detach` abort it. A sid outlives its thread, so a
  tap that outlived its transport would write its SUCCESSOR's status file.

Also from the same review:

- `FocusRoutes` recovers a poisoned lock (`PoisonError::into_inner` + an error
  log) instead of dropping the mutation. A dropped `set` reads back as "this
  chat has no session" — a phantom spawn or a misrouted message, the very bug
  class the table exists to prevent.
- `load_saved` now disqualifies a candidate route by the SAME predicate
  `drop_dead_session_routes` prunes with (extracted as
  `focus_route_is_addressable`), before ranking. Collapsing a pre-fix
  `routing.json` could otherwise pick a stopped sid that merely ranked well and
  leave the chat with no focus at all once the prune ran.
- `switch_current_role` calls `respawn_tuning` like every other re-spawn; which
  axis differs is now stated in the type (`RespawnModel::FromRole`), not
  re-implemented.

Tests, each red on the specific mechanism it locks: a late tap write after
`/role` no longer changes the resume plan's model (was
`(claude-fable-5-1[1m], max)` where `(claude-opus-5, max)` was owed); a closed
transport's trailing status write is dropped; a poisoned focus lock still
records the chat's session (was `None`); the collapse skips a stopped sid (was
`["s2"]`); and the completion notification now travels the REAL notifier —
`DelegationSignal` → `run_delegation_notifier` → `deliver_delegation_signal_shared`
— to assert the parent's answer reaches the IM leg (was `Elapsed`).

Co-Authored-By: firstintent <239836187+firstintent@users.noreply.github.com>
…diagnostics, 409 on held thread

GitHub #189. The Codex thread writer lock is an flock that dies with its
holder; ccteam's old app-server was ended via tokio kill_on_drop (SIGKILL,
unawaited), which — measured on codex-cli 0.149.1 — orphans the
`codex-code-mode-host` helper and skips codex's own shutdown, while SIGTERM
exits in ~30 ms cleanly.

- codex_jsonrpc: no kill_on_drop; `terminate_stdio_child` = SIGTERM -> wait
  (3 s) -> SIGKILL, always reaped; `Drop` sends SIGTERM synchronously (daemon
  shutdown) and reaps via a detached waiter when a runtime is available.
- codex_app_server: `forget_client` terminates + reaps the old child UNDER the
  connection lock, so a re-dial can never race it for a thread's writer lock;
  a `thread/resume` rejected with "already has an active writer" now names the
  holder (pid + cmdline, ppid-1 flagged) from /proc and says what to do —
  ccteam never kills a process it did not spawn (on the reporting host the
  holder was the Codex desktop SSH remote app-server, `--listen unix://`).
- web: POST /sessions/{sid}/turn maps that rejection to 409
  `CODEX_THREAD_WRITER_HELD` instead of a generic 502.
- tests: hermetic fake app-server fixture; respawn reaps + SIGTERMs the old
  process before re-dialing; a dropped adapter ends its child gracefully;
  holder lookup; 409 classification.

Co-Authored-By: firstintent <239836187+firstintent@users.noreply.github.com>
…tbeat; one-line bubble footer; vendor in completion header

GitHub #186 (web: empty bubble after switching sessions, silent long turns):
- SessionView: ONE history seed path (mount / reconnect / retry) that applies
  the page through `mergeHistory` on top of the SSE frames that arrived while
  the fetch was in flight (content-deduped), so a reply that raced the
  server's turns.jsonl read is never wiped by `setRows(history)`; an
  unresolved approval prompt survives a reseed.
- a failed history fetch is shown (`history-error` banner + retry) instead of
  silently keeping stale localStorage rows.
- BusyHeartbeat under the streaming cursor: elapsed since send (ticking) +
  last event clock; the activity fold row's time advances with the latest step.

Owner requests (2026-09-02):
- web bubble: the header line is merged into ONE footer line
  `HH:MM · vendor · sid · model · turn N · ctx N%`; the IM status line uses the
  same field order (`→ slug/sid (role) · vendor · model · turn N · ctx P% · $`).
- child→parent completion header carries the vendor:
  `s12 done · codex · turn 7 · ctx 19%` (the inline agent JSON is unchanged —
  numeric keys only).

Co-Authored-By: firstintent <239836187+firstintent@users.noreply.github.com>
Checker round 2 on docs-local/issues/#14②. The `awaiting_observation` flag the
previous commit introduced was wrong three ways, and each was a symptom of the
same mistake — trying to decide "is this observation current?" with state that
someone else could reach:

- `refresh_session_activity_meta` runs on heartbeats too, not just at a turn
  boundary, and a `/role` switch only `abort()`s the old pump asynchronously —
  so an in-flight event from the RETIRED pump could clear the NEW thread's flag;
- only claude's tap was gated, while the ACP turn runner, pi's event task and
  thread_status, and claude's own `/model` handler all write `status.json`;
- `shutdown`/`detach` aborted the tap BEFORE marking the transport closed, and
  the closed-check was itself a check/write race.

Gating every writer and racing an abort is whack-a-mole. Late writes are now
harmless by construction instead:

- `SpawnCtx.generation` carries the gateway's per-spawn thread generation into
  every adapter, and `ThreadStatus.generation` records which thread produced an
  observation. Every writer family stamps it, and all of them do it by seeding
  the shared per-thread status once at spawn — the claude tap, pi's event task
  and the ACP turn runner all persist clones of that seed, so no individual
  write site has to remember.
- `SessionMeta.model_pinned_generation` replaces the flag: `/role` records the
  NEW thread's generation, and an observed model counts only when stamped `>=`
  it. A late write by generation N can never satisfy `>= N+1`, and the new
  thread's first report satisfies it forever — the pin resolves itself, nothing
  clears it, and no writer needs stopping. `persist_status_while_open` and its
  abort-ordering dependency are deleted; the tap's JoinHandle abort stays as
  hygiene that nothing relies on.
- Effort is deliberately generation-blind: it belongs to the session, not to
  one of its threads, and `/role` carries it across (daef69b).
- An unstamped `status.json` (written before the stamp existed) satisfies no
  pin, and is trusted as before when nothing is pinned.

The generation counter is now persisted (`state/sessions/next-generation`),
like the sid counter and for the same reason: a restart that rolled it back
would let a fresh thread stamp below a pin written before the restart, and the
pin would never resolve.

Tests are data-level, so none of them simulates a race: an observation from the
retired generation cannot retune a `/role` switch; one from the new generation
satisfies the pin; with no pin a resume replays the last observation whatever
its generation; an unstamped file is trusted only without a pin. Plus one
end-to-end test per writer family (claude tap, ACP turn runner, pi) driving a
real turn and asserting the persisted `status.json` carries the spawn
generation, and a round-trip through the file layer itself.

Co-Authored-By: firstintent <239836187+firstintent@users.noreply.github.com>
Checker round 3 on docs-local/issues/#14②. Generation stamping was confirmed
end to end; two narrow holes let a generation be reused or disagree with
itself.

D1 — the counter hands out values that end up in DURABLE records (`status.json`
stamps, `meta.model_pinned_generation`), but `load_state` trusted its file
alone. A wiped or corrupt `next-generation` reset it to 0, so a new thread
could stamp a generation an old observation or pin already used: a stale
observation would satisfy a pin (a silent retune — the very bug this all
fixes), or a pin above the reset counter would become permanently
unsatisfiable. The counter now recovers a floor from the records themselves —
one boot-time scan over the `list_session_metas` walk `recover_routing_from_meta`
already makes — and may only move forward. A file at or above the floor is
taken as-is; one behind it recovers to the floor; a missing or corrupt file
steps PAST the floor, since nothing then says the floor value was not already
handed out. Every recovery logs a warning.

D2 — a resume minted TWICE: `plan_resume_dead_session` passed the live entry's
existing epoch to the adapter (so the resumed thread stamped its observations
with the OLD generation) while `apply_resume_dead_session` minted a second one
for the live map. `GatewaySession.generation` and this session's `status.json`
stamps therefore disagreed, and a pin could be judged against a generation no
thread had ever written. The plan now mints once: that value rides the
`SpawnCtx` and apply installs the same one. The live-entry epoch it used to
overload is a separate `fenced_generation`, which is all the apply-phase race
guard ever needed.

Audited every mint/install pair — `plan_session_rebuild`/`apply_rebuilt_session`,
`plan_new_session`/`apply_new_session`, `switch_current_role`, and now the
resume path — all mint once and install that same value. (The two
`DelegationMirror` mints are a different entity's epoch, not a thread stamp;
they only consume counter values, which is harmless.)

Tests, red-first: the counter rule as a pure function over every file/floor
combination; a restart with the counter file deleted or rolled back to 3 while
the record claims 7, asserting the next mint clears 8 (was 1); and a resume
asserting the thread's `SpawnCtx` stamp equals `GatewaySession.generation`
(was 2 vs 3).

Co-Authored-By: firstintent <239836187+firstintent@users.noreply.github.com>
Checker round 4 on docs-local/issues/#14②. The generation floor was computed
from `list_session_metas`, which returns an empty list when it cannot read a
directory and says nothing about why — so the scan could not tell "this project
has no sessions" from "this project could not be looked at". A permission-denied
directory, or a satellite project whose `.ccteam/` lives on the other host by
design, therefore left the floor low, and after a counter wipe a generation
those sessions' `status.json`/`meta` already carry could be re-issued: a stale
observation would satisfy a pin. The same silent-cap class as #13/#15.

Reuse is now impossible even for records we could not read, and the omission is
loud:

- `recovered_generation_counter` takes the wall clock and uses it ONLY on the
  recovery branches, only through `max`: a file behind the floor recovers to
  `max(floor, now)`, a missing or corrupt one to `max(floor, now) + 1`. A
  healthy file is still taken verbatim, so normal operation never consults the
  clock and a backwards clock step cannot lower anything. Every generation ever
  issued before a recovery is a small dense counter value orders of magnitude
  below unix-seconds, so a recovered counter is provably above every stamp on
  disk whether or not the scan could see it. The cost is that generations stop
  being dense after a recovery; they are opaque `u64` and nothing reads them as
  a count. The recovered value is persisted immediately, like a minted one.
- `list_session_metas_reporting` is a new sibling returning the metas AND the
  `(path, io::Error)` pairs the old function swallowed; the floor scan reports
  one line per unreadable project — `warn!` for a local project, `debug!` when
  the host binding says the directory belongs to another host — and returns how
  many it could not read. `list_session_metas` now delegates to it and discards
  the errors, so all thirteen existing callers keep their exact behavior; none
  of them was touched. A missing `.ccteam/chat` stays a non-event (a project
  that never had a session); a corrupt `meta.json` is reported as `InvalidData`.

Tests, red-first: the pure rule across every file/floor/clock combination,
including that the file-at-or-above-floor branch ignores the clock entirely and
that a data floor above the clock still wins; plus an integration test that
chmods a project's chat dir to 000 (restored by a Drop guard, skipped as root),
deletes the counter, and asserts both halves — the scan REPORTS one unreadable
project, and the recovered counter clears the stamp it could not see.

Co-Authored-By: firstintent <239836187+firstintent@users.noreply.github.com>
…ly see

Checker round 5 on docs-local/issues/#14②. Two holes left, both the same
shape as R4's: a record we failed to READ was treated as a record that did not
exist.

D1a — `observed_generation_floor` read each `status.json` through
`read_status_file`, which collapses permission, read and parse failures into
`None`. An unreadable stamp was therefore neither counted nor logged, only the
metas were. `read_status_file_reporting` is a new sibling returning
`Result<Option<ThreadStatus>, io::Error>` (missing = `Ok(None)`, unreadable =
`Err`, unparseable = `Err(InvalidData)`); the floor scan uses it and reports
status failures exactly like meta failures — `warn!` for a local project,
`debug!` when the host binding says the directory belongs to another host.
`read_status_file` keeps its signature and delegates, so all fourteen existing
callers are byte-unchanged; none was touched.

D1b — and the class, not just the instance: a floor is only a floor if the scan
saw everything. `recovered_generation_counter` now takes `saw_everything`, and
any unreadable record makes even a HEALTHY counter file recover to
`max(file, floor, now)`. Previously a counter that parsed fine but had been
rolled back (a backup restore, a disk snapshot) took the clock-free branch and
stayed below a stamp sitting in a file the scan could not open. Stated plainly
in the code: an install with a permanently unreadable project — a satellite
whose `.ccteam/` lives on another host by design — is effectively clock-floored
on every boot, its generations jumping to ~unix-seconds once and staying
monotonic. They are opaque u64, nothing reads them as a count, and no
generation is ever reused. The fully-readable case keeps its clock-free branch.

D2 — the `chmod 000` test returned silently under root, so root CI never
exercised the permission path at all. The floor scan now takes its reader as a
parameter (`SessionRecordReader`; production is `DiskSessionRecords`), and a
test reader fails on demand — covering an unreadable meta, an unreadable
`status.json`, and the fully-readable control on every uid. The chmod test
stays as a belt that proves the real denied path end to end, but it now
announces its root skip and its doc names the injected test that carries the
rule when it cannot run.

Tests, red-first: the pure table gains the `saw_everything` axis (an
uncertifiable floor under a healthy counter recovers: `(9, false)` became
`(1800000000, true)`); and the injected-reader test asserts an unreadable
`status.json` counts exactly like an unreadable meta (`(0, 0)` vs `(0, 1)`) and
that a hidden stamp of 7 can never be re-issued either way.

Co-Authored-By: firstintent <239836187+firstintent@users.noreply.github.com>
…d model — issue #14

Two production bugs root-caused from the owner's live daemon (s487 /
Telegram 339498819):

1. Focus key mismatch dropped the IM leg of every delegation-completion
   answer. `current_session` was keyed by ChatKey incl. user_id (sender
   username on inbound) while `plan_unlocked_turn` reset reply_to to the
   owner-shaped key (user_id = chat_id) -> is_focused false -> internal-
   origin answers went broadcast-only (web/SSE), never to Telegram.
   Fix at the layer: focus identity is the CHAT (channel+chat_id) —
   ChatKey::focus_key + FocusRoutes (private map, poison-recovering,
   route-validity-filtered load collapsing legacy member-keyed rows).

2. Cold resume / rebuild / role-switch came back on a different model
   and effort: the plan builders read meta.model only; status.json (the
   vendor's own reported model incl. [1m], and applied effort) was never
   consulted — persisted_session_model's caller had been lost in
   084d17d. Fix: respawn_tuning (status.json -> meta -> role; effort
   status.json -> meta), with observations stamped by thread generation
   (SpawnCtx.generation -> ThreadStatus.generation -> meta pin) so a
   retired thread's late write can never retune a session; the
   generation counter is persisted, recovers a floor from the durable
   record, and treats any unreadable record as a recovery condition
   (wall-clock floor) so a generation is never reused.

7 commits, 5 checker rounds (codex s524) to CONFIRMED + releasable.
Baseline im 681->698, harness 580->581; fmt/clippy clean.
Brings the #14 focus/generation work (8 commits, e175428) onto the
dynamic-workflow branch; main (9e5b7c3) was already an ancestor.

Conflict resolution:
- crates/ccteam-flow/src/orchestrator.rs: modify/delete. The file was
  removed when ccteam-flow was rebuilt as the runner core (8089580);
  dev's only edit was the mechanical `generation: 0` field. Deletion kept.

Merge adaptations (both sides' intent preserved):
- codex_app_server_test::fake_spawn_ctx (#189 helper) gains the
  `SpawnCtx.generation` field dev introduced.
- gateway::tests::the_real_notifier_delivers_a_childs_completion_to_the_im_thread
  (dev, #14) matched `done · turn`; the completion header now carries the
  vendor (`<sid> done · <vendor> · turn N`, #186), so it matches
  ` done · ` + ` · turn 1` like the other notification tests.

Gate: fmt, clippy --all-targets, test-baseline (727 im-lib), harness
codex_app_server_test, web flow_runs/openapi/agents/status, flow, hooks.

Co-Authored-By: firstintent <239836187+firstintent@users.noreply.github.com>
…e is an on-ramp — docs-local/issues/#16

The Team → Runs (编排) tab told every busy project "scan window full —
older runs scrolled out; this list is incomplete", including projects that
had never run a flow: the endpoint tailed a fixed 5000-row window of
progress.jsonl and reported the window overflowing as runs being lost.
Three envelope rows per run among thousands of chat rows means the window
was measured in the wrong unit, and its empty state offered one bare
command and no way in.

- journal: `tail_select` — the tail for SPARSE rows. `n` counts kept rows
  only, `Pick::{Keep,Skip,Corrupt}` keeps skips out of the corruption
  counters, a byte budget bounds the walk, and `has_more` is true when an
  (n+1)th kept row exists OR the budget ran out first. `tail_filter_map` /
  `last_valid` ride the same inner unchanged.
- core: `collect_recent_events_with_more` (one caller) → `collect_recent_
  events_where(n, max_bytes, pick)`.
- flow-runs: bound = 50 runs × 3 envelope rows, 64 MiB budget, byte
  prefilter on the three kind names before any JSON parse. `truncated` now
  means exactly "a run exists that is not listed"; a busy project that never
  ran a flow answers empty and complete.
- SPA: intro in plain words with the guide linked; the empty state is a
  three-step on-ramp (`ccteam flow new <name>` → edit, examples pointer →
  `ccteam flow run .agents/flows/<name>.flow.js`), commands copyable, each
  step saying where its output lands; truncated copy says only what is
  known. Zero listed + truncated still never shows the on-ramp.
- docs: usage(-cn) Team section now three tabs; the Flow guide's
  "Triggering" section gains "Watching it" with the honest edge (a run
  driven while the daemon was down is not on the ledger); README mention.

Gates: fmt clean; clippy 0; journal 8/8 (+2), flow_runs_test 10/10 (+2
new, 1 strengthened, 1 row-window test removed), web lib 190; lib baselines
harness 590 · core 645 · web 190 · im 727; web-check 735/735 (+1), tsc -b.
… issue #192

Claude's stream-json `result.result` carries only the LAST assistant text
block, and the translator preferred it over the accumulated stream. Any
reply the model wrote before a further tool call in the same turn
(typically an answer to a human who spoke mid-turn) vanished from
turns.jsonl, the IM reply and the delegation notification.

Measured on claude 2.1.258: one `assistant` event per content block;
text → tool_use → text yields `result.result == "<last text>"` only.

- translate: the answer = every top-level text block in stream order
  (paragraph-joined); `result.result` is appended only when the stream
  never showed that tail. Subagent blocks (`parent_tool_use_id`) are
  never part of the answer.
- recovery (restart path): same shape — fold every text block of the
  turn up to its `end_turn` message instead of the last message only.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
… turn boundary — issue #193

`/goal <text>` (and every other vendor slash command ccteam passes
through verbatim) was written to claude's stdin the moment it arrived.
Claude executes a slash line only when idle; mid-turn the same line is a
steer the MODEL reads as prose — measured on claude 2.1.258: idle `/goal`
sets the goal (goal_status attachment written), the identical line sent
while a Bash tool ran was quoted back by the model as "another user
message". Nothing regressed between 37fb7f0 and cc97544; the report's
`/goal` attempts simply landed while the planner was working.

- The adapter parks a passthrough slash line arriving while `active_turn`
  is set (per-session FIFO) and answers with a receipt that says when it
  runs; the status tap writes the next parked line right after each
  `result`, so the CLI executes it as a command. Check-and-park and
  clear-and-pop share one lock, so a directive racing the `result` is
  never parked behind a turn that already ended.
- Plain text sent mid-turn is unchanged (still a steer).
- Fake claude gains a slow-first-result mode + user-line log; new
  integration test covers park → receipt → delivery after the turn.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…lts for notify and agent_read — issue #194

A ccteam-authored turn (a delegation completion notification) was written to
the parent's stdin the moment it arrived. On claude that is shown to the model
twice whenever the parent is mid-turn: once as a `queued_command` preview
inside the running turn, again as the prompt of the next one. Measured on the
reporting planner's own transcript (s1480): 7 of 89 notifications took that
path; the `<task-notification>` rows the report blamed were the planner's own
background Bash tasks — ccteam never emits that tag.

- Gateway routes by origin: a human's message is `Inject` (they are steering),
  a ccteam-authored one is `Queue` (a distinct follow-up turn).
- `TurnRouting` is a preference, `TurnDisposition` the truth: stream-json
  implements `Queue` by parking the line behind the running turn (the #193
  deferred queue, now shared with slash commands) and writing it right after
  the `result`, where it is read exactly once. The parked queue is mirrored to
  `<chat>/<sid>/deferred-input.json` and replayed by the next `start_thread`
  of the sid, so a daemon restart mid-turn loses nothing (at-least-once).
  tui / bg / codex exec / codex app-server / pi take their only channel and
  report the disposition instead of refusing `Queue` as NotImplemented; ACP
  already queues natively.
- `notify` defaults to `brief` (500 chars); `final` (2000) on request.
- `agent_read{sid}`: `n:0` = status only (no text); `remaining` counts the
  matching turns off the page and `latest` names the newest turn whenever the
  page does not end on it, so a `since` + `n:1` read can no longer pass for
  the newest answer; `truncated` now means text was cut; `max_chars` default
  1000, floor 100 (a `max_chars:300` request is honoured, not widened).
- Every truncated excerpt — notification, inline result, transcript row — ends
  with the exact `agent_read{…}` call that reads the whole turn.
- Tool descriptions state the defaults and that a completion notification is
  never polled for; byte gates rebalanced (status / agent_stop / agent
  trimmed). DSH tool mirror, docs/mcp*.md and docs/orchestration*.md follow.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…flushed lazily — review of 36c8c24 (issue #194)

Checker findings on the stream-json park queue (codex s1592), each verified
against the code:

- A parked `Queue` line is now mirrored to disk BEFORE the adapter answers
  `Queued`: the delegation notifier spends the child's watch on that answer,
  so a line that lived only in memory turned a daemon crash into a lost
  notification. The mirror is written under the queue lock; when the disk
  refuses, the line is taken back and written mid-turn instead (reported as
  `Injected`: read twice by claude, never lost). A slash command keeps the
  best-effort mirror — it is not an at-least-once contract.
- A line leaves the queue only once it is written: a failed flush (the child
  is gone) puts it back at the front, and the disk copy follows a successful
  write instead of preceding it.
- No eager replay at `start_thread`. The message that resumed the session is
  about to be submitted and gets a clean turn of its own — an eager replay
  made it a steer of a stale notification's turn (previewed and re-run by
  claude, the very double charge this fix removes). The parked lines are
  reloaded and flushed by the status tap after that life's first `result`,
  in order, one turn each.

Tests: replay-after-first-turn ordering, unmirrorable park degrades to the
mid-turn write; 29 stream-json integration cases green.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…cond review of 36c8c24 (issue #194)

Checker kimi (s1593) found the pre-d11a7a5c flush persisting a queue snapshot
cloned before the awaited write, so a park landing during the write was
overwritten by the stale snapshot. d11a7a5 already moved the mirror after
the write and under a fresh lock; the one gap left was the FAILURE path: the
line went back to the front of the in-memory queue, but a park that landed
while the write was in flight had persisted a queue without it, and the next
life of the sid reloads only what is on disk. The mirror is now re-synced
under the same lock that restores the line.

Reviewed and left as designed (recorded in docs-local/issues/#5): a resumed
session's first message runs before the reloaded lines by intent (User or
Internal alike); the seconds between a flushed line and its canonical
`TurnStarted` read idle exactly as an ACP queued turn does today.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ops paying for pointers — issue #195

Found by driving the tools as an ordinary agent on the deployed build, not by
reading the code.

- A parent that blocks on `agent{wait}` (or `agent_read{sid,wait}`) got the
  same answer twice: inline, then again as a notification turn. The disarm
  that was supposed to prevent it ran after an event-broadcast hop and two
  file reads, while the notifier runs straight off the pump at the same
  boundary — measured 1 ms, so the disarm lost every time, and the duplicate
  cost the parent a whole turn of its own context to answer "I already
  reported that". The decision now happens when the wait is DECLARED: the
  watch carries the claim, the notifier hands that boundary to the waiter, and
  the claim is released on timeout. A boundary suppressed in the same instant
  a wait lapsed is reported back to the waiter, so neither path can drop it.
  Both the hire and the follow-up branch get it — they share `dispatch_task`.
- A bare `agent_read{sid}` was 73% pointer: `n` defaulted to the roster's 10
  and 1000 characters split ten ways left ~100 per turn against a 70-88
  character pointer. The transcript branch now answers with the newest turn
  alone (the roster keeps its 10), the newest row's pointer drops the `since`
  it does not need (89 → 57), a turn comes back whole when its pointer would
  cost more than the text it withholds, and a page too tight for its rows
  sheds whole rows into `remaining` instead of shredding every one — from the
  oldest end for a tail read, the newest for a forward one, so a cursor never
  walks past a turn the caller has not seen. Measured on a real session: 267
  chars of content became 944, pointers 733 became 56.
- `INLINE_RESULT_MAX_CHARS` joins the `final` tier (4000 → 2000): `notify`
  went frugal by default while the inline path — the one a correct
  orchestrator actually takes — kept a private tier four times the size.
- `HotConfig` could not see a rewrite that landed inside the filesystem's
  timestamp granularity: same mtime, stale parse served until some later
  edit. It now keys on (mtime, len) and refuses a parse that was not strictly
  newer than the mtime it saw — git's racily-clean rule. This was also the
  root of a baseline flake (`remote_fake_host_one_turn_resume_and_host_stamp`,
  1 in 3 in isolation; 6 of 6 green after).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…tract is gone

`4be69a11` gave an Ambient (verified-principal) caller the right to refine its
delegation edge with `parent_sid` — onto a live session in its OWN project,
refusing an unknown sid loudly. That is what the flow runner needs: it connects
as an enrolled client acting FOR the managed session that launched it, and
without the refinement its hires would mount as roots and the topology would
lose an edge that really exists.

`mcp_session_bearer_test` still asserted the contract that replaced: that a
declared `parent_sid` is inert for a caller holding a principal. CI does not run
the `ccteam-web` suite, so the test rotted on `dev` instead of failing anyone.

Rewritten to the shipped contract, all three branches: declaring your own sid is
inert, an unknown sid is refused with the project-scoped wording (identical for
"no such sid" and "someone else's sid", so monotonic sids are not an enumeration
oracle), and a live sibling in your own project is accepted.

Found by the v0.11.0 ship gate.

Co-Authored-By: firstintent <239836187+firstintent@users.noreply.github.com>
…CP surface

Ship gate for the post-v0.10.5 cycle (58 commits since the tag).

Version lockstep: workspace `Cargo.toml` 0.10.5 -> 0.11.0; `@ccteam/ccteam-ui`
version + `ccteam.engine` + the four `@ccteam/engine-*` optionalDependencies +
the lockfile + `PACKAGE_VERSION` / `ENGINE_VERSION` (all asserted equal by the
plugin's own tests); the embedded `ccteam-ui.tgz` repacked, two-path
reproducible.

Docs describe current capability, not a changelog:

- README gains orchestration-in-code as a fifth way to drive ccteam — a flow is
  a JS script the runner executes over real cross-harness hires, journaled and
  resumable, with explicit brakes; and the pre-agent policy hook that gates
  every delegation whether a human, an agent or a flow made it. The doc list
  gains hook-dynamic-workflows.md.
- `docs/usage.md` and `docs/usage-cn.md` gain the `flow` command group:
  new / run / eval, the brakes and their defaults, the run journal and
  `--resume`, and how a run is attributed to the session that launched it.

Also retires the last "eight tools" copy — the surface has been six since
`37fb7f00`. The engine's own line now states no count, so it cannot rot again.

Co-Authored-By: firstintent <239836187+firstintent@users.noreply.github.com>
@firstintent
firstintent marked this pull request as ready for review September 4, 2026 21:31
@firstintent firstintent changed the title post-v0.10.5 cycle v0.11.0 — the flow engine, the policy hook, and a six-tool MCP surface Sep 4, 2026
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 4, 2026

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review Completed 2026-09-04T21:38:20.974678Z e6ab15e Draft marked ready
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@firstintent
firstintent merged commit 3ed05d4 into main Sep 4, 2026
3 checks passed

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: e6ab15eb72

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

};

let ident = CallIdent {
seq: self.seq.fetch_add(1, Ordering::SeqCst),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Derive resume identity independently of completion order

When pipeline() items finish a stage in a different order, later agent() calls receive sequence numbers in completion order. On resume, cached earlier stages resolve in deterministic item order, so the same unchanged calls can receive different seq values; Journal::lookup then treats the first mismatch as a changed script, invalidates the remaining prefix, and re-hires work that was already completed and paid for. Use a stable per-item/stage call identity rather than this runtime counter.

Useful? React with 👍 / 👎.

Comment on lines +651 to +655
} else if let Some(sid) = &opts.sid {
// A script-named sid is somebody else's session; the script owns
// its lifetime, so it is never stopped implicitly.
self.note_session(sid, true);
(sid.clone(), self.client.follow_up(sid, task).await)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Make named-session follow-ups crash-idempotent

For agent(task, {sid}), the task is dispatched and awaited without first appending an in-flight journal entry or sending an idempotency_key. If the flow process exits after the daemon accepts the dispatch but before this await returns, the resumed run sees a journal miss and sends the same task to the same session again. Persist the dispatch anchor before waiting and give this agent call a stable idempotency key.

AGENTS.md reference: AGENTS.md:L62-L62

Useful? React with 👍 / 👎.

return outcome;
};
let mut state = self.lock();
let seen = state.charged.entry(sid.to_string()).or_insert(0.0);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Seed the cost baseline before following up a named session

On the first follow-up to a pre-existing session, charged has no entry, so the session's cumulative cost_usd is subtracted from zero. A session that had already spent $10 and incurs a $0.10 follow-up is therefore charged as $10.10 to this flow, corrupting totals and potentially tripping --budget or --max-cost immediately. The pre-dispatch agent_read should seed the prior cumulative total before the new turn is charged.

Useful? React with 👍 / 👎.

Comment on lines +479 to +480
match std::fs::create_dir_all(&dir) {
Ok(()) if dir.read_dir().is_ok_and(|mut e| e.next().is_none()) => {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Claim autogenerated run directories atomically

When two processes start the same flow during the same second, both can successfully execute create_dir_all for the same path and both can observe it as empty before either writes the journal. They then share a run ID and concurrently append to the same journal/results directory, corrupting resume state and ledger attribution. Create the parent separately and claim each candidate with an exclusive single-directory creation operation.

Useful? React with 👍 / 👎.

Comment on lines +29 to +31
pub const SCHEMA_RETRY_PROMPT: &str =
"Your reply did not match the requested JSON schema. Reply with ONLY the JSON value \
itself — no prose, no explanation, no code fence.";

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Remove the built-in schema retry prompt

Whenever schema validation fails and the workflow did not provide retry.prompt, the engine injects this repository-owned instruction as a new agent turn. That makes agent behavior depend on built-in prompt content rather than user-owned workflow content; require the workflow to supply its retry text or expose a non-prompt structured-output mechanism instead.

AGENTS.md reference: AGENTS.md:L67-L67

Useful? React with 👍 / 👎.

Comment on lines +2460 to +2462
gw.session_views()
.into_iter()
.find(|v| v.sid == declared && v.project == caller_slug)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Apply the session-owner ACL to parent_sid refinement

For an operator/unowned project containing sessions created from different IM chats, matching only sid and project lets any ambient session nominate another chat's live session as parent_sid. The new child's completion notification is then delivered as a user-role turn to that unrelated session, bypassing the required chat-level session isolation. Validate the target with the shared session-owner visibility policy, not merely project equality.

AGENTS.md reference: AGENTS.md:L57-L57

Useful? React with 👍 / 👎.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant