v0.11.0 — the flow engine, the policy hook, and a six-tool MCP surface - #190
Conversation
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>
|
Second commit on this cycle: The first fix unified the card; this one fixes the fact it could not obtain.
Gates: fmt ✓ · |
…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>
`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>
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
There was a problem hiding this comment.
💡 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), |
There was a problem hiding this comment.
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 👍 / 👎.
| } 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) |
There was a problem hiding this comment.
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); |
There was a problem hiding this comment.
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 👍 / 👎.
| match std::fs::create_dir_all(&dir) { | ||
| Ok(()) if dir.read_dir().is_ok_and(|mut e| e.next().is_none()) => { |
There was a problem hiding this comment.
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 👍 / 👎.
| 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."; |
There was a problem hiding this comment.
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 👍 / 👎.
| gw.session_views() | ||
| .into_iter() | ||
| .find(|v| v.sid == declared && v.project == caller_slug) |
There was a problem hiding this comment.
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 👍 / 👎.
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 engineccteam-flowis 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 isagent()/parallel()/pipeline()/phase()/log()/args/budget— deliberately the same shape as Claude Code's workflows, so a script moves over with no new vocabulary.sidfollow-ups reuse context,rolebehaves like an agent type.~/.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.--parallel(32),--max-agents(100),--max-cost/--budget,--watchdog.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).$CCTEAM_CHAT_SID,--parentto override), recorded onprogress.jsonlas run-envelope kinds and read back overGET /api/v1/projects/{slug}/flow-runs.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 — therouting.mdrule).ccteam initnever seeds one.0allows; exit2denies and relays your stderr verbatim (≤2000 B) to the calling agent; anything else, a timeout, or a non-executable file is a distinctpolicy_script_errorthat still refuses. A guardrail that opens when its script breaks is not a guardrail.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
agent/agent_read/agent_stop/status(+ the bare-name discovery aliasgrok_claude_codex_kimi) /chat_send_file. No aliases kept.initializeinstructions cut down to cross-tool policy plus one line of identity. No summarization anywhere — the engine still runs no LLM.agent_readgainswait(0–240 s long-poll). This was the missing primitive: callers were tailing a child'sturns.jsonlbecause there was no way to await an in-flight turn.notify:"all"deleted (it had been identical tofinalin the engine for a release);notify:"brief"added; notification and inline-result excerpts halved by default.statusand handed to the hook.docs/mcp.md(+ 中文).4. Fixes found by using the tools, not by reading them
#195an inlinewaitand 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 bareagent_readstopped being 73% pointer ·#194completion notifications are read once, with frugal defaults ·#193a slash command sent mid-turn defers to the turn boundary instead of landing as text ·#192the turn ledger keeps every text block, not just the last ·#189graceful Codex app-server termination, writer-lock holder diagnostics, 409 on a held thread ·#186web history seed merge, visible load failure, busy heartbeat ·#14a 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) ·McpFlowClienthardening,run/mcp-urlre-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.toml0.10.5→0.11.0;@ccteam/ccteam-uiversion +ccteam.engine+ the four@ccteam/engine-*optionalDependencies+ lockfile +PACKAGE_VERSION/ENGINE_VERSIONin lockstep; embeddedccteam-ui.tgzrepacked (two-path reproducible).README.mdgains orchestration-in-code as a fifth way to drive ccteam;docs/usage.md(+ 中文) gain theflowcommand group. Both describe current capability, not a changelog.mcp_session_bearer_teststill asserted that a declaredparent_sidis inert for a verified principal, a contract4be69a11deliberately replaced with "refine within your own project, refuse loudly on a miss". CI does not run theccteam-websuite, so it rotted unnoticed ondev.🤖 Generated with Claude Code