fix(skills): refuse to overwrite an agent-authored skill of the same name (#2914) - #2920
Conversation
…name (#2914) Assigning a library skill whose name matched a `.claude/skills/<name>/` the agent wrote itself overwrote the agent's copy, gitignored and untracked the directory, and buried an `unmanaged_dir_overwritten` warning in the assignment response. The library and the agent's repo share one flat namespace on the agent side, so a name match is not proof of the same skill — the library silently won. The inject path now refuses a directory that exists without the platform's `.trinity-skill.json` marker before a byte is staged: no archive, no restore, no `.gitignore` line, no untracking, not listed under CLAUDE.md Platform Skills. `force` (manual Sync) does not override it; a platform-managed dir keeps upgrading in place; an unreadable probe keeps the pre-existing fail-open direction. The per-skill status is `conflict` — neither `injected` nor `failed` (overall `success` stays true so the fleet re-inject does not alarm on a standing conflict every auto-sync); `skills_conflict` + `conflicts[]` carry it, and `deliver_assigned` reports `conflict` / `partial` + `conflicts[]`. The verdict is recorded on the assignment row (`agent_skills.delivery_status`, SQLite `agent_skills_delivery_status` + Alembic `0065`), carried across the bulk-replace PUT for retained names, cleared by the next injection where the name lands, and gone on unassign. The Skills tab reads it off the rows so the badge, the explanation (which skill, the agent's own copy is what runs) and an inline "Unassign library skill" action show on a fresh load; `inject()` re-reads the rows so a resolved conflict clears without a reload. MCP passes the delivery block through, `sync_agent_skills` names conflicts on its success branch, and `get_agent_skills` carries `delivery_status` per row. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
merge-train: ejected from the 2026-09-21 train — rides the next one once fixed. Nothing was pushed to this branch. Saying the good part first, because it is most of the PR: the tests genuinely execute the changed path. I reverted only What blocks it is that marker-absence is treated as one case, and this PR's own code says it is two.
The new refusal at
Either way the next injection takes the new branch and returns Two consequences worth naming. The Skills-tab copy ( Reachability, honestly: this needs a pre-#384 agent image or a restore that drops the meta. Not the common path on current images — but both are live code paths, not hypotheticals, and the failure is permanent and silent when it fires. What unblocks it is a design call, which is why I parked it rather than patching in-train: either have Nine warnings also came out of the pass. Three are worth fixing while you are in there; the rest are in the thread if you want them:
Second blocker, which landed after the seeds finished: Base clean on all three seeds, head failing on two — a within-run head-vs-base differential, which is what that job exists to isolate. The underlying error is the signal guard firing: That is a test-isolation defect, order-dependent by construction, which is why it is seed-dependent. I do not think this PR introduced it — the diff adds zero subprocess/signal/threading/ I checked whether Either way it has to be resolved before merge — fixed, or established as pre-existing with evidence. Worth naming why this matters beyond the one PR: One process note, not a finding: |
…; delivery copy and draft fixes (#2914) Merge-train review on #2920: marker-absence was read as "the agent authored this" and refused, but the platform itself wrote marker-less directories on two live paths — the pre-#384 legacy fallback (SKILL.md only) and a restore that dropped the meta member — so each would have turned its own package into a permanent, unrepairable conflict on the next sync (AC#6 violated). Both are closed at the write: `_legacy_fallback` writes the marker beside SKILL.md (manifest = what was actually written), and `_restore_skill` writes the marker back directly when the agent's `restored` list lacks it (`marker_written_directly`). If the marker cannot be written the injection is reported `failed` (`marker_not_written`; the legacy write is rolled back) rather than a half-managed directory. Five tests cover the two paths, their failure arms, and the legacy-marker round trip that closes AC#6. Also from the review: `deliveryText` grammar ("its own a skill") with a real plural arm; the `not_delivered` arm now names conflicts beside the failure it retries; the Skills tab resets its draft only when the assigned SET changes, so a Sync (which re-reads the rows for the verdict) no longer wipes unsaved ticks; the conflict copy says "a directory the platform did not create — usually a skill it authored" rather than asserting authorship. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Re: merge-train ejection — fixed in
|
…md (#2914) Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…065 — the three 2026-09-21 schema PRs are stacked (Abilityai/trinity-enterprise#637) Three open PRs each added a 0065_* off 0064, which alembic-heads reads as three heads and `upgrade head` would apply nothing. #2924 now stacks on #2920 (base branch fix/2914-skill-name-conflict) and its revision is 0066 off 0065_agent_skills_delivery_status. The migrations.py append collision is resolved by keeping both entries, #2920's first. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`SkillsPanel.vue` had no spec at all, so the ~60 lines of conflict UI and the fix for the regression this PR itself introduced were both inert: disabling the whole conflict block AND reverting the draft-wipe fix left the suite byte-identically green. `inject()` re-reads the assignment rows (the conflict verdict rides them), so the panel sees a new `assigned` array after every Sync even when the set is unchanged. The shipped defect was a deep watch on that array wiping unsaved ticks; the fix watches the set identity. Nothing executed it. Two tests, mounted (the ent#625 / #2918 harness), and negative-controlled in both directions so neither can be satisfied by the other's cheat: revert to `watch(() => store.assigned, resetDraft, {deep:true})` -> "keeps an unsaved tick when Sync re-reads the SAME set" FAILS delete the watcher outright ("never reset" also passes test 1) -> "still follows a real change to the assignment set" FAILS Full frontend suite: 142 files, 3246 passed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
merge-train — one test pushed to this branch (
|
…s-wide caplog, stale release allowlist (#2935) * fix(tests): three reds that keep CI honest-but-wrong — a guard race, a process-wide caplog, and a stale release allowlist Three failure classes have been redding `backend-unit-test` on dev and the regression diff on unrelated PRs (#2920, #2924, #2927, the 09-18 train): 1. `test_subprocess_pgroup` — `signal_guard.guarded_killpg` walked the group's members, then read each member's cgroup; the harness parent exiting in between (that IS the scenario under test) made `_cgroup_of` answer None, which read as "outside the session cgroup", and a kill of a group that was entirely ours a millisecond earlier was refused. Membership is now decided per LIVE member: a pid that vanished (or is a zombie) is not a member. A live pid whose cgroup cannot be read stays foreign — fail closed, unchanged. Two tests pin both halves; reverting the guard turns the race test red. 2. `test_2789…test_retry_budget_is_logged_with_its_cause` asserted over EVERY caplog record in the process, so a background task left by an earlier test (order-dependent under a random seed) logging an unrelated ERROR read as `['ERROR', 'WARNING'] == ['WARNING']`. It now asserts over the module's own logger. 3. `test_2814_workflow_trigger_parity` — every ACCEPTED_UNTIL_RELEASE entry is declared on `main` since the v0.9.5 cut and the guard has said "prune these" on every run since. Pruned, as the guard was designed to demand. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(tests): finish the caplog narrowing — six process-wide reads survived `mine()` was introduced to stop a background task's unrelated record from reading as this test's own, and applied at four sites. Six reads of the process-wide `caplog.records` survived in the same function, after the last `mine()` call — including `:523`, the exact shape the fix was written for, and two `assert not caplog.records` that any stray record from any logger reddens. Verified by negative control: with an unrelated ERROR emitted inside the third phase's `caplog.at_level` window, the pre-fix assertions fail at `assert "30s already spent" in caplog.records[0].message`; with `mine()` they pass. `caplog.records` now appears once, in `mine()` itself. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(tests): _is_gone fails closed on an unreadable status, per its docstring `_is_gone` returned True for every OSError, so a LIVE pid whose `/proc/<pid>/status` cannot be read (EACCES under a `hidepid=` mount, a malformed line) was classified as gone, dropped from `live`, and the `killpg` proceeded. The docstring two lines above states the opposite contract: "a LIVE pid whose cgroup we cannot read is treated as foreign (fail closed)". Only the vanished-pid case is `gone` — FileNotFoundError. Every other OSError/IndexError now keeps the pid in `live` so the cgroup check can refuse the kill. This guard replaces os.kill/os.killpg for the whole unit suite and exists because a mis-fire SIGKILLed a developer's desktop twice. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> Co-authored-by: sim <sim@example.com>
vybe
left a comment
There was a problem hiding this comment.
merge-train: batch validated on train/20260921-1644 (#2939) — full suite green, including pg-migrations and schema-parity. Both migration tracks present and agreeing; check_alembic_heads.py on the merged tree returns 66 revisions, 1 head (0065). Backend mutation-verified (4 mutations, all red). A mounted spec was pushed to this branch pinning the draft-wipe regression this PR introduced and fixed — negative-controlled in both directions; see the comment above.
…ds and writes their memory (Abilityai/trinity-enterprise#637) (#2924) * fix(skills): refuse to overwrite an agent-authored skill of the same name (#2914) Assigning a library skill whose name matched a `.claude/skills/<name>/` the agent wrote itself overwrote the agent's copy, gitignored and untracked the directory, and buried an `unmanaged_dir_overwritten` warning in the assignment response. The library and the agent's repo share one flat namespace on the agent side, so a name match is not proof of the same skill — the library silently won. The inject path now refuses a directory that exists without the platform's `.trinity-skill.json` marker before a byte is staged: no archive, no restore, no `.gitignore` line, no untracking, not listed under CLAUDE.md Platform Skills. `force` (manual Sync) does not override it; a platform-managed dir keeps upgrading in place; an unreadable probe keeps the pre-existing fail-open direction. The per-skill status is `conflict` — neither `injected` nor `failed` (overall `success` stays true so the fleet re-inject does not alarm on a standing conflict every auto-sync); `skills_conflict` + `conflicts[]` carry it, and `deliver_assigned` reports `conflict` / `partial` + `conflicts[]`. The verdict is recorded on the assignment row (`agent_skills.delivery_status`, SQLite `agent_skills_delivery_status` + Alembic `0065`), carried across the bulk-replace PUT for retained names, cleared by the next injection where the name lands, and gone on unassign. The Skills tab reads it off the rows so the badge, the explanation (which skill, the agent's own copy is what runs) and an inline "Unassign library skill" action show on a fresh load; `inject()` re-reads the rows so a resolved conflict clears without a reload. MCP passes the delivery block through, `sync_agent_skills` names conflicts on its success branch, and `get_agent_skills` carries `delivery_status` per row. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * docs(learnings): #2914 — warnings on unread responses are not protection; the #2662 class inside BaseButton (#2921) * fix(skills): the platform never leaves a marker-less directory behind; delivery copy and draft fixes (#2914) Merge-train review on #2920: marker-absence was read as "the agent authored this" and refused, but the platform itself wrote marker-less directories on two live paths — the pre-#384 legacy fallback (SKILL.md only) and a restore that dropped the meta member — so each would have turned its own package into a permanent, unrepairable conflict on the next sync (AC#6 violated). Both are closed at the write: `_legacy_fallback` writes the marker beside SKILL.md (manifest = what was actually written), and `_restore_skill` writes the marker back directly when the agent's `restored` list lacks it (`marker_written_directly`). If the marker cannot be written the injection is reported `failed` (`marker_not_written`; the legacy write is rolled back) rather than a half-managed directory. Five tests cover the two paths, their failure arms, and the legacy-marker round trip that closes AC#6. Also from the review: `deliveryText` grammar ("its own a skill") with a real plural arm; the `not_delivered` arm now names conflicts beside the failure it retries; the Skills tab resets its draft only when the assigned SET changes, so a Sync (which re-reads the rows for the verdict) no longer wipes unsaved ticks; the conflict copy says "a directory the platform did not create — usually a skill it authored" rather than asserting authorship. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * test(ent332): the legacy fallback now writes the marker beside SKILL.md (#2914) Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat(schedules): a schedule that names a user runs as that seat — reads and writes their memory (Abilityai/trinity-enterprise#637) A role companion's proactive brief is a scheduled run, and a scheduled run could not touch an individual user's memory: `write_user_memory` refused every `schedule` trigger, so a brief could not carry the open loops and commitments that make the next brief better than the last. That gap is what forced "one agent per seat"; operator ruling R26 removed it. The seat is ent#498's address, read off the execution row, never sent. The ent#498 stamp already writes `source_channel='portal'` + `source_channel_client=<email>` after the roster and block checks; `services/schedule_seat_memory.seat_for_execution` reads it back for a `schedule`-triggered row and nothing else, and `routers/public_memory.py` accepts the write for exactly that case. `source_user_email` is deliberately not stamped — `client_portal/work` reads it as "work I started" and two stream-ownership checks key on it. No address → refused as before, now saying "names no one". The run reads before it writes: the tool is whole-blob replace, so the internal dispatch composes the seat's MEM-001 memory block plus a seat note into `execute_task(system_prompt=…)` on both branches, only after the stamp landed. The public persona prompt is not folded in. One write boundary, now with history: `db.write_public_user_memory_agent_notes` replaces the notes and records `public_user_memory_writes` (previous + new, execution, trigger, schedule) in one transaction — ent#419's screen lands there once, and its rollback layer is this table. The Workspace agent details gain "What it remembers about you" (`PortalAgentMemory.vue`, `GET /agents/{name}/memory`) listing the writes with schedule name and time, and Undo (`POST …/memory/writes/{id}/undo`) — latest-first, a named 409 for a later write, uniform 404 for a foreign id. Dual-track migration (SQLite `public_user_memory_writes_table` + Alembic `0065`), CASCADE in AGENT_REFS. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat(schedules): Run-now and webhook fires of an addressed schedule serve the same seat (Abilityai/trinity-enterprise#637) The manual trigger stamps triggered_by='manual' with the operator's own source_user_email (#1970); the brief is still addressed to the seat, so the seat resolver keys on a real schedule_id + the ent#498 portal stamp across all three fire paths and never consults source_user_email. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(workspace): a Run-now seat write is labelled as a scheduled run (Abilityai/trinity-enterprise#637) Found live: the Workspace projection keyed the write's kind on triggered_by == 'schedule', and a Run-now fire is 'manual', so the seat's own write rendered as "In a conversation". The boundary records schedule_id only for a seat run, so that is the key. Pinned with the projection test and the named-refusal test. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(workspace): the memory change header wraps instead of truncating the time and the undone mark (Abilityai/trinity-enterprise#637) Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * chore(migrations): chain 0066_public_user_memory_writes off #2920's 0065 — the three 2026-09-21 schema PRs are stacked (Abilityai/trinity-enterprise#637) Three open PRs each added a 0065_* off 0064, which alembic-heads reads as three heads and `upgrade head` would apply nothing. #2924 now stacks on #2920 (base branch fix/2914-skill-name-conflict) and its revision is 0066 off 0065_agent_skills_delivery_status. The migrations.py append collision is resolved by keeping both entries, #2920's first. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * merge-train: agent-self gate on the user-memory write boundary (#2924) — mechanical, per the merge-train note on the PR An agent-scoped key resolves to its owner carrying the owner's role, so `assert_agent_access` alone let a sibling agent under the same owner name this agent's finished seat run and replace that person's notes (cso --diff F1, 2026-09-22). Mirrors `reminders._self_gate`; human principals unaffected. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> Co-authored-by: sim <sim@example.com>
…th metric freshness, readiness (Abilityai/trinity-enterprise#527) (#2927) * fix(skills): refuse to overwrite an agent-authored skill of the same name (#2914) Assigning a library skill whose name matched a `.claude/skills/<name>/` the agent wrote itself overwrote the agent's copy, gitignored and untracked the directory, and buried an `unmanaged_dir_overwritten` warning in the assignment response. The library and the agent's repo share one flat namespace on the agent side, so a name match is not proof of the same skill — the library silently won. The inject path now refuses a directory that exists without the platform's `.trinity-skill.json` marker before a byte is staged: no archive, no restore, no `.gitignore` line, no untracking, not listed under CLAUDE.md Platform Skills. `force` (manual Sync) does not override it; a platform-managed dir keeps upgrading in place; an unreadable probe keeps the pre-existing fail-open direction. The per-skill status is `conflict` — neither `injected` nor `failed` (overall `success` stays true so the fleet re-inject does not alarm on a standing conflict every auto-sync); `skills_conflict` + `conflicts[]` carry it, and `deliver_assigned` reports `conflict` / `partial` + `conflicts[]`. The verdict is recorded on the assignment row (`agent_skills.delivery_status`, SQLite `agent_skills_delivery_status` + Alembic `0065`), carried across the bulk-replace PUT for retained names, cleared by the next injection where the name lands, and gone on unassign. The Skills tab reads it off the rows so the badge, the explanation (which skill, the agent's own copy is what runs) and an inline "Unassign library skill" action show on a fresh load; `inject()` re-reads the rows so a resolved conflict clears without a reload. MCP passes the delivery block through, `sync_agent_skills` names conflicts on its success branch, and `get_agent_skills` carries `delivery_status` per row. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * docs(learnings): #2914 — warnings on unread responses are not protection; the #2662 class inside BaseButton (#2921) * fix(skills): the platform never leaves a marker-less directory behind; delivery copy and draft fixes (#2914) Merge-train review on #2920: marker-absence was read as "the agent authored this" and refused, but the platform itself wrote marker-less directories on two live paths — the pre-#384 legacy fallback (SKILL.md only) and a restore that dropped the meta member — so each would have turned its own package into a permanent, unrepairable conflict on the next sync (AC#6 violated). Both are closed at the write: `_legacy_fallback` writes the marker beside SKILL.md (manifest = what was actually written), and `_restore_skill` writes the marker back directly when the agent's `restored` list lacks it (`marker_written_directly`). If the marker cannot be written the injection is reported `failed` (`marker_not_written`; the legacy write is rolled back) rather than a half-managed directory. Five tests cover the two paths, their failure arms, and the legacy-marker round trip that closes AC#6. Also from the review: `deliveryText` grammar ("its own a skill") with a real plural arm; the `not_delivered` arm now names conflicts beside the failure it retries; the Skills tab resets its draft only when the assigned SET changes, so a Sync (which re-reads the rows for the verdict) no longer wipes unsaved ticks; the conflict copy says "a directory the platform did not create — usually a skill it authored" rather than asserting authorship. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * test(ent332): the legacy fallback now writes the marker beside SKILL.md (#2914) Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat(schedules): a schedule that names a user runs as that seat — reads and writes their memory (Abilityai/trinity-enterprise#637) A role companion's proactive brief is a scheduled run, and a scheduled run could not touch an individual user's memory: `write_user_memory` refused every `schedule` trigger, so a brief could not carry the open loops and commitments that make the next brief better than the last. That gap is what forced "one agent per seat"; operator ruling R26 removed it. The seat is ent#498's address, read off the execution row, never sent. The ent#498 stamp already writes `source_channel='portal'` + `source_channel_client=<email>` after the roster and block checks; `services/schedule_seat_memory.seat_for_execution` reads it back for a `schedule`-triggered row and nothing else, and `routers/public_memory.py` accepts the write for exactly that case. `source_user_email` is deliberately not stamped — `client_portal/work` reads it as "work I started" and two stream-ownership checks key on it. No address → refused as before, now saying "names no one". The run reads before it writes: the tool is whole-blob replace, so the internal dispatch composes the seat's MEM-001 memory block plus a seat note into `execute_task(system_prompt=…)` on both branches, only after the stamp landed. The public persona prompt is not folded in. One write boundary, now with history: `db.write_public_user_memory_agent_notes` replaces the notes and records `public_user_memory_writes` (previous + new, execution, trigger, schedule) in one transaction — ent#419's screen lands there once, and its rollback layer is this table. The Workspace agent details gain "What it remembers about you" (`PortalAgentMemory.vue`, `GET /agents/{name}/memory`) listing the writes with schedule name and time, and Undo (`POST …/memory/writes/{id}/undo`) — latest-first, a named 409 for a later write, uniform 404 for a foreign id. Dual-track migration (SQLite `public_user_memory_writes_table` + Alembic `0065`), CASCADE in AGENT_REFS. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat(schedules): Run-now and webhook fires of an addressed schedule serve the same seat (Abilityai/trinity-enterprise#637) The manual trigger stamps triggered_by='manual' with the operator's own source_user_email (#1970); the brief is still addressed to the seat, so the seat resolver keys on a real schedule_id + the ent#498 portal stamp across all three fire paths and never consults source_user_email. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(workspace): a Run-now seat write is labelled as a scheduled run (Abilityai/trinity-enterprise#637) Found live: the Workspace projection keyed the write's kind on triggered_by == 'schedule', and a Run-now fire is 'manual', so the seat's own write rendered as "In a conversation". The boundary records schedule_id only for a seat run, so that is the key. Pinned with the projection test and the named-refusal test. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(workspace): the memory change header wraps instead of truncating the time and the undone mark (Abilityai/trinity-enterprise#637) Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * chore(migrations): chain 0066_public_user_memory_writes off #2920's 0065 — the three 2026-09-21 schema PRs are stacked (Abilityai/trinity-enterprise#637) Three open PRs each added a 0065_* off 0064, which alembic-heads reads as three heads and `upgrade head` would apply nothing. #2924 now stacks on #2920 (base branch fix/2914-skill-name-conflict) and its revision is 0066 off 0065_agent_skills_delivery_status. The migrations.py append collision is resolved by keeping both entries, #2920's first. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat(workspace): the role card in Agent details — role, objectives with metric freshness, readiness (Abilityai/trinity-enterprise#527) When a companion has a role (Tandem, ent#497), the Info rail shows a Role card: the role it fills, the objectives it owns or supports with each metric's value / target / freshness, the viewer's relationship, and its readiness. Framework §8's organisation UI shrunk to one agent — files are truth, the card is a projection. The Role + Readiness half; the relationship line reads ent#500's assignment when it lands and states "no assignment recorded" until then. Files are truth, read through the platform, never a second store: `client_portal/role_card.py` reads template.yaml (`x-role`, `x-canon`), `<canon>/roles/<id>.yaml`, `<canon>/objectives/*.yaml` and the agent's own `/api/metrics` through the agent door on every request; ids and paths that reach a read are validated, text is bounded, and every failure is named (`role.error`, `unavailable`) — no `x-role` means no card at all. A metric is stale when its value is missing, unstamped, or older than the framework's 30-day bound, and stale renders as stale, never as current. Readiness is the agent owner's stamp (#663, ruled 2026-09-20). `x-role.status` is agent-writable, so `agent_role_readiness` (dual-track: SQLite + Alembic 0065, CASCADE in AGENT_REFS) holds the state, when it changed and who flipped it; a template that says `ready` with no stamp is shown as calibrating with the note that no owner stamped it. The flip is `POST …/role/readiness`, gated on the platform's owner of the agent record (named 403 for everyone else, no route for the agent), behind a confirm that says it does not switch any schedule on. While calibrating the card shows the viewer's own walkthrough count (asks in their Main, capped at ten, and their thumbs-down). `PortalAgentRole.vue` is mounted-tested (#2918); the backend card is driven through a fake agent door; the stamp against a real SQLite file. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * merge-train: mark the role card's 30-day freshness rule interim (#2927) — mechanical, per the merge-train note on the PR The PM ruling of 2026-09-21 (recorded on ent#476) allows shipping the card as-is only with the 30-day rule marked interim in the feature flow, pointing at the ent#479 shared metrics read that replaces it. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> Co-authored-by: sim <sim@example.com>
…d after #2920/#2924/#2927 Content of both tracks is unchanged; only the Alembic parent moves so the version line resolves to one head again (Invariant #3, #2068). It was chained on 0064 while 0065–0067 were still open PRs; they have all landed on dev. Refs Abilityai/trinity-enterprise#549 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ai/trinity-enterprise#549) (#2936) * fix(files): a shared file is for the person the turn was for (Abilityai/trinity-enterprise#549) `agent_shared_files` was scoped by agent alone, so the Workspace Files tab listed every active share of an agent -- download link included -- to everyone on its roster: a file made in one person's chat appeared in another person's tab. Third occurrence of one class (asks, reports, now files): a table scoped by an owning entity gains a per-person dimension, and a reader written before the column cannot be neutral about it. A share now has an addressee. Three nullable columns on both migration tracks (`addressed_to_email`, `addressed_to_channel` -- display only --, `audience_source`); NULL email and NULL channel is the owner only, which is also what every existing row becomes, so there is no backfill. The platform decides the addressee; the agent does not choose. `services/turn_audience.py` keeps two questions apart. Given a turn, who is it for -- pure, table-driven over columns every entry path already stamps, with an allow-list of channels. And which turn did this call come from -- the hard one: an MCP call carries the agent's key and nothing about the turn, so the only link is an execution id the agent types, and a resumed session cites ids out of its own history (observed live: a turn 22 minutes old). The rule therefore needs positive evidence: only the agent's own key; the cited id must be this agent's; an agent-to-agent child is nobody; otherwise the id proves the CONVERSATION and never the person, and that conversation's running direct turns must agree on who it is. Every "could not tell" is the owner only, so the failure direction is under-share. The shortcut that must not come back is "the agent has exactly one running turn, so take it": a web-terminal session holds the agent's key, has no execution row, and would hand an operator's file to whichever client happened to be mid-conversation. On a channel turn the resolver reads `source_channel_client`, which the router now stamps only for a verified speaker in a one-to-one chat. In a group the verified email is the unlocker's, set once per group and not per speaker. - One override: `audience_email`, checked with the reader's own roster predicate; an off-roster address is refused by name and nothing is stored. - The addressee joins the effect key, so re-addressing a file within one turn is a second share rather than a silent replay; the key is hashed and the replay snapshot carries no address, so `idempotency_keys` never holds one. - The Workspace Files tab narrows in the query to the viewer, plus the owner-only rows for the agent's owner; an unidentifiable viewer matches nothing. The owner's Sharing panel lists everything, says who each file is for, and withholds the addressee from key-authenticated callers. - WhatsApp media and voice notes are addressed by the platform code that already holds the recipient. - `share_file` returns `visible_to_requester` / `visibility_note` (the names `set_canvas` uses) and never an address the platform resolved; the guidance lives in the tool description, since the prompt section is dropped at the minimal tier. Unchanged and stated: the `?sig=` link remains a bearer credential -- the audience governs the listing, not the download. Tests: 87 in tests/unit/test_ent549_file_audience.py, built on real-SQLite write-to-read round trips (every earlier share test mocked the persist step and every listing test seeded by INSERT, so nothing crossed write to read), plus mcp-server and frontend helper tests. 33 call-site mutations, all red, run in isolated scratch copies; the battery is what found a vacuous test (`column == None` compiles to `IS NULL`, and the table was empty). Fixes Abilityai/trinity-enterprise#549 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(files): name the platform-injected id slot so CodeQL does not read it as a secret CodeQL's sensitive-data heuristic classifies any identifier containing "trusted" as secret material. The unused slot for a platform-injected execution id was named `trusted_execution_id`, and that value reaches two existing log lines in `idempotency_service.resolve_and_validate_execution` that print an execution id -- so the first push raised two HIGH `py/clear-text-logging-sensitive-data` alerts on a file this change never touched. An execution id is not a secret; the alerts were true to the naming heuristic and false to the data. Renamed to `platform_execution_id` (and the local to `platform_turn`), which is also what the docs already call it. No behaviour change. A comment at the declaration says why, so the next reader does not name it back. Refs Abilityai/trinity-enterprise#549 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * docs(learnings): an identifier named trusted_* is a CodeQL secret source A new data flow into an existing log line raises the alert on a file the PR never touched; the fix is a rename at the source, not a dismissal. Refs Abilityai/trinity-enterprise#549 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(migrations): chain 0068 onto 0067_agent_role_readiness, dev's head after #2920/#2924/#2927 Content of both tracks is unchanged; only the Alembic parent moves so the version line resolves to one head again (Invariant #3, #2068). It was chained on 0064 while 0065–0067 were still open PRs; they have all landed on dev. Refs Abilityai/trinity-enterprise#549 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * docs(learnings): move the two ent#549 lessons into fragments (#2945 convention) dev now takes new ledger entries as one file per lesson under docs/memory/learnings/, folded into learnings.md at the release cut, so same-day PRs stop conflicting on the ledger's tail. The two entries this branch had appended to learnings.md move there unchanged; learnings.md is byte-identical to dev again. Refs Abilityai/trinity-enterprise#549 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>

Fixes #2914
What
Assigning a library skill whose name matched a
.claude/skills/<name>/the agent had written itself overwrote the agent's copy, gitignored + untracked the directory, and left only anunmanaged_dir_overwrittenwarning in an API response the Skills-tab operator never sees. The library and the agent's repo share one flat namespace on the agent side, so a name match proves nothing — the library silently won.Inject path (
skill_service._inject_skills_locked) — a directory that exists on the agent without the platform's.trinity-skill.jsonmarker is now refused before a byte is staged: nogit archive, no restore call, no.gitignoreline, no untracking, not listed under CLAUDE.md Platform Skills. Applies on every trigger (assign, manual Sync withforce=True, start path, fleet re-inject) —forceis a repair of platform-written packages, never permission to replace agent work. Per-skill status isconflict— neitherinjectednorfailed; overallsuccessstaystrueso the fleet sweep does not raise an operator-queue alarm on every auto-sync for a standing conflict.skills_conflict+conflicts[]ride the result;deliver_assignedreportsconflict(every requested name collides) orpartial+conflicts[].Recorded on the row —
agent_skills.delivery_status('conflict'/ NULL). Stamped by the inject path, cleared by the next injection where the name lands, carried across the bulk-replace PUT for retained names (the PUT only re-injects added names, so it would otherwise go quiet until the next start), gone on unassign. Dual-track: SQLiteagent_skills_delivery_status+ Alembic0065_agent_skills_delivery_status(IF NOT EXISTS, matching 0046–0062);schema.py/tables.pyupdated;check_alembic_heads+check_alembic_paritypass.Skills tab — reads the verdict off the assignment rows (
stores/skills.js::conflictNames), so on a fresh load the skill shows aname conflictbadge, which directory the agent owns, that the agent's copy is what runs, and the two ways out — an inline Unassign library skill (BaseButton) or rename/remove the agent's directory and sync.inject()re-reads the rows so a resolved conflict clears without a reload.deliveryTextgains aconflictarm and never arms the Sync nudge for it (Sync refuses again by design). Primitives (BaseBadge,BaseButton), tokens only; raw-colour ratchet unchanged.MCP —
deliverypasses through;sync_agent_skillsnames conflicts on its success branch;get_agent_skillscarriesdelivery_statusper row + aconflicts[]summary; descriptions name the vocabulary.Kept as-is: a platform-managed dir (marker present — including one a pre-#2914 assignment overwrote) keeps upgrading in place (AC#4/#6); an unreadable probe (
_read_agent_skill_metas→{}) keeps the pre-existing fail-open direction rather than refusing the whole start path on a transient exec fault — documented in code and in the test.Acceptance criteria
test_agent_authored_dir_survives_byte_for_byte(bothforcearms: no archive / restore / finalize / delete)conflictstatus, notinjected, not a post-hoc warning —test_conflict_is_neither_injected_nor_failed,test_delivery_names_the_conflict_explicitlySkillsPanel.vue+ store specstest_platform_managed_dir_still_upgrades_in_placetest_conflict_is_recorded_on_the_row_and_landed_names_are_cleared,test_set_skill_delivery_status_stamps_and_clears_only_named_rows,inject re-reads the rows…spectests/unit/test_2914_skill_name_conflict.py(15),test_ent183updated, frontendskillAssignDelivery.spec.js(+5), MCPskills.test.ts(+3)Verification
Full unit suite (
pytest unit/ -n 6 --dist loadfile): 16711 passed, 31 skipped, 16 failed — the 16 are pre-existing on the base commit0c470c76c(origin/dev): 15 intest_736_a2a_outbound_edges/test_ent14_registry_url_ssrf/test_ent399_ipv6_origin/test_mcp_validator(IPv6-mapped-address parametrizations, environment-specific — reproduced on a clean detached checkout of the base:15 failed, 414 passed) plustest_2814_workflow_trigger_parity::test_every_accepted_entry_names_a_real_divergence(staleACCEPTED_UNTIL_RELEASEentries now thatmaindeclares them). None touch skills./verify-local --skip-unit --skip-agent: pass — backend image build + import smoke OK, isolated sibling stack boot+health OK (the SQLite migration ran at boot), integration suite70 passed, 13 skipped(all skips are the pre-existing agent-gated ones:TRINITY_*_TEST_AGENTunset /testfixagent absent).Live check (dev stack, PostgreSQL, agent
sidekick, this branch mounted): Alembic0065applied at boot (alembic_version = 0065_agent_skills_delivery_status,delivery_statuscolumn present). Planted an agent-authored.claude/skills/add-backlog/SKILL.md(committed in a temp repo, no marker), then:POST /agents/sidekick/skills/add-backlogdelivery.status: conflict,conflicts: ["add-backlog"], per-skillerror: name_conflict: …delivery_status = 'conflict'git ls-files-tracked, no.trinity-skill.json, no.gitignoreline, not in CLAUDE.mdPOST …/skills/inject(Sync,force=True)skills_conflict: 1, statusconflict, sha still unchangedPUTaddingadd-memorybeside itadd-memoryinjected (marker + gitignore line + CLAUDE.md),add-backlogrow keepsconflictname conflictbadge, explanation naming.claude/skills/add-backlog/, "Unassign library skill" visibleconflictagain; agent removes its dir → Syncinjected, row cleared to NULL (AC#5)Found while live-testing, not introduced here:
BaseButton variant="secondary"has a transparent border in the light theme (the #2662 class inside the primitive) — filed as #2921.Docs
docs/memory/requirements/skills.md§21.4 (new bullet),docs/memory/feature-flows/skill-injection.md(delivery vocabulary, key behaviours, result contract, what-the-user-is-told),docs/memory/architecture/database.md(column note).🤖 Generated with Claude Code