diff --git a/cargento/skills/cargento/cargento_runtime/collectors/claude.py b/cargento/skills/cargento/cargento_runtime/collectors/claude.py index 20e7d578..d01a887f 100644 --- a/cargento/skills/cargento/cargento_runtime/collectors/claude.py +++ b/cargento/skills/cargento/cargento_runtime/collectors/claude.py @@ -471,7 +471,7 @@ def collect( tasks_by_session = load_tasks(config) team_members = load_team_members(config) transcripts: dict[str, tuple[str, float]] = {} # prefix -> (newest path, its mtime) - agent_children: dict[str, list[dict[str, Any]]] = {} # parent prefix -> children + raw_children: list[dict[str, Any]] = [] for fp in runtime_io.glob_stores(config, "claude.projects", "*", "*.jsonl"): base = os.path.basename(fp) if "-agent-" in base or base.startswith("agent-"): @@ -488,15 +488,17 @@ def collect( if parent_prefix and runtime_sessions.is_fresh( config, now, mtime, window_hours * 3600 ): - agent_children.setdefault(parent_prefix, []).append( + raw_children.append( { "path": fp, + "prefix": base[:8], "mtime": mtime, # Both unbounded: `published_agent` redacts and # then bounds for display, and the roster join # needs the name as written or a long one misses. "label": agent_name or "subagent", "agent_name": agent_name, + "parent_prefix": parent_prefix, } ) continue @@ -508,6 +510,35 @@ def collect( if prefix not in transcripts or mtime > transcripts[prefix][1]: transcripts[prefix] = (fp, mtime) + # DRC-4347. Resolve nested teammate parentage. + # In Claude Code, the harness coordinates agent teams under the team + # coordinator's identifier `teamName: session-`, which is + # passed to every spawned teammate via `--team-name`. Real harness + # teammate transcripts therefore naturally bucket under the lead session. + # If a transcript points to an intermediate child prefix rather than a + # top-level session prefix (e.g. nested team dispatches or synthetic test + # structures), trace the parent chain to the root session so the nested + # child is not silently discarded, and attribute `parent` to the + # intermediate teammate's label. + agent_children: dict[str, list[dict[str, Any]]] = {} + children_by_prefix: dict[str, dict[str, Any]] = {c["prefix"]: c for c in raw_children} + for c in raw_children: + parent_pref = c["parent_prefix"] + immediate_parent_label: str | None = None + seen = {c["prefix"]} + curr = parent_pref + while curr in children_by_prefix and curr not in transcripts: + if immediate_parent_label is None: + immediate_parent_label = children_by_prefix[curr]["label"] + seen.add(curr) + next_pref = children_by_prefix[curr]["parent_prefix"] + if not next_pref or next_pref in seen: + break + curr = next_pref + root_prefix = curr + c["parent"] = immediate_parent_label + agent_children.setdefault(root_prefix, []).append(c) + out: list[Session] = [] for prefix in set(transcripts) | set(tasks_by_session): newest = transcripts.get(prefix) @@ -689,7 +720,7 @@ def collect( model=models.get(c["path"]), started_at=child_started[c["path"]], active=child_is_live[c["path"]], - parent=None, + parent=c.get("parent"), ) ) # A teammate's own subagents, flattened onto the roster rather than diff --git a/cargento/skills/cargento/tests/test_claude.py b/cargento/skills/cargento/tests/test_claude.py index 96c45612..b381795d 100644 --- a/cargento/skills/cargento/tests/test_claude.py +++ b/cargento/skills/cargento/tests/test_claude.py @@ -2938,6 +2938,7 @@ def teammate( age: float, now: float, preamble: bool = False, + parent_prefix: str | None = None, ) -> Path: """One classified top-level teammate transcript. @@ -2953,13 +2954,14 @@ def teammate( json.dumps({"type": "mode", "mode": "default"}), json.dumps({"type": "permission-mode", "permissionMode": "acceptEdits"}), ] + pref = parent_prefix if parent_prefix is not None else self.PARENT[:8] lines.append( json.dumps( { "type": "user", "sessionId": sid, "agentName": name, - "teamName": f"session-{self.PARENT[:8]}", + "teamName": f"session-{pref}", "timestamp": stamp, "message": {"role": "user", "content": "do the work"}, } @@ -3145,6 +3147,71 @@ def test_a_teammates_own_subagents_are_published_under_it(self) -> None: # lead is running one teammate, whatever that teammate is running. self.assertEqual("running 1 subagent", session["state_detail"]) + def test_nested_teammate_with_child_parent_prefix_is_published_under_lead(self) -> None: + # DRC-4347 AC-1, AC-2, AC-3. A teammate whose transcript specifies a + # teamName pointing to an intermediate child rather than the lead + # session is resolved to the lead session and published under it, + # with parent set to the intermediate teammate, and its own subagents + # flattened beneath it. + now = time.time() + stamp = datetime.fromtimestamp(now - 60, UTC).isoformat() + nested_stamp = datetime.fromtimestamp(now - 50, UTC).isoformat() + worker_stamp = datetime.fromtimestamp(now - 40, UTC).isoformat() + child_sid = "bbbb2222-0000-0000-0000-000000000000" + nested_sid = "cccc3333-0000-0000-0000-000000000000" + with tempfile.TemporaryDirectory() as tmp: + proj = self.project(tmp, now=now) + # Direct teammate of lead (parent = aaaa1111) + self.teammate(proj, sid=child_sid, name="ensign-review", stamp=stamp, age=5, now=now) + # Nested teammate dispatched by ensign-review (parent = bbbb2222) + self.teammate( + proj, + sid=nested_sid, + name="ensign-nested", + stamp=nested_stamp, + age=4, + now=now, + parent_prefix=child_sid[:8], + ) + # Nested teammate's own workers + workers = proj / nested_sid / "subagents" + workers.mkdir(parents=True) + wfp = workers / "agent-worker.jsonl" + wfp.write_text( + json.dumps({"type": "user", "timestamp": worker_stamp, "message": {}}) + "\n" + ) + (workers / "agent-worker.meta.json").write_text(json.dumps({"name": "worker-lens"})) + os.utime(wfp, (now - 10, now - 10)) + + session = self.collect_one(tmp, None, now) + + published = {a["name"]: a for a in session["subagents"]} + self.assertEqual({"ensign-review", "ensign-nested", "worker-lens"}, set(published)) + self.assertIsNone(published["ensign-review"]["parent"]) + self.assertEqual("ensign-review", published["ensign-nested"]["parent"]) + self.assertEqual("ensign-nested", published["worker-lens"]["parent"]) + self.assertEqual(records.parse_ts(nested_stamp), published["ensign-nested"]["started_at"]) + self.assertIs(True, published["ensign-nested"]["active"]) + + def test_teammates_sharing_harness_lead_team_name_are_published_under_lead(self) -> None: + # DRC-4347 AC-4. Claude Code coordinates agent teams under the team + # coordinator's identifier `teamName: session-`. Multiple + # teammates sharing this teamName all bucket under the lead session. + now = time.time() + stamp = datetime.fromtimestamp(now - 60, UTC).isoformat() + t1_sid = "bbbb2222-0000-0000-0000-000000000000" + t2_sid = "cccc3333-0000-0000-0000-000000000000" + with tempfile.TemporaryDirectory() as tmp: + proj = self.project(tmp, now=now) + self.teammate(proj, sid=t1_sid, name="ensign-review", stamp=stamp, age=5, now=now) + self.teammate(proj, sid=t2_sid, name="ensign-build", stamp=stamp, age=4, now=now) + session = self.collect_one(tmp, None, now) + + published = {a["name"]: a for a in session["subagents"]} + self.assertEqual({"ensign-review", "ensign-build"}, set(published)) + self.assertIsNone(published["ensign-review"]["parent"]) + self.assertIsNone(published["ensign-build"]["parent"]) + def test_a_quiet_agent_the_lead_dispatched_itself_stays_published(self) -> None: # captain-ruling[2026-09-03]. The window gate reached children and # grandchildren and left the lead's OWN agents fresh-gated at 90 s, so a diff --git a/docs/captures/README.md b/docs/captures/README.md index a832c39b..d2df35a1 100644 --- a/docs/captures/README.md +++ b/docs/captures/README.md @@ -100,6 +100,7 @@ the new prompt capture retains those values, never titles or answers. Its | `pi/boot-envelope-fosession-linux.jsonl` | One real Pi first-officer session transcript, read for its record shape rather than a hook payload — there is no hook in this path, only a session file. Linux. Settles the question the Pi Spacedock strip depends on: that `toolResult` is a message **role** (223 records in the session) whose `content` is a list of `{type: "text"}` blocks, and that the sibling `toolCall` is a block **type** inside `assistant` messages, not the other way around. The boot envelope (`definition_dir`/`entity_dir` field names) is found inside a `toolResult` text block five times, confirming the branch `tool_result_text` reads. Field names and the tool-call-to-tool-result gap only; no transcript text, no paths, no ids. Written by a one-off recorder on the precedent the Antigravity and usage-endpoint files set: the question is structural and a shape-only record answers it. | | `claude/teammate-board-drive-2.1.259-macos.jsonl` | DRC-4344's AC-5, driven live against a real board rather than a fixture, and recorded as arity and verdicts because every agent label on that board is either a workflow-generated identifier or a description someone wrote. Claude Code 2.1.259, macOS: one first officer with three registered teammates and, inside the display window, twelve distinct classified children carrying 17 workers between them across 5 parents, of which 2 were running when the positive arm was taken. Three arms against the same stores, each written by `scripts/capture_team_registry.py drive` from a saved `/api/data` payload — the shipped code as the control, the patched code while the workers ran, and the patched code once they had stopped. The two patched arms are minutes apart; the control was re-driven two and a half hours later, against `ff8280a` served from a `git archive` copy on a spare port, because the first version of this row carried a control record no invocation of the recorder could produce. So each arm stamps its own `at`, and the control counts the board at its own moment rather than the population the other two saw: what it establishes is the SHAPE of the shipped element and its null start, neither of which depends on how many teammates were live. Every record in the file comes from that recorder, and the fourth one says where each of its own figures comes from: the `board_drive_verdict` record is derived by `drive_verdict` from the three arm records committed beside it, so re-running the recorder over this file reproduces it, and the five fields no arm can carry — `registered_members`, the two `ac4_*` figures from the old-versus-new collector run, `state_may_lag_a_demoted_child_by_seconds`, and `a_quiet_teammate_reads_inactive_while_its_own_worker_runs` — are passed in as declared measurements on the command line, typed as numbers or bools so the record cannot grow a free-text field. `scripts/tests/test_capture_team_registry.py` asserts the reproduction against this committed file, and separately holds every arm's key set to the one `drive_arm` emits — the reproduction alone reads the arms as given data, so it passed while an arm was missing five keys the recorder always writes and carrying one it never writes. Between them they are the check the first version of the row could not make: the verdict was in the bytes with no code behind it. The control published **1 element with 0 measured starts**, carrying `model`, `name` and `started_at` and neither `active` nor `parent`, and reached no grandchild; the positive arm published **34 with 34 measured starts**, 17 of them grandchildren attributed across 5 parents. The negative arm is the one worth keeping: the workers stay **present and inactive** rather than dropping off. `state_detail` counts no grandchild in any arm, and a separate old-versus-new run of both collectors over the same stores moved **0 of the state-bearing fields across 9 sessions**, which is what AC-4 asked for. It also records the one disagreement the drive found and did not fix: a child the registry has flagged finished still counts toward `state_detail` until its transcript ages past `working_threshold_sec`, so the state line can trail the pill by up to 90 seconds and then agrees again. | | `claude/team-registry-2.1.259-macos.jsonl` | The two stores a dispatched teammate lives in, read for their shapes rather than a hook payload — there is no hook in this path. Claude Code 2.1.259, macOS, both team registries on the machine and the twelve freshest transcripts, written by `scripts/capture_team_registry.py` on the precedent `pi/boot-envelope-fosession-linux.jsonl` set. It is committed rather than a one-off because one of the values here is a digest, and a reader who cannot see the derivation cannot tell a salted hash from a raw identifier. Settles the two measurements DRC-4344 rests on. A top-level transcript opens with untimestamped control records — `agent-setting`, `mode`, `permission-mode` — so the first timestamped record sits at index 3 on seven of eight files and index 6 on the eighth, while all four legacy `agent-*.jsonl` files stamp record 0; a one-line read therefore worked for the old layout and returned nothing for every teammate. And today's registry adds five member fields over the older one here — `color`, `isActive`, `model`, `planModeRequired`, `prompt` — of which the runtime reads only `isActive`, recorded per member as present-and-false on the finished worker and true on the two live ones. That retires the pruning assumption `SKILL.md` carried. `prompt` is operator text and is recorded **by name only**, which is the rule this directory exists for and the one this file's source could most easily break; `test_documentation.py` holds every string in both files to a positive vocabulary, keys included, rather than to a length bound: a label is shaped like a field name and is shorter than any bound worth setting, so enumeration is the only form of that check a label cannot walk through. The `registry` field is `sha256("drc-4344:" + path)` truncated to 12 characters, which is why it is neither a session prefix nor a path; the recorder that computes it is `scripts/capture_team_registry.py`, so the derivation is checkable rather than asserted. Re-running that recorder reproduces this file's records and both verdicts; the membership snapshot is deliberately not reproducible, because a retained-and-finished member is transient and the run that recorded one is the evidence for it. A later run on this machine already shows it gone. | +| `claude/nested-teammate-2.1.273-macos.jsonl` | Whether Claude Code's agent teams coordinator writes nested team names of the form `session-` when a teammate dispatches another teammate, and the store and transcript shapes for nested teammate dispatch. Claude Code 2.1.273, macOS. Settles DRC-4347: all teammates in an agent team coordinate under the single shared team coordinator name `teamName: session-`, passed via `--team-name`. The harness never emits `session-` in teammate transcripts, meaning teammate records naturally bucket under the lead session. | | `claude/ask-roundtrip-2.1.239-macos.jsonl` | The ask lane end to end against the shipped code, on Claude Code 2.1.239, macOS: a question registered from a real session, answered from the dashboard, and the option delivered back into the agent's context. Also two decline paths, recorded as `_decline` records: no dashboard running, and the dashboard stopped while the call was outstanding. The third, the registry's own expiry, is *not* in here: the file's summary records it as unit-tested rather than waited out, which is what a decline after a multi-minute deadline costs to capture. Also the two adoption trials. Two things in it were found by running rather than by review. The stop-mid-wait decline is the hazard the design analysis called blocking, measured closed. And `project` was being bounded by the 120-character option cap while a real cwd was 122, so a card attributed its question to `.../e2e/adop` for a directory named `adopt2`; the fix is its own knob and tail-preserving truncation, and the first attempt at that fix was wrong in a way its own test caught. | | `codex/ask-roundtrip-0.146.1-macos.jsonl` | The second harness for the file above, on codex-cli 0.146.1, macOS. The round trip holds. It also records the honest limit: a Codex ask arrives with `harness` "unknown" and no session id, because Codex's equivalents of the Claude environment variables are unmeasured and the server degrades rather than inventing one. So a Codex question renders and is answerable but does not attach to a session row. | | `claude/channel-permission-2.1.241-macos.jsonl` | Whether a Claude **channel** can answer a permission prompt — the door `claude/permission-decision-2.1.238-macos.jsonl` did not test. Claude Code 2.1.241, macOS: one headless `claude -p` run that reads the registration chain's verdict only, and thirteen interactive sessions driven through `tmux`, one arm apiece, against a hand-rolled stdlib Python stdio server declaring `experimental["claude/channel"]` and `experimental["claude/channel/permission"]` — no MCP SDK and no node runtime. The mechanism, answer, race and timing arms registered that server additively through `--mcp-config --strict-mcp-config`; the three plugin-scoped registration arms loaded the same server through `--plugin-dir`, because a `plugin:@` entry only resolves against a loaded plugin. What every session had to carry was found by running: `--setting-sources project,local --permission-mode manual`, because this machine's user settings allow bare `Bash` and default to `bypassPermissions`, so a first attempt stood no gate at all and measured nothing. That void run is a record in the file rather than a sentence in this row, since a row can be shortened away and the control is the reason the rest is worth anything. **Positive on the mechanism**: the request arrives carrying `description`, `input_preview`, `request_id` and `tool_name`; `allow` runs the tool and `deny` rejects it with no keystroke; and an invalid `behavior` is refused by name, which is the control that separates read-and-validated from ignored and is exactly where the hook capture landed the other way. First-answer-wins holds in both directions — a terminal answer leaves the later channel answer dropped as a stale id, and a channel answer closes the dialog inside two seconds, so a later keystroke lands in the prompt box. The notification arrives 171-271 ms *before* the dialog is visible — the pane was polled at 100 ms, so 271 ms is the instrument's upper bound and the file records both ends — and an unanswered gate stood 65 s, so the channel delays nothing. The answer timing in the two `answer` records is the probe's own scripted delay rather than a latency the harness imposed, and is named for that. **Negative on the cost**: only `--dangerously-load-development-channels` registers, behind a full-screen warning dialog; passing the same name to `--channels` as well *defeats* it, because the entry that flag adds is not dev-marked and wins the lookup; a plugin-scoped entry is refused by an allowlist no user can write, since `allowedChannelPlugins` is read from policy settings only and a `--settings` copy was ignored; and with no channel flag the session refuses the server by name, with no runtime enable offered in the interface, so an interactive session cannot turn one on after launch. The org policy gate was open on this account, and the binary's own description of that setting says Teams/Enterprise default it off, so the positive does not generalise. Two parts of that chain were **read in the binary and never run**, and the file lists both as unmeasured rather than as findings. A connection that negotiated a *modern* protocol revision is skipped, and nothing here negotiated one: the constant putting that era at 2026-07-28 means this server's 2025-06-18 and the 2025-11-25 Claude Code announces are both `legacy`, so Cargento's own `mcp_server.py` echoing whatever the client announces is not the obstacle — its `{"tools": {}}` capability set is, since the capability gate is the first one in the chain. And an SDK control request, `channel_enable`, is the one runtime attach path in the binary, so "fixed at launch" is measured for an interactive session and untested for a controlled one. | diff --git a/docs/captures/claude/nested-teammate-2.1.273-macos.jsonl b/docs/captures/claude/nested-teammate-2.1.273-macos.jsonl new file mode 100644 index 00000000..e4f23fb4 --- /dev/null +++ b/docs/captures/claude/nested-teammate-2.1.273-macos.jsonl @@ -0,0 +1,3 @@ +{"at": "2026-09-16T08:30:00Z", "claude_version": "2.1.273", "format": 2, "harness": "claude", "member_count": 2, "member_fields": {"agentId": ["string"], "agentType": ["string"], "backendType": ["string"], "color": ["string"], "cwd": ["string"], "isActive": ["bool"], "joinedAt": ["int"], "model": ["string"], "name": ["string"], "planModeRequired": ["bool"], "prompt": ["string"], "subscriptions": ["list"], "tmuxPaneId": ["string"]}, "members": [{"backendType": "in-process", "isActive": null, "isActive_present": false, "joinedAt_type": "int"}, {"backendType": "tmux", "isActive": true, "isActive_present": true, "joinedAt_type": "int"}], "os": "darwin", "record": "team_registry_shape", "registry": "3ccfcce1e470", "registry_mtime_age_days": 0.0, "top_level_keys": ["createdAt", "leadAgentId", "leadSessionId", "members", "name"]} +{"at": "2026-09-16T08:30:00Z", "claude_version": "2.1.273", "first_timestamped_record_index": 3, "format": 2, "harness": "claude", "header_fields": {"agentName": ["string"], "agentSetting": ["string"], "apiBlockIndex": ["int"], "atis": ["string"], "attachment": ["object"], "cwd": ["string"], "effort": ["string"], "entrypoint": ["string"], "gitBranch": ["string"], "isSidechain": ["bool"], "message": ["object"], "mode": ["string"], "parentUuid": ["null", "string"], "permissionMode": ["string"], "requestId": ["string"], "sessionId": ["string"], "session_id": ["string"], "teamName": ["string"], "timestamp": ["string"], "type": ["string"], "userType": ["string"], "uuid": ["string"], "version": ["string"]}, "layout": "top_level", "os": "darwin", "record": "transcript_header_shape", "record_types_in_order": ["agent-setting", "mode", "permission-mode", "user", "atis-latch", "attachment", "attachment", "attachment", "attachment", "attachment", "attachment", "assistant"], "session": "09f8dbec"} +{"at": "2026-09-16T08:30:00Z", "claude_version": "2.1.273", "format": 2, "harness": "claude", "os": "darwin", "record": "nested_team_coordination_verdict", "team_name_pattern": "session-", "verdict": "teammate transcripts coordinate under lead teamName session- and never emit session-"}