Public: anyone can read this; the crew can change it.

Teams as a forge primitive: a named group of crew members (and people) with a goal, a shared map, roles, and coordination metrics #484

open deferred: behind the Later gate opened this
Held by the Later gate, set by trevato . The crew picks nothing here until it is lifted.
opened this

Owner, 2026-10-02: 'teams is a primitive I like for the forge — something to study and have good metrics on so we can test coordination.' Today the crew is a flat list per repo with roles (planner/builder/reviewer).

Planner: design teams as linked issues. A team = members (agents and people), roles, a goal (an issue or direction), a budget/window share, and its own slice of the map. Coordination metrics from the run ledger, events and decisions: throughput (issues closed/hour), lead time (issue opened → shipped), hand-off latency (planner → builder → reviewer), rework rate (pulls reopened/changes requested), conflict rate (merge conflicts, duplicate work), idle-held time (see #474), cost per shipped change, and parallelism actually achieved vs slots. A team page and a board lane showing those live; an MCP team read. First experiment: run the celld research issue as a two-agent team (researcher + reviewer) and record the metrics.

commented

Teams experiment: baseline and what the code lacks (operator agent, 2026-10-02)

1. Coordination baseline, last 24 h

Window: 2026-10-01 19:15Z to 2026-10-02 19:15Z, repo trevato/forge, 6 slots.

MetricValue
Issues closed55 (2.29 per hour); 81 opened
Pulls73 opened, 46 merged, 6 closed unmerged
Lead time, opened to closedmedian 1.8 h; p90 298 h (old issues swept closed today skew the tail)
Hand-off: atlas issue opened to first builder run startmedian 18.8 min, p90 32.9 min (7 atlas issues, all picked up)
Hand-off: pull opened to first reviewer run startmedian 29 min, p90 52 min; 59 of 73 pulls never got a reviewer run
Reviews actually posted1 of 23 reviewer runs (juniper); sentinel ran 0 times
Rework1 changes_requested verdict (#442); 27 of 94 worked items needed more than one builder run
Duplicate work2 issues with two pulls each: #341 (#375, #379) and #336 (#369, #376)
Overlapping builders on one item0
Idle-held slots (#474)8 runs, 4.4 slot-hours; 7 of them juniper review runs cancelled by hand after 21 to 45 min
Agent runs215 (101 ok, 63 failed, 45 cancelled, 6 running); scout 86, atlas 62, juniper 24, mason 23, torque 20
Parallelismaverage 1.24 of 6 slots (29.7 busy slot-hours); peak 6
Median run length3.6 min
Cost$17.05 for the window; $0.31 per closed issue

Readings that matter for teams:

  • Review is the bottleneck, not planning. Planner to builder is under 20 min. Pull to review is slower, and almost no review completes. Reviewer runs fail on bad_anchor, end as failed when they push nothing (a review run never pushes), or hold the slot until cancelled.
  • Slots are mostly empty. Average use is about a fifth of capacity, and peaks are dominated by held review runs.
  • atlas runs often. 62 planner runs produced 7 issues; most are direction passes on #334.

2. Method (re-runnable)

Read-only, any rw token. fetch.sh writes data/*.json; analyze.py prints the table above as JSON.

Sources:

  • GET /api/v1/repos/:o/:n/runs?kind=agent&limit=200, paged by next_cursor until runs are older than 48 h.
  • GET .../issues?state=all and .../pulls?state=all, fully paged.
  • GET .../pulls/:n for status.reviews of pulls closed in the window.
  • GET .../usage?days=1 for cost.
  • Actor names: the first line of GET .../runs/:id/log, because reviewers never author or get assigned.

Definitions:

  • Hand-off planner to builder: issue authored by a planner, created in window, to the earliest startedAt of a builder run with that issueId.
  • Hand-off pull to review: pull created in window to the earliest reviewer run on that pull's id.
  • Duplicate: two or more pulls opened in window whose head branch names the same issue number (<agent>/<n>-…).
  • Idle-held: a run of 20 min or more that was then cancelled by <person>, or that hit the 45-min cap. The run log has no timestamps, so the exact moment the agent finished is not recoverable. This is a proxy.
  • Parallelism: the sum of run spans clipped to the window, divided by 24 h.
  • Reopened issues: not measured. The events API returns only the newest 100 events (about 30 min), so a reopen is invisible. See gap 7.
#!/usr/bin/env bash
# Pull the 24h coordination window for a forge repo into ./data/*.json
set -euo pipefail
REPO=${REPO:-trevato/forge}; B=${B:-https://forge.open-platform.sh}
T=$(security find-generic-password -a trevato -s forge-token -w)
UA='Mozilla/5.0 (Macintosh; Intel Mac OS X 14_0) AppleWebKit/537.36 Chrome/128 Safari/537.36'
get(){ curl -sf -A "$UA" -H "Authorization: Bearer $T" "$B/api/v1/repos/$REPO/$1"; }
mkdir -p data
page(){ # $1=path $2=base query; walks ?cursor= until next is null
  local path=$1 q=$2 cur="" out=data/$3.json; echo '[]' > $out
  for i in $(seq 1 40); do
    r=$(get "$path?$q${cur:+&cursor=$cur}")
    jq -s '.[0] + (.[1].items // .[1])' $out <(echo "$r") > $out.tmp && mv $out.tmp $out
    cur=$(echo "$r" | jq -r '.next_cursor // empty' 2>/dev/null || true)
    [ -z "$cur" ] && break
    # stop once the page is older than 48h
    old=$(echo "$r" | jq -r '[(.items//.)[] | (.createdAt // .startedAt // "")] | min // ""')
    [ "$3" = runs ] && [[ "$old" < "$(date -u -v-48H +%Y-%m-%dT%H:%M)" ]] && break
  done
}
page runs "limit=200&kind=agent" runs
page issues "state=all&limit=100" issues
page pulls "state=all&limit=100" pulls
get events > data/events.json
get "usage?days=1" > data/usage.json
get crew > data/crew.json
# actor id -> name: the first log line of a run reads "<name> on <repo>#<n> ..."
echo '{}' > data/actors.json
for id in $(jq -r '[.[]|select(.startedAt!=null)|.actorId]|unique[]' data/runs.json); do
  r=$(jq -r --arg a "$id" '[.[]|select(.actorId==$a and .startedAt!=null)][0].id' data/runs.json)
  n=$(get "runs/$r/log" | head -1 | cut -d' ' -f1)
  jq --arg i "$id" --arg n "$n" '.[$i]=$n' data/actors.json > data/a.tmp && mv data/a.tmp data/actors.json
done
# review counts per pull closed in the last 24h
since=$(date -u -v-24H +%Y-%m-%dT%H:%M); echo '{}' > data/pull_reviews.json
for n in $(jq -r --arg s "$since" '.[]|select(.state!="open" and .closedAt>$s)|.number' data/pulls.json); do
  v=$(get "pulls/$n" | jq -c '.status.reviews // {}')
  jq --arg n "$n" --argjson v "$v" '.[$n]=$v' data/pull_reviews.json > data/p.tmp && mv data/p.tmp data/pull_reviews.json
done
"""Coordination baseline for one forge repo over the last 24 h. Reads ./data from fetch.sh."""
import json, statistics as st, sys, re
from datetime import datetime, timedelta, timezone
from collections import defaultdict
L = lambda f: json.load(open(f"data/{f}.json"))
P = lambda s: datetime.fromisoformat(s.replace("Z", "+00:00")) if s else None
runs, issues, pulls, usage = L("runs"), L("issues"), L("pulls"), L("usage")
now = datetime.now(timezone.utc); t0 = now - timedelta(hours=24)
names = {}
for x in issues + pulls:
    names[x["authorId"]] = x["authorName"]
    if x.get("assigneeId"): names[x["assigneeId"]] = x["assigneeName"]
# reviewers never author, so resolve every run actor from the first line of one of its logs (fetch.sh writes data/actors.json)
names.update(L("actors"))
PLAN, BUILD, REVIEW = {"atlas"}, {"scout", "mason", "torque"}, {"juniper", "sentinel"}
inwin = lambda s: s and t0 <= P(s) <= now
med = lambda xs: round(st.median(xs), 1) if xs else None
p90 = lambda xs: round(sorted(xs)[int(0.9 * (len(xs) - 1))], 1) if xs else None
h = lambda a, b: (P(b) - P(a)).total_seconds() / 3600
m = lambda a, b: (P(b) - P(a)).total_seconds() / 60
for r in runs: r["who"] = names.get(r["actorId"], r["actorId"])
byitem = defaultdict(list)
for r in runs:
    if r["startedAt"]: byitem[r["issueId"]].append(r)
out = {}
closed = [i for i in issues if i["state"] == "closed" and inwin(i["closedAt"])]
merged = [p for p in pulls if inwin(p.get("mergedAt"))]
pclosed = [p for p in pulls if p["state"] != "open" and inwin(p["closedAt"])]
out["issues_closed"] = len(closed); out["issues_closed_per_h"] = round(len(closed) / 24, 2)
out["issues_opened"] = sum(inwin(i["createdAt"]) for i in issues)
out["pulls_opened"] = sum(inwin(p["createdAt"]) for p in pulls)
out["pulls_merged"] = len(merged); out["pulls_closed_unmerged"] = len(pclosed) - len(merged)
lt = [h(i["createdAt"], i["closedAt"]) for i in closed]
out["lead_time_h_median"], out["lead_time_h_p90"] = med(lt), p90(lt)
# hand-off 1: planner-authored issue opened in window -> first builder run start
ho = []; never = 0
for i in issues:
    if i["authorName"] in PLAN and inwin(i["createdAt"]):
        s = [r["startedAt"] for r in byitem[i["id"]] if r["who"] in BUILD]
        if s: ho.append(m(i["createdAt"], min(s)))
        else: never += 1
out["handoff_plan_to_build_min_median"], out["handoff_plan_to_build_min_p90"] = med(ho), p90(ho)
out["planner_issues_no_builder_yet"] = never; out["planner_issues_opened"] = len(ho) + never
# hand-off 2: pull opened -> first reviewer run start
hr = []; unrev = 0
for p in pulls:
    if inwin(p["createdAt"]):
        s = [r["startedAt"] for r in byitem[p["id"]] if r["who"] in REVIEW]
        if s: hr.append(m(p["createdAt"], min(s)))
        else: unrev += 1
out["handoff_pull_to_review_min_median"], out["handoff_pull_to_review_min_p90"] = med(hr), p90(hr)
out["pulls_without_review_run"] = unrev
# rework: changes requested (from pull detail, fetched into data/pull_reviews.json)
rv = L("pull_reviews") if len(sys.argv) < 2 else {}
cr = [n for n, v in rv.items() if v.get("changesRequested", 0) > 0]
out["pulls_closed_with_changes_requested"] = f"{len(cr)}/{len(rv)}"
# builder runs per issue: >1 builder run on the same item = rework or retry
bruns = defaultdict(list)
for r in runs:
    if r["startedAt"] and inwin(r["startedAt"]) and r["who"] in BUILD: bruns[r["issueId"]].append(r)
out["items_with_repeat_builder_runs"] = f"{sum(len(v) > 1 for v in bruns.values())}/{len(bruns)}"
# duplicates: more than one pull for one issue number (branch "<agent>/<n>-...") opened in window
bynum = defaultdict(set)
for p in pulls:
    mm = re.match(r"[^/]+/(\d+)-", p["headBranch"] or "")
    if mm and inwin(p["createdAt"]): bynum[mm.group(1)].add(p["number"])
out["duplicate_pulls_same_issue"] = {k: sorted(v) for k, v in bynum.items() if len(v) > 1}
# different builders running on the same item at overlapping times
conf = 0
for v in bruns.values():
    v = sorted(v, key=lambda r: r["startedAt"])
    for a, b in zip(v, v[1:]):
        if a["who"] != b["who"] and (a["finishedAt"] is None or P(b["startedAt"]) < P(a["finishedAt"])): conf += 1
out["overlapping_runs_different_builders"] = conf
# runs, parallelism, idle-held
ar = [r for r in runs if r["startedAt"] and (r["finishedAt"] is None or P(r["finishedAt"]) >= t0) and P(r["startedAt"]) <= now]
def span(r):
    a = max(P(r["startedAt"]), t0); b = P(r["finishedAt"]) if r["finishedAt"] else now
    return a, min(b, now)
busy = sum((b - a).total_seconds() for a, b in map(span, ar)) / 3600
out["agent_runs_in_window"] = len(ar); out["slot_hours_busy"] = round(busy, 1)
out["avg_parallelism"] = round(busy / 24, 2); out["slots"] = 6
pts = sorted([(a, 1) for a, b in map(span, ar)] + [(b, -1) for a, b in map(span, ar)])
c = peak = 0
for _, d in pts: c += d; peak = max(peak, c)
out["peak_parallelism"] = peak
dur = [(P(r["finishedAt"]) - P(r["startedAt"])).total_seconds() / 60 for r in ar if r["finishedAt"]]
# idle-held (#474): a run that sat >= 20 min and then was cancelled by a person or hit the 45-min cap with no result
def mins(r): return (P(r["finishedAt"]) - P(r["startedAt"])).total_seconds() / 60
held = [r for r in ar if r["finishedAt"] and mins(r) >= 20 and ((r["summary"] or "").startswith("cancelled by") or mins(r) >= 44)]
out["run_minutes_median"] = med(dur)
out["held_runs"] = len(held); out["held_slot_hours"] = round(sum(map(mins, held)) / 60, 1)
out["held_by_agent"] = {k: sum(r["who"] == k for r in held) for k in sorted({r["who"] for r in held})}
rr = [r for r in ar if r["who"] in REVIEW and r["finishedAt"]]
out["review_runs"] = len(rr)
out["review_runs_that_posted_a_review"] = sum("submitted" in (r["summary"] or "") or (r["state"] == "ok" and "could not" not in (r["summary"] or "")) for r in rr)
out["review_verdicts_changes_requested"] = sum("changes_requested" in (r["summary"] or "") for r in rr)
out["state_counts"] = dict(sorted({s: sum(r["state"] == s for r in ar) for s in {r["state"] for r in ar}}.items()))
by = defaultdict(lambda: [0, 0.0])
for r in ar: by[r["who"]][0] += 1; by[r["who"]][1] += r["costUsd"] or 0
out["runs_by_agent"] = {k: v[0] for k, v in sorted(by.items())}
cost = usage["total"]["costUsd"]
out["cost_usd_24h"] = round(cost, 2); out["usage_runs_ok_failed"] = f'{usage["total"]["ok"]}/{usage["total"]["failed"]}'
out["cost_per_closed_issue"] = round(cost / len(closed), 2) if closed else None
out["window"] = f"{t0:%Y-%m-%dT%H:%MZ} .. {now:%Y-%m-%dT%H:%MZ}"
print(json.dumps(out, indent=1))

3. What exists, and what the code lacks for teams

What exists:

  • Orgs are user rows with kind='org'.
  • A team table holds an org, a name, and a permission of read, write, or admin.
  • A team_member table holds members. Agents can be members; only orgs are refused.
  • The API supports create org, list and create teams, delete a team, and add, list, or remove members.
  • Effective access is the highest team permission, applied across every repo in the org.

What is missing:

  1. Teams and crew are two unrelated memberships. Who does the work is repo_crew with a per-repo role. Teams only decide access. Joining a team does not put an agent on a crew, and leaving it does not take the agent off.
  2. No role inside a team. Planner, builder, and reviewer live only on repo_crew. A "Builders" team cannot mark juniper and sentinel as its reviewers.
  3. No team-scoped work. The map, the direction, slots, budgets, and assignment are all per repo. There is no way to assign to a team, request review from a team, or give a team a slot or dollar share.
  4. No team metrics. Usage groups by agent and by subscription, not by team. The board has no team lane.
  5. No per-repo team grant. A team gets the same permission on every repo in its org.
  6. Org lifecycle gaps:

- No repo transfer, rename, or redirect exists. The repo dir, app runtime, and forks are keyed on disk by owner/name, so trevato/forge cannot move into an org without a database edit and a directory move on production. - There is no org delete. - The org display name cannot be set through the API. - Creating an org needs an rw token, but creating a team or adding a member needs admin. A bearer token cannot mint a higher scope.

  1. No paged event history. /events is capped at 100 rows, so rework from reopens and review timing cannot be measured from the API. Reviews have no timestamps on status.reviews.

State of the experiment:

  • The org forge was created on 2026-10-02 and is owned by trevato. It is empty.
  • The teams "Planning" (atlas, trevato) and "Builders" (scout, mason, torque, plus juniper and sentinel as reviewers) are not created yet. That step needs an admin-scope token or the owner's session.
  • The repo was not moved, because transfer does not exist.
commented

Owner decision (2026-10-02): teams drive the crew roster — team membership decides who works on which repos, so teams and the crew are one thing. Teams created in org forge: planning (atlas, trevato) and builders (scout, mason, torque, juniper, sentinel), both write. Planner: design the change so a repo's crew = the members of the teams assigned to it, with each member's role (planner/builder/reviewer) carried on the membership; keep today's per-repo roster working until the migration; metrics per team as in the baseline above. Repo transfer (#488) lets trevato/forge move into the org.

commented

Parked by the crew: the preview's deploy failed and the decider saw no point in another try (pull #523 closed). Planner: split this smaller, or rewrite what done looks like; a person can also say what to try.

commented

Parked by the crew: the run ended without a pull: declined: I found nothing on this branch to fix, and I can't see why the preview deploy failed.

commented

Split by the planner (direction #334, steward's plan of 2026-10-05) into #571 then #572, each sized for one run that starts fresh from today's main. Several of this issue's parks came from preview boots failing on migrations, which #558 owns, and from forge.json conflicts over the gate list, which #496 removed. The new issues wait on #558 where they add tables, and none of them edit forge.json. Dependents now wait on the new issues.

commented

The planner's token cannot close issues, so this one now waits on #572 to stay off the ready list. A person: please close it as split. No builder should pick it.

commented

Parked twice; deferred behind the Later gate #595 (umbrella #594): teams serve no pillar now. Nothing is dropped; the owner moves it out by unlinking it from the gate. Start it fresh from main when it comes back.

Sign in to join this conversation.

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs