Skip to content

feat: policy hooks + ccteam Flow — programmable guardrail, dynamic-workflow engine, three-mode docs - #191

Draft
firstintent wants to merge 28 commits into
devfrom
feat/hook-dynamic-workflows
Draft

feat: policy hooks + ccteam Flow — programmable guardrail, dynamic-workflow engine, three-mode docs#191
firstintent wants to merge 28 commits into
devfrom
feat/hook-dynamic-workflows

Conversation

@firstintent

Copy link
Copy Markdown
Owner

Two engine capabilities plus their manual, examples, and three root-cause fixes the branch's own dogfooding surfaced. Spec/journal: docs-local/versions/v0-x-flow/prd.md (owner-side).

Features

  • pre-agent policy hook (fb5d98f): every MCP hire/dispatch runs <project>/.ccteam/hooks/pre-agent (global fallback; replace-never-merge; hot-edit; 3s process-group budget; stdin carries caller/request/usage/counts facts; exit 0/2+stderr dialect; fail-closed with deny≠fault wording).
  • ccteam Flow runner (8089580): ccteam-flow rebuilt (net −8k lines) as a QuickJS deterministic workflow runner — agent/parallel/pipeline/phase/log/args/budget/usage, determinism traps, per-run+per-vendor scheduler with brakes (never cancels in-flight), content-keyed journal with prefix resume + in-flight re-attach.
  • MCP client + ccteam flow run (2d5dcc8): every leaf is a real delegated session through the ordinary guardrails/budget/hook; enrollment reuses the existing credential slot; real-daemon integration tests prove resume-without-re-hire.
  • run attribution (4be69a1): flows launched inside a managed session hang their leaves under it (--parent/CCTEAM_CHAT_SID; server honors same-project declared parents; cross-project = non-disclosing miss to avoid a sid-enumeration oracle).
  • docs+examples (ea165a1, bc37b6d, 031f63d, ce97ad8): en+cn manual with the three-mode model (hook / Flow / Claude-native bridge), runnable hooks+flows incl. the live-tested branch-review and the flow-review evaluation loop.

Fixes (all found by this branch's own live test)

  • c48befc — released sessions stay dispatchable across daemon restart (live-only resolve in the R-M3 gate; red-line regression, predates branch).
  • 41cc57e — run/mcp-url re-recorded from the bound address (:0 binds).
  • c8de49e — McpFlowClient hardening, six confirmed defects (paging keeps failed empty rows; cursor retires only at boundaries; stuck=in-flight; per-poll deadline; envelope validation incl. JSON-null bodies; single-flight initialize).

Gates

ccteam-im 681→710 · ccteam-flow 49→121(+11 integration) · ccteam-cli 106→114 + flow_run_test 2 (real daemon) · clippy -D warnings clean · fmt clean. Every commit checker-confirmed (codex, 2-4 rounds each).

Owner decision points

version + merge order · deploy (server-side attribution + restart fix need the new daemon) · workspace rust-version 1.85→1.87 (rquickjs 0.12 wants 1.87; builds fine, declaration stale) · red-line wording amendments (PRD §3) · F1 (daemon-hosted durable runs) / W-perf / fleet scheduling.

Sanctioned baseline notes: ccteam-flow deleted-and-rebuilt (49→121); retired agent-team residue cut from ccteam-cli (bins 109→106, TODO-anchor test renamed 4→3); Cargo.lock +9 crates, zero version bumps.

🤖 Generated with Claude Code

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>
A second progress sink alongside the stderr renderer: the three
run-level events become flow-run hook submissions, so a run is visible
to the daemon and the web UI while it happens, not only to whoever is
watching the terminal.

Best-effort by construction, because observability must never change
what a run does:

- submission happens on one worker thread behind a channel, so an
  unreachable daemon costs the run a channel send, not an HTTP timeout;
- one thread, not one per event, keeps started strictly before finished
  on the wire;
- every failure is a warning and the run continues — nothing here can
  change a RunReport or an exit code;
- Drop closes the queue and joins, so the finished row is delivered on
  the error paths too (the runner emits RunFinished before returning
  Err, and run() propagates that with `?`).

A brake is remembered onto the terminal row: the runner reports a
braked run and a thrown script with the same ok:false, and the reason
is the only thing that keeps "hit its ceiling" apart from "broke".

The daemon address comes from the same resolver the run's own MCP
client uses, so "which daemon" has one answer per process; the token is
the admin web token, absent when a loopback daemon runs without auth.

run()'s diff is three hunks, all inside it: parent_sid hoisted so the
bridge stamps the same attribution the hires carry, the bridge built,
and the progress callback teed.

Gates: cli bins 114->119, clippy clean, fmt clean.

Co-Authored-By: firstintent <239836187+firstintent@users.noreply.github.com>
The read side of the flow-run envelope: fold the ledger's three row
kinds back into runs, newest first. Mirrors the evolution route — same
can_see_project check, same project_not_visible (404, not 403, so an
unowned slug's existence is not revealed), same honest-empty answer
when a project has never run a flow.

Status ladder: a started row with no partner is running; the terminal
row's ok decides ok vs error; a brake outranks both, because every
braked run also reports ok:false and "hit its ceiling" is a different
thing for a reader to act on than "the script broke". A brake counts
from the moment it trips — it refuses NEW admissions while in-flight
work keeps going, so a run that has only tripped one is braked, not
finished.

The journal is tailed backwards under a fixed window, so one request
costs the same on a 64 MiB journal as on a fresh one. A run whose
opening row has already scrolled out of that window is dropped rather
than shown with an invented start time.

Nothing is read from a run directory: those live on whichever machine
drove the run, which is not necessarily this one. That also keeps
ccteam-web free of a ccteam-flow dependency.

Gates: web lib 183->188, flow_runs_test 6 new, openapi_test route
inventory updated (exact-count assert), 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.
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