Skip to content

fix(skills): refuse to overwrite an agent-authored skill of the same name (#2914) - #2920

Merged
vybe merged 5 commits into
devfrom
fix/2914-skill-name-conflict
Sep 21, 2026
Merged

vybe merged 5 commits into
devfrom
fix/2914-skill-name-conflict

Conversation

@dolho

@dolho dolho commented Sep 21, 2026

Copy link
Copy Markdown
Contributor

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 an unmanaged_dir_overwritten warning 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.json marker is now refused before a byte is staged: no git archive, no restore call, no .gitignore line, no untracking, not listed under CLAUDE.md Platform Skills. Applies on every trigger (assign, manual Sync with force=True, start path, fleet re-inject) — force is a repair of platform-written packages, never permission to replace agent work. Per-skill status is conflict — neither injected nor failed; overall success stays true so 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_assigned reports conflict (every requested name collides) or partial + conflicts[].

Recorded on the rowagent_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: SQLite agent_skills_delivery_status + Alembic 0065_agent_skills_delivery_status (IF NOT EXISTS, matching 0046–0062); schema.py/tables.py updated; check_alembic_heads + check_alembic_parity pass.

Skills tab — reads the verdict off the assignment rows (stores/skills.js::conflictNames), so on a fresh load the skill shows a name conflict badge, 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. deliveryText gains a conflict arm and never arms the Sync nudge for it (Sync refuses again by design). Primitives (BaseBadge, BaseButton), tokens only; raw-colour ratchet unchanged.

MCPdelivery passes through; sync_agent_skills names conflicts on its success branch; get_agent_skills carries delivery_status per row + a conflicts[] 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

  • Agent-authored dir survives byte-for-byte, stays tracked — test_agent_authored_dir_survives_byte_for_byte (both force arms: no archive / restore / finalize / delete)
  • Honest conflict status, not injected, not a post-hoc warning — test_conflict_is_neither_injected_nor_failed, test_delivery_names_the_conflict_explicitly
  • Skills tab shows which skill, that the agent's copy runs, with a next action — SkillsPanel.vue + store specs
  • Platform-managed dirs upgrade in place — test_platform_managed_dir_still_upgrades_in_place
  • Resolving clears on next sync/inject — test_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… spec
  • Pre-existing overwrites not made worse — same test as AC#4 (marker present ⇒ managed)
  • Tests: tests/unit/test_2914_skill_name_conflict.py (15), test_ent183 updated, frontend skillAssignDelivery.spec.js (+5), MCP skills.test.ts (+3)

Verification

pytest unit/test_2914_skill_name_conflict.py unit/test_ent183_skill_packages.py unit/test_2703_skill_assign_delivery.py unit/test_ent236_skills_lifecycle.py unit/test_ent237_skill_sources.py
264 passed
pytest unit/test_schema_parity.py unit/test_alembic_parity_guard.py unit/test_alembic_revision_id_length.py
96 passed
scripts/ci/check_alembic_heads.py → 66 revision(s), 1 head — PASS
scripts/ci/check_alembic_parity.py HEAD~1 HEAD → PASS — both tracks updated
SQLite migration applied to an old-shape DB twice (idempotent), existing row reads NULL
vitest skillAssignDelivery.spec.js (21) + rawColorRatchet + loadingGateRatchet → 36 passed; vite build OK; check:tokens OK
mcp-server npm test → 442 pass; tsc --noEmit clean

Full unit suite (pytest unit/ -n 6 --dist loadfile): 16711 passed, 31 skipped, 16 failed — the 16 are pre-existing on the base commit 0c470c76c (origin/dev): 15 in test_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) plus test_2814_workflow_trigger_parity::test_every_accepted_entry_names_a_real_divergence (stale ACCEPTED_UNTIL_RELEASE entries now that main declares 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 suite 70 passed, 13 skipped (all skips are the pre-existing agent-gated ones: TRINITY_*_TEST_AGENT unset / testfix agent absent).

Live check (dev stack, PostgreSQL, agent sidekick, this branch mounted): Alembic 0065 applied at boot (alembic_version = 0065_agent_skills_delivery_status, delivery_status column present). Planted an agent-authored .claude/skills/add-backlog/SKILL.md (committed in a temp repo, no marker), then:

step result
POST /agents/sidekick/skills/add-backlog delivery.status: conflict, conflicts: ["add-backlog"], per-skill error: name_conflict: …
row delivery_status = 'conflict'
agent side SKILL.md sha256 unchanged, still git ls-files-tracked, no .trinity-skill.json, no .gitignore line, not in CLAUDE.md
POST …/skills/inject (Sync, force=True) skills_conflict: 1, status conflict, sha still unchanged
bulk PUT adding add-memory beside it add-memory injected (marker + gitignore line + CLAUDE.md), add-backlog row keeps conflict
Skills tab (Playwright, light + dark) name conflict badge, explanation naming .claude/skills/add-backlog/, "Unassign library skill" visible
click Unassign library skill note "Unassigned add-backlog — the agent's own skill stays.", card gone, row deleted, agent's SKILL.md sha unchanged
re-assign → conflict again; agent removes its dir → Sync injected, 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

…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>
@dolho

dolho commented Sep 21, 2026

Copy link
Copy Markdown
Contributor Author
image

@vybe

vybe commented Sep 21, 2026

Copy link
Copy Markdown
Contributor

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 services/skill_service.py to dev in a throwaway worktree, kept your tests, and 9 of 15 went red; restored, 15/15. The six survivors are the real-DB and behaviour-preserving cases, which is correct. No readFileSync, no inspect.getsource, no source-text stand-ins — the only toContain hits assert the return value of deliveryText(...). The dual-track migration is complete and correct on all four artifacts, and check_alembic_heads.py passes on the merged tree (66 revisions, 1 head). delivery_status is threaded to a live consumer end to end — row → AgentSkill response model → store → badge — and I checked every status consumer for an else ⇒ failed fallthrough the new enum value would break. There are none. Durable-state-on-the-row over a field-on-the-response is the right design, and inverting the ent#183 test rather than deleting it was the right call.

What blocks it is that marker-absence is treated as one case, and this PR's own code says it is two.

skill_service.py:2222-2224, edited by this PR:

No meta ⇒ the platform never wrote this directory (an agent-authored Playbook of the same name, or the pre-#183 single-file era).

The new refusal at :1778if agent_entry.get("exists") and not isinstance(agent_meta, dict) — collapses that disjunction into "the agent authored it". The platform writes marker-less directories itself, on two live paths:

  1. _legacy_fallback (_restore_skill on a 404, pre-Reset-preserve-state operation (S3) #384 agent image) writes only SKILL.md — one write_file, files_written: 1, no .trinity-skill.json. It returns status: "fallback", and :1856 (if outcome["status"] in ("injected", "fallback")) appends it to injected_names, so _finalize_injected_dirs gitignores and untracks the dir. On disk that is exactly your own UNMANAGED = {"exists": True, "meta": None} fixture.
  2. The skipped-meta path (_restore_skill:2604-2610): META_FILENAME goes into sent, and when it doesn't come back in restored the code appends restore_skipped:<meta> — while still returning status: "injected". A successful-looking injection can leave the directory marker-less.

Either way the next injection takes the new branch and returns conflict permanently, and there is no repair path: force=True deliberately does not override (:1769-1771, pinned by test_agent_authored_dir_survives_byte_for_byte[manual-sync]), and remove_skills:2222 returns not_managed + unmanaged_dir_kept, so unassigning orphans the dir and re-assigning conflicts again.

Two consequences worth naming. The Skills-tab copy (SkillsPanel.vue:102-108) — "a skill it authored, not this library package… Its copy was left intact and is what runs" — is factually wrong for this population: the content is the library's own truncated SKILL.md. And its recommended action, "Unassign the library skill to keep the agent's", is actively harmful. AC#6 ("pre-existing overwrites not made worse") is violated here specifically: such a dir used to upgrade on every sync; now it never upgrades again.

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 _legacy_fallback (and the skipped-meta path) emit a minimal marker so platform-written dirs stay identifiable, or give the refusal an explicit operator-driven take-over. Plus the test that would have caught it — the platform wrote this dir marker-less, then re-inject — which test_2914_skill_name_conflict.py does not currently 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:

  • skillDelivery.js:21-25 renders "the agent already has its own a skill with that name"noun = names.length === 1 ? 'a skill' : 'skills'. skillAssignDelivery.spec.js:190 asserts the broken string, so the suite locks it in. The plural arm disagrees too ("skills with that nameits copy is kept").
  • skillDelivery.js:90-97 — the not_delivered arm ignores report.conflicts and returns needsSync: true + "Sync now", prescribing exactly the retry Sync refuses by design. Your own test_delivery_conflict_plus_failure_is_not_delivered_but_still_names_the_conflict proves the state is reachable.
  • stores/skills.js:182inject() now re-reads into assigned.value, SkillsPanel.vue:347 deep-watches it to resetDraft, and Sync is disabled only on injecting || !agentRunning, not on dirty. Tick boxes → Sync → ticks vanish. That is a new behavioural regression from this PR.

Second blocker, which landed after the seeds finished: regression diff is RED.

## ❌ New failures introduced by HEAD (4)
- [E]/[F] test_subprocess_pgroup.TestDrainReaderThreads::test_buffered_data_preserved_after_grandchild_kill
- [E]/[F] test_subprocess_pgroup.TestDrainReaderThreads::test_unwinds_reader_stuck_on_grandchild_pipe

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:

signal_guard.ForeignProcessSignal: os.killpg(13797, 9) refused: group contains a process
outside the session cgroup … A test reached the REAL process killer — usually a monkeypatch
that landed on the wrong module copy (trinity-enterprise#620).

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/killpg lines and test_subprocess_pgroup.py is not among its changed files. What it most likely does is expose it: a 351-line new test file changes collection order, the one input this class is sensitive to. It did not reproduce standalone (that file passes alone, with test_drain_bounded.py, and with test_2914 collected in front), so it needs full-suite ordering under a specific seed.

I checked whether dev covers it, and it does not. backend-unit-test is red on dev right now (09-17 onward), but on a different test — test_2814_workflow_trigger_parity::test_every_accepted_entry_names_a_real_divergence, a stale ACCEPTED_UNTIL_RELEASE allowlist. So the pre-existing rot does not account for this, and the differential stands against your head.

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: regression diff is not one of dev's four required contexts, so "all required checks green" would have merged this.

One process note, not a finding: pytest (head, seed 67890) was still running when the validation finished — its two siblings and all three base seeds passed. Per the train's own rule a seed running well past its siblings with no --timeout firing is usually a stalled xdist worker rather than a regression, so I would cancel and gh run rerun --failed rather than wait it out.

…; 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>
@dolho

dolho commented Sep 21, 2026

Copy link
Copy Markdown
Contributor Author

Re: merge-train ejection — fixed in d660483db

Blocker — marker-less platform dirs. Took the first option: the platform never leaves a marker-less directory behind, closed at the write rather than with an operator take-over.

  • _legacy_fallback now writes .trinity-skill.json beside SKILL.md, with manifest = [SKILL.md] (what was actually written, so a later prune on a newer image diffs against the truth). files_written: 2.
  • _restore_skill: when the agent's restored list lacks the marker member, it is written back with one write_file (marker_written_directly warning).
  • Either marker write failing → the injection is reported failed (marker_not_written); the legacy write is rolled back (SKILL.md + dir deleted), so a retry Sync lands cleanly instead of hitting the refusal. A half-managed directory is never left.
  • Tests (test_2914_skill_name_conflict.py): legacy fallback writes the marker with the truthful manifest · legacy marker failure rolls back + fails + nothing finalized · restore-without-meta writes it back · that failing → failed · the round trip that closes AC#6 — a legacy-fallback marker reads as managed on the next sync and upgrades.
  • Residual population: dirs a pre-fix legacy fallback already left marker-less on a pre-Reset-preserve-state operation (S3) #384 image are indistinguishable from agent-authored ones; they resolve through the conflict's second way out (rename/remove, then Sync). Named in the requirement (§21.4) and the flow doc. The tab copy no longer asserts authorship: "…that the platform did not create — usually a skill it authored."

The three warnings

  • skillDelivery.js — "its own a skill" fixed; real plural arm ("its own skills with those names (a, b) — its copies are kept and run"); spec updated (it asserted the broken string) + a plural case.
  • not_delivered arm now appends the conflict clause after the Sync prescription ("Also, the agent already has its own skill with that name (c)…"), so Sync is asked for the failure, not the conflict. Spec added.
  • Draft wipe on Sync: SkillsPanel now resets the draft only when the assigned set changes ([...assignedNames].sort().join('|')), not on every row refetch. Spec pins that inject() re-reads rows without changing the set.

Second blocker — regression diff. Established as pre-existing, with evidence: the same test_subprocess_pgroup.TestDrainReaderThreads::test_unwinds_reader_stuck_on_grandchild_pipe was reported as a "new failure introduced by HEAD" on the 2026-09-18 train run (run 35328963969, branch train/20260918-0918-dolho, head 36c563f) — a tree that does not contain this PR. test_subprocess_pgroup.py was last touched 2026-06-01. The test spawns a real harness and killpgs its group; the ent#620 signal guard refuses when the group check sees a process outside the session cgroup — order/runner-dependent, not this diff (zero subprocess/signal lines changed). It is the class #2891 (in progress) is about. Not fixed here; scope stays the issue's.

Rerun requested on this head.

…md (#2914)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
dolho added a commit that referenced this pull request Sep 21, 2026
…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>
dolho added a commit that referenced this pull request Sep 21, 2026
`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>
@vybe

vybe commented Sep 21, 2026

Copy link
Copy Markdown
Contributor

merge-train — one test pushed to this branch (03f7f807)

On the 2026-09-21 train. Validated as lane B+schema. The backend is in good shape and I'm not asking you to change any of it: both migration tracks are present and agree on identical DDL, check_alembic_heads.py on the merged tree returns 66 revisions, 1 head (0065), and four mutations against the second-round marker code all go red (_restore_skill writeback, _legacy_fallback, set_skill_delivery_status, the kept_status carry). Every value this PR introduces was traced to a live production consumer — delivery_status reaches SkillsPanel and mcp-server/src/tools/skills.ts both.

What was missing was a test, so I wrote it rather than sending the PR back.

SkillsPanel.vue had no spec at all. Proven by mutation: disabling the entire conflict UI (isConflict()return false) and reverting the draft-wipe fix at the same time left the suite byte-identically green — 139 files / 3209 tests either way. That includes the fix for the regression this PR itself introduced, which the last train caught: inject() re-reads the rows, and the old deep watch wiped unsaved ticks on every Sync. The test added for it (skillAssignDelivery.spec.js:127) asserts a store property that was already true before the fix, so reverting SkillsPanel.vue's watcher left it green.

tests/unit/skillsPanelDraftSurvivesSync.spec.js mounts the panel (the ent#625 harness) and drives the real path — a real checkbox tick, a real store.inject() over a mocked axios returning a new rows array with the same set. Negative-controlled in both directions, deliberately, so neither test 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" would pass test 1)
    -> "still follows a real change to the assignment set"      FAILS

Full frontend suite on the branch afterwards: 142 files, 3246 passed.

Nothing else was changed. Left as yours to judge, none of it blocking: the refusal is conditional on a successful probe (skill_service.py:1778 — a transient exec fault makes exists falsy and re-opens the overwrite; deliberate and tested, but AC#1 holds only when the probe succeeds); the fleet sweep and start path still report success for a conflict, so the fleet report is silently incomplete; data-testid="skill-conflict" / -unassign have no consumers; and marker_written_directly renders to the operator untranslated.

Worth noting for context: your #2922 is on the same train, and its ratchet cannot catch this class — it fails a spec that reads source text, not a component that has no spec. That gap is the one thing #2918's title names and the ratchet does not close.

vybe pushed a commit that referenced this pull request Sep 21, 2026
…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 vybe left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

vybe pushed a commit that referenced this pull request Sep 22, 2026
…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>
vybe pushed a commit that referenced this pull request Sep 22, 2026
…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>
webmixgamer added a commit that referenced this pull request Sep 22, 2026
…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>
vybe pushed a commit that referenced this pull request Sep 22, 2026
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

priority-p1 Critical path theme-devex Theme: DevEx type-bug Bug fix

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants