fix(push): namespace new rules and agents from --role/--project (#649) - #698
Conversation
|
…name RulesHandler.scanLocalForPush matched a root-level local rule against the sole active rules/<ns>/<name>.md by basename. A namespaced team rule is pulled into a namespaced local directory, so another member's unrelated root rule with the same name would have been read as a modification of the team rule and overwritten it under --all. push now records where it placed each root-level rule (state.placedRules, name -> team path). The scanner redirects a root-level local rule only when that record exists and its team file is still present; otherwise the rule is new. The ambiguity warning goes with the basename index. Review: Tencent#698 (comment)
|
Both items addressed in d0ab295. PR body's Test Plan rewritten to match the actual record. P1 — basename matching in
|
|
Findings
|
…name RulesHandler.scanLocalForPush matched a root-level local rule against the sole active rules/<ns>/<name>.md by basename. A namespaced team rule is pulled into a namespaced local directory, so another member's unrelated root rule with the same name would have been read as a modification of the team rule and overwritten it under --all. push now records where it placed each root-level rule (state.placedRules, name -> team path). The scanner redirects a root-level local rule only when that record exists and its team file is still present; otherwise the rule is new. The ambiguity warning goes with the basename index. Review: Tencent#698 (comment)
d0ab295 to
5229bec
Compare
|
All five findings addressed in P1 —
|
| case | agents | provider |
|---|---|---|
--project resolves each type from its own axis |
claude, codex, codebuddy, opencode | git |
| creates a GitHub PR for the namespaced branch | claude, codex, codebuddy, opencode | github |
| creates a GitLab MR for the namespaced branch | claude, codex, codebuddy, opencode | gitlab |
| edit of the root copy after the PR merges | claude | git |
| another member's root rule with the same basename | claude | git |
| teammate's newer namespaced rule | claude | git |
| unreadable roles manifest | claude | git |
--dry-run, and --dry-run on an axis that cannot answer |
claude | git |
18/18. The GitLab cell now exercises createPrWithFallback end to end against a real HTTP server speaking the GitLab MR API, so the gap I flagged last round — no GitLab instance available to me — is closed without one.
Reverting the three fixes of this round in src/push.ts and rebuilding turns exactly their cases red and leaves the other fourteen green:
× syncs a teammate's newer namespaced rule instead of pushing the stale root copy over it
× stops the push when the roles manifest exists but cannot be parsed
× --dry-run reports the destinations and pushes nothing
× --dry-run fails on a project axis the real push would refuse
✓ 14 others
Full record on 5229bec: npm run build success · npx tsc --noEmit clean · npx vitest run 269 files, 3808 passed, 1 skipped, 0 failed · npm run test:e2e 43 files (40 passed, 3 skipped — the live-provider cells that need real tokens), 190 passed, exit 0.
On the three shell-profile failures I reported last round: they are gone, and they were environmental as described. The detectShellProfile > Windows (win32) cases do not stub SHELL, and detectShellProfile reads SHELL before the platform branch, so a zsh terminal resolved them to .zshrc. With the SHELL=/bin/bash that CI has, the suite is fully green.
P1 — contradictory documentation
docs/usage-guide.md:1445 and docs/usage-guide.zh-CN.md:1404 both said a new agent lands at the root. Rewritten in both languages to describe the actual behaviour, with a link to the push section:
- A new agent lands at the root.
+ A new agent is placed the way a new skill is: `--role <ns>` or `--project <id>`
+ (that project's `agents` namespace) names the directory, and with neither flag it
+ resolves from the primary role's `agents` namespaces. It only stays at the shared
+ root — where every member receives it — when no namespace resolves, and push warns
+ when that happens.Both guides also gained a bullet for each behaviour change from this round: the manifest that stops the push, and --dry-run resolving the same destinations. I re-grepped for lands at the root / 落在根目录 and there is nothing else left.
P2 — --dry-run exited before namespace resolution
Fixed. The two steps that decide a destination are now functions, reuseRecordedDestinations and placeNewResources, and the dry run calls both in the same order a real push does before it exits:
push --dry-run
step 1 list the scan
step 2 reuse the destination an open PR recorded
place the new root-level resources, printing each destination
stop with exit 2 on an axis that cannot answer
"Dry run — no changes made"
Everything is treated as selected, which is what a dry run means, so the destinations it prints and the failures it reports are the ones the real command would produce. The namespace prompt is reached only where a real push would reach it — two or more candidates and not --silent — so --dry-run --silent stays non-interactive.
One thing still open, unchanged from last round
A member who pushes with --project front-app a new root rule whose name already exists in rules/fe-know/ overwrites it, because placement resolves to that path and the item is listed as new. It predates this PR and applies to skills equally, and the destination is printed so it is not silent. A "destination already exists, edit the namespaced copy instead" guard in step 4 would close it — here or as a follow-up, your call.
|
|
Findings
The PR description includes a detailed test plan and real-CLI/e2e verification record, so no testing-description finding is needed. |
|
Findings
The PR description includes a detailed test plan and real-CLI/e2e verification record, so it satisfies the review requirement for testing evidence. |
The PR description contains a detailed test plan and real-CLI/e2e verification record, so it satisfies the review requirement. |
…name RulesHandler.scanLocalForPush matched a root-level local rule against the sole active rules/<ns>/<name>.md by basename. A namespaced team rule is pulled into a namespaced local directory, so another member's unrelated root rule with the same name would have been read as a modification of the team rule and overwritten it under --all. push now records where it placed each root-level rule (state.placedRules, name -> team path). The scanner redirects a root-level local rule only when that record exists and its team file is still present; otherwise the rule is new. The ambiguity warning goes with the basename index. Review: Tencent#698 (comment)
fe5ed50 to
5431374
Compare
|
Findings
The PR description includes a detailed test plan and real-CLI/e2e verification record, so it satisfies the testing-documentation requirement. |
…name RulesHandler.scanLocalForPush matched a root-level local rule against the sole active rules/<ns>/<name>.md by basename. A namespaced team rule is pulled into a namespaced local directory, so another member's unrelated root rule with the same name would have been read as a modification of the team rule and overwritten it under --all. push now records where it placed each root-level rule (state.placedRules, name -> team path). The scanner redirects a root-level local rule only when that record exists and its team file is still present; otherwise the rule is new. The ambiguity warning goes with the basename index. Review: Tencent#698 (comment)
5431374 to
92978a6
Compare
|
Findings
The PR description includes a detailed test plan and real-CLI/e2e verification record, so no testing-description finding is needed. |
The PR description includes a detailed test plan and real-CLI/e2e verification record, so no testing-description finding is needed. |
|
Findings
Testing
|
|
|
Findings
The PR description includes a detailed test plan and real-CLI/E2E records, so no testing-description finding is needed. |
…name RulesHandler.scanLocalForPush matched a root-level local rule against the sole active rules/<ns>/<name>.md by basename. A namespaced team rule is pulled into a namespaced local directory, so another member's unrelated root rule with the same name would have been read as a modification of the team rule and overwritten it under --all. push now records where it placed each root-level rule (state.placedRules, name -> team path). The scanner redirects a root-level local rule only when that record exists and its team file is still present; otherwise the rule is new. The ambiguity warning goes with the basename index. Review: Tencent#698 (comment)
58cb8bc to
f0717d6
Compare
|
Findings
The PR description includes a detailed test plan and real-CLI E2E records, so there is no testing-documentation finding. |
|
Findings
The PR description includes a detailed test plan and real-CLI/e2e verification record, so the testing-documentation requirement is satisfied. |
|
|
|
Findings
The PR description contains a detailed test plan and real-CLI/e2e verification record, so no testing-description blocker is needed. |
…uthor Three review findings on the placement this PR added. A placement record only ever meant "push put this here", and the two sides that read one disagreed about how much it was worth. `placedResourcePath` is now the single resolver: it validates the record (inside the resource root, namespaced, no traversal, named after the resource) and both the push scanner and the pre-push sync go through it, so they cannot drift apart again. The record also takes precedence over a shared-root file that appears later with the same basename — mapping the author's copy onto somebody else's rule would push their content over it. Agents gained the analogue, `placedAgents`. `AgentsHandler.scanLocalForPush` only accepts a team source whose namespace is ACTIVE here, so an agent published with --role/--project into a namespace this directory never activated was skipped as "no active source" on the author's very next edit: they could create the agent and then never maintain it. `teamai remove rules <name>` resolves the same record through the new `publishedNameFor` hook. The author's copy stays at the rules root, so the name they type is the bare one, and remove answered "not found" about a rule it had recorded publishing. It now reports which name it resolved to, deletes the namespaced team file, and takes the author's root copy with it — left behind, that copy re-publishes the rule on the next push.
Four review findings, each about the record rather than the placement. `remove` consulted it only after a bare-name match failed, but the LOCAL scan contributes the bare name whenever the author's own copy has edits — so `remove rules my-rule` deleted that copy, reported success, and left `rules/<ns>/my-rule.md` published. The record is now resolved first. A record is written only for a resource push actually placed: `new`, and namespaced by this run. Recording a `modified` agent meant a namespace that happened to be active at edit time became standing permission to keep editing that agent long after the role or project granting it was dropped. Records are persisted per group, right after that group reaches the remote, instead of after every group completes. A failing later group returned early and took the earlier group's mapping with it, so a resource that WAS pushed came back misclassified once its PR merged. And the roles manifest is held to the same rule as `--role` and the projects manifest: a namespace is one path segment. `foo/bar` wrote an agent below the depth pull looks at, and read back as namespace `foo` for a rule.
…belongs to `AgentsHandler.scanLocalForPush` picked the team file to edit by activity alone, and it runs before the destination is resolved. So `agents/other-ns/vr.yaml` — an agent this directory never activates — made `push --project front-app` report "no active source" and push nothing, even though the same stem is allowed to exist in several namespaces and the flag had named a different one. The scan now takes the requested namespace, through a new optional `ScanForPushOptions`; with one named, sources in other namespaces are other agents, and an absent one means this agent is new there. A shared-root copy still blocks, and now says why: both would be active at once, which is the collision pull reports. `RulesHandler.removeItem` swept the bare basename unconditionally, so `remove rules fe/foo` deleted an unrelated personal .claude/rules/foo.md. The bare copy is only ours to delete when this machine's placement record says the two are the same rule. `teamai push --help` said both flags target skills.
Placement rewrote a new root-level resource to the resolved namespace without looking at what was at that path. An unrelated local `foo.md` — which the scanner rightly calls new, since no record maps it anywhere — landed on `rules/<ns>/foo.md` and replaced somebody else's rule, silently, in a run they never reviewed. Push now stops and names the file. The same guard covers the `--role`/`--project` skills override, for new skills only: a modified one is meant to land on its own directory. `loadRolesManifest` read through `readFileSafe`, which answers null for every failure, so a manifest that exists but cannot be read arrived looking exactly like a missing one — and a missing one is the pre-manifest layout, which sends new rules and agents to the shared root. The two are told apart now. `RulesHandler.removeItem` tombstoned only the name it was given. Removing through a placement record means the author's source is named `<name>` while the published file is `<ns>/<name>`, and the local sweep skips excluded tools, so a root copy could outlive the removal there and come back on the next push. Both names are tombstoned when the record vouches for the bare one.
…xtension `remove` asked every handler for the published name, but only rules answered. So `teamai remove agents vr` matched the bare stem and deleted every `vr` in every namespace — other people's agents included — while the namespaced `placedAgents` record, keyed by a path the removal never named, survived. `AgentsHandler.publishedNameFor` resolves it now; the existing sweep already narrows a `<ns>/<stem>` to one file, since the root directory is one of the directories it probes. The bare stem is tombstoned alongside the published one and swept from the tool directories, the same way rules are. The placement collision check tested the proposed path alone. `pull` reads a legacy `<stem>.md` as the same agent as `<stem>.yaml`, so a new `.md` landing beside an existing `.yaml` passed the check and left two copies answering to one name. Agents are now checked under both canonical extensions.
Round 7 added `AgentsHandler.publishedNameFor` but `remove` only used its answer when `allNames` also carried that spelling — and `scanTeamForPull` reports an agent by its bare stem, never `<ns>/<stem>`. So the resolution was inert on the real command path: `teamai remove agents vr` fell back to the bare stem and deleted every `vr` in every namespace, exactly as before. The cross check is gone; `publishedNameFor` has already proved the file is in the team repo, which is stronger evidence than membership in a list the scans spell differently per type. The bare-stem tombstone that round went with it. Agents deploy FLATTENED, so both the push scan and the post-pull cleanup read a bare tombstone globally: removing `fe/vr` suppressed and deleted `be/vr` the moment that namespace became active. Only the published name is tombstoned now. The author's own flattened copy is still swept, but only where this machine's record says the file just removed is where push put it — without that, the copy on disk may be another namespace's deployment. Covered end to end this time: the new case drives `teamai remove agents vr` through the built CLI, which is the join the round-7 unit tests skipped.
…se swallow `--project <id>` resolved the agents destination before scanning and dropped the failure on the floor. A project with no agents namespace then looked identical to a run with no flag at all: the scan skipped the agent as "no active source", the item never reached placement, and the command exited 0 with "No new or modified resources" — on a flag it could not honour. The error is carried forward and raised as soon as the scan contains an agent. It cannot wait for the selection the way the skills axis does, because the item that would prove the axis is needed is exactly the one the scan removes. `placedResourcePath` matched the recorded filename by prefix, so a record pointing at `rules/<ns>/foo.backup.md` was trusted whenever that file existed, and scanning, the pre-push sync and removal would all follow it onto somebody else's file. The filename must now be exactly the resource's own.
…it proves Four review findings, all on the agent side of placement. The single-repo canonical source in `.teamai/agents/` is picked up directly, never reverse-parsed, and that branch ignored the placement record: a root `vr.yaml` placed at `agents/fe/vr.yaml` read as new on the next push, and the collision guard then refused the very agent this machine published. Removal missed the same directory, so the agent republished itself on the next push — which a bare-stem tombstone cannot prevent without suppressing that stem in every other namespace, since agents deploy flattened. The project agents-axis error now counts only agents that actually need a destination. A modified agent already in a namespace is written in place, so an empty agents axis is none of its business; blocking it contradicted the rule that only new shared-root resources are placed. And the record is no longer taken as licence to overwrite. It admits a namespace this directory never activates, which also means `pull` never refreshed a copy and the pre-push sync does not cover agents — so if the canonical file moved on since the last pull, push now says so and asks for a pull instead of writing a stale rendering over whoever changed it.
…tion win The staleness guard added last round was defeated by the pull it recommended: `pull` advances lastPullRev without deploying an inactive namespace, so the next push saw an unchanged canonical and wrote the stale rendering anyway. It also never fired right after the first PR merged, when the file did not exist at lastPullRev. The guard is gone, and the cause with it. `pull` now delivers an agent whose placement record names it, so the local copy tracks the team file and the ordinary comparison is valid — the inactive case stops being special instead of needing its own machinery. A stem an ACTIVE namespace already claims is left alone, since agents deploy flattened and the active one is what is deployed here; the scan follows the same order, treating the record as a fallback rather than an extra candidate. Pending-PR reuse matched on type and name alone, so an open PR for a different resource of the same name captured a push that named another namespace and force-pushed into that review. Neither silent answer is safe, so the flag the user typed decides, the open PR is left untouched, and the collision is reported. This supersedes the original Tencent#331/Tencent#654 rule that a PR's destination always won; that rule still holds whenever no destination is named.
Self-review of the branch, before the next review round. Round 11 taught delivery about placement records but not revocation, and both run in the same pull: `filterAgentsByNamespaces` wrote the agent and `cleanupInactiveNamespaces` deleted it again, byte-equal to the render so the data-safety gate passed it straight through. The record-based delivery was inert and the file churned on every pull. Both halves now resolve through one exported `selectAgentsForDirectory`, so they cannot disagree — the same treatment `placedResourcePath` already gives the push scanner and the pre-push sync. Two smaller ones from the same pass. `--dry-run` grouped against the unfiltered pending list, so it reported a destination the real push no longer uses, which breaks the property that a dry run matches the run it describes. And the partial-selection warning counted entries the run had already declined to reuse, contradicting the warning given for them; both now share the filtered list, which is computed once there is actually something to push.
… rule `pullAllRules` sweeps a local rule whose name is absent from the desired set. A rule published into a namespace keeps the author's copy at the rules ROOT under its bare name, while the desired set holds `<ns>/<name>` — or nothing at all when that namespace is not active here — so the sweep deleted their own file, local edits included. The placement record marks it as theirs, and only while the team file it points at still exists. Pending-PR conflict detection trusted `PendingPushItem.namespace`, but the agent scan records a namespaced destination without setting that field, so those entries slipped past the check and a push naming another namespace could force-push into the PR under review. The namespace is derived from the recorded path when the field is absent, and the agent scan now sets it too — the field was the only thing telling `pendingNamespaceFor` where to put the resource.
…s.yaml after the pull, and stop a named namespace reusing a shared-root PR A project with no agents namespace failed before the listing whenever any new agent was present locally, so a rules-only push under `--project` was blocked on an agent the user was never given the chance to deselect. Only an agent the scan itself skipped (`needsDestination`) fails early now — that one never reaches the listing, so deferring its error means never raising it. A new agent is listed, and step 4 raises the same error if it stays selected. `manifest/projects.yaml` was read in `push`, before `pushCore` pulled the team clone, so a project whose namespaces changed on the remote placed this run's new rules and agents by the previous pull's mapping. It is read inside `pushCore` now, after the pull, and in self mode from the fresh worktree. Pending-PR conflict detection treated a recorded path with no namespace as non-conflicting, so an explicit `--role`/`--project` reused a shared-root PR's branch and rebuilt it with the namespaced path, moving a review the user did not name from "everyone" to one namespace. The shared root is a destination like any other: it conflicts with any namespace the flag names. `pull` delivered a rule this machine placed at `<tool>/rules/<ns>/<name>` beside the author's copy at the rules root, so a tool that loads rules recursively applied the same rule twice, disagreeing as soon as the team file moved on. The placement record names the root copy as this rule's local file, so delivery updates it and removes the namespaced duplicate an earlier pull wrote. A namespace another member placed is untouched.
…nonical agent's recorded file, and prune placement records A failed refresh of the team clone was only warned about, after which `--project` resolved every destination from the previous pull's `manifest/projects.yaml`. A namespace the remote had changed sent this run's new rules and agents to the members of the old one. `--project` stops now, and so does a new resource that would resolve from `manifest/roles.yaml`; a team with no roles manifest resolves from nothing that can go stale and keeps its behaviour. `--role` names the namespace itself and is unaffected. In self mode the placement record redirected a root canonical agent to the recorded file, extension included. `pushItem` writes by the source's extension, so an author who rewrote `vr.md` as `vr.yaml` had the `.yaml` written and the `.md` staged: the change never reached the branch and the `.md` stayed. The destination now keeps the record's directory and the source's extension, `pushItem` deletes the file it retires, `push` stages that deletion and moves the record to the new path. Placement records were written when a branch reached the remote and never removed. Once the PR was closed unmerged, or the file deleted upstream, the record pointed at nothing — until another member created the same path, at which point it came true again and their unrelated resource read as this author's. `push` and `pull` now drop a record whose target is neither on the default branch nor awaiting review on a branch origin still has, before anything reads the records. A record is kept when origin cannot be asked.
…en a shared-root file takes the name, and drop every stale record Placement records were written when the branch reached the remote, so a PR closed without merging left one behind for as long as its branch stayed — and no provider here can say whether a PR is open. The record now travels on the pending PR entry (`PendingPushItem.placed`, with the blob push wrote) and becomes a `placedRules`/`placedAgents` record only when that blob is in the default branch's history for the path: the PR merged, however the platform merged it. A path that merely exists is not enough, since another member may have created it after the PR was closed. `push`, `pull` and `remove` settle this before reading the records; the stale sweep spares a root copy whose placement is still awaiting review. `localNameFor` redirected a recorded rule onto the bare root path without asking whether a shared-root rule of the same name was being delivered too, so both landed on the one file in loop order, and the next push could follow the record and carry the shared rule over the namespaced one. Delivery keeps the namespaced path when the shared root holds that name, and the reconcile pass withdraws the record with a warning: the root copy follows the shared rule from then on. Dropping stale records destructured from the ORIGINAL map each iteration, so a later deletion put back what an earlier one had removed and only the last stale record went. The kept entries are rebuilt in one pass.
Recording a landed placement left its `placed` mark on the pending entry. Had the team then deleted the file — which drops the record — and another member recreated the path, the next reconcile recorded it again: the path existed and the blob push had written was still in the default branch's history, so both checks passed, and the unrelated replacement read as this author's resource. The mark and the blob are cleared when the record is written, so a placement is recorded exactly once.
…re flattened copies `remove` dropped the placement record as soon as its branch was pushed, so a retry during review resolved `vr` to the bare stem and removed that agent from every namespace. Records are now left to the reconcile pass, which drops one once its file is gone from the default branch, or was deleted since the last check (`placementsCheckedAt`) even if another member has recreated the path. A namespaced removal tombstones only `<ns>/<name>`, which never matched the flattened `<agents>/<name>` copy members hold, so that copy survived pull and the next push republished it. `AgentsHandler.removedStems` reads the tombstone as the flattened stem while no namespace still has that agent, for both the pull cleanup and the push scan. Rules no longer write a bare tombstone, which swept and suppressed other members' unrelated rules of the same name. Agent and rule push scans skip tools the member excluded: remove leaves those copies behind by design, so reading them republished the removed resource. Landing is proven only by history after the full commit the push branch was built on, and a placement whose path was deleted after it landed is spent unrecorded. Single-repo pull no longer reconciles against the member's own checkout; push and remove still do, in a fresh origin/<default> worktree.
…d retire flattened copies per directory Single-repo pull skipped the reconcile pass, so a merged placement stayed unrecorded until the next push or remove. It now reads the default branch as the ref origin/<default> (existence via `<ref>:./<path>`, history up to that ref) instead of the member's own checkout, and changes nothing when the ref cannot be resolved. `placementsCheckedAt` survived its last record, and a record made in the same run was checked against it, so re-placing a resource at a path deleted earlier dropped the new record at once. The checkpoint is cleared with the last record, and records made in this run are not held to it. `removedStems` retired the flattened stem only when no namespace had it at all, so an fe member kept a removed fe/vr while an unrelated be/vr existed. It now asks what this directory is meant to hold, through the same selection pull delivers with.
…tination the flag gives `remove` ignored a failed refresh and reconciled the stale clone as if it were the default branch. A placement merged since the last pull was then not recorded, and the bare name fell back to the stem, removing that agent from every namespace. `remove` now stops with exit 1 and removes nothing. Under --role/--project, a pending PR counted as conflicting whenever its recorded namespace differed from the flag's, although only skills and new shared-root rules and agents are moved by it. A modified rule already in a namespace kept its path yet went to a second PR on the same file. Only an item the flag actually moves can conflict with it now.
… new placements, validate the record's namespace With --role/--project the requested namespace always chose the team file a local agent was compared with, so an untouched copy delivered from an active namespace read as an edit of the requested namespace's agent and overwrote it. Candidates now follow delivery: an active source (the shared root included), then this machine's record, and only then the requested namespace. A placement that landed after the last pull had no `lastPullRev` version, so the pre-push sync skipped it and a teammate's edit before the author's next pull was pushed over. Rules take the version the file was added with as their base; a recorded agent, which has no pre-push sync, is held with a pull-first message when the team file moved past that baseline. `placedResourcePath` now requires a safe namespace segment: a backslash in it is a separator on Windows and walked out of the resource root.
…t sources - A pending namespaced placement whose name a shared-root file now takes is left out of the push with a warning, instead of the open PR being rebuilt with the author's copy over the shared file. - `remove` stops when the placement records cannot be reconciled and saved: a missing record sends the bare name to the stem, which spans namespaces. - An agent skipped for want of a --project agents namespace no longer blocks the rest of the push; the error stands only when nothing else is left. - A placement is marked only with a blob that can prove it landed, and one without is spent rather than recorded because its path exists. - The single-repo `.teamai/rules` scan source is not a tool, so an `enabledAgents` list no longer hides it. - A namespaced agent tombstone retires the flattened stem only where that agent could have been delivered; reconcile keeps the author's dropped record (`retiredPlacedAgents`) so their own copy still counts. - Two active same-name agents stay ambiguous under a flag, and a flag naming a namespace that already holds the agent is a collision, as for rules. - In single-repo mode a root copy equal to an older version of its placed file is held as stale rather than pushed over a teammate's edit. - The recorded-agent hold runs only for a changed copy and says to set the edit aside first; a pending placement is routed to its PR, not skipped; a flag that does not move a shared-root edit says so; several candidate namespaces without a terminal fail with a --role hint; a placement that landed with other content is reported once.
|
@jeff-r2026 I've rebased onto Rebase. One conflict: Round 21. The bot's four P1s from 04:06 are fixed, plus six more from our own adversarial pass over the branch:
Verification on
Still open:
The PR description has the full record. |
6a83b0b to
859ac58
Compare
|
Findings
The four findings from the previous review are resolved in the current diff. The PR description includes a sufficient test plan and real-CLI/E2E record for exact head |
…ced agents on remove - `push` stops, pushing nothing, when the reconciled placement records cannot be saved: the sync and the scan read them back from disk, and a record that could not be withdrawn still redirects the author's copy. - On a stale clone every unflagged placement stops, not only one resolved from an existing roles manifest: the manifest's absence and the skills namespaces detected from the tree are clone state too. - `remove agents <ns>/<name>` names one namespaced agent; a bare name only one namespace has resolves to it, and a bare name found in several places is refused rather than removed from all of them. - A record dropped because its file was deleted and recreated is retired like one whose file is simply gone, so the author's flattened copy of the removed agent is still recognised.
|
The four P1s from the 06:11 review are fixed in
This changes behaviour for teams without a roles manifest: if a push can't refresh the clone, a new resource now needs Verification on
|
Reconcile the manifest namespace guard with Tencent#698 (push placement for rules and agents) and Tencent#699 (skills served from skill-data/). - Keep main's RolesManifestNotFoundError name with this branch's stricter loader (readManifestFile: only ENOENT without a dangling link is absent). - Import isSafeNamespaceSegment and NAMESPACE_RULE from manifest-schema in push.ts and push-namespaces.ts; drop the old ASCII wording from their namespace errors. - Port the role-id guards into main's placement: the legacy fallback with no roles.yaml and the silent default both refuse an unsafe role id. - pushCore reports a failing scan (e.g. an unparseable roles.yaml) with exit 2 instead of an uncaught stack trace; a projects manifest that cannot load for --project does the same. A legacy role that could not be resolved no longer places a new rule or agent at the shared root. - The skills scan rethrows only manifest load failures again; a valid manifest that no longer lists the role keeps the role-id fallback. - The learnings index uses the namespace rule contribute writes with, so a non-ASCII namespace is indexed. - Namespace errors quote the value and manifest read errors say what to do. - Docs, CHANGELOG and skill-data (troubleshooting, manage-admin) describe the broken-manifest behavior, including that --role cannot bypass it.
Summary
teamai push --role <ns>andteamai push --project <id>only ever placed new skills. A new rule was written torules/<name>.mdand a new agent toagents/<name>.yaml. Neither path carries a namespace segment, sopulldelivers both to every member of the team (src/pull.ts,slashIndex === -1returns true, and the!agent.namespace ||branch).--projectalso collapsed into the project'sskillsnamespace, which is the wrong directory for a rule: a rule is scoped on theknowledgeaxis, andProjectResourceNamespacesSchemaallows a project to declare different namespaces for the two.Each pushable type now resolves from its own axis, and push says where the resource went.
Only a new resource that would otherwise land at the shared root is placed. A resource the scanner already namespaced keeps its path, which is what #654 fixed, and the destination an open PR recorded still wins so a force-push never moves a resource that is under review.
One more change closes a hole the placement would have opened.
RulesHandler.scanLocalForPushmatched a local rule against the team repo by full path, so once a rule was placed underrules/<ns>/, the author's copy at the tool's rules root read as brand new on the next push and went back to the shared root. push now records where it placed each root-level rule instate.json(placedRules, rule name → team path), and the scan redirects a root-level local rule only when that record exists and its team file is still present. A basename match alone is never used: a namespaced team rule is pulled into a namespaced local directory, so another member's unrelated root rule with the same name has no record and stays new (review round 1).Type of Change
Test Plan
Run on
23717ec, the head of this PR, based ona52374a(currentmain), afternpm run buildof that commit.npm run build— successnpx tsc --noEmit— cleannpx vitest run— 277 files, 4056 passed, 1 skipped, 0 failednpm run test:e2e— the full e2e suite, every file: 45 files (42 passed, 3 skipped — the live-provider files that need credentials), 215 passed, 26 skipped, 0 failed, exit 0src/__tests__/push-namespace-e2e.test.ts, 37/37The full e2e suite was run locally on this exact commit. Since #739, CI also runs it for this fork PR in the
E2E (fork-safe, no credentials)job;E2E (GitHub provider, full surface)still skips here because itsvars.TEAMAI_TEST_REPO_URLguard cannot be satisfied from a fork.In round 21 one unrelated unit test,
local-agent: emitBindingHint … emits hint only once per sessionId, failed once and then passed on every rerun. This PR does not touchsrc/local-agent.tsor its test. On23717ecthe full suite passed on the first run.The three
shell-profilefailures reported in the previous round are gone. They were environmental: thedetectShellProfile > Windows (win32)cases do not stubSHELL, anddetectShellProfilereadsSHELLbefore the platform branch, so a zsh terminal resolved them to.zshrc. The suite is green with theSHELL=/bin/bashthat CI has, and none of those files is touched by this branch.Real CLI, committed
The verification is no longer a one-off script.
src/__tests__/push-namespace-e2e.test.tsdrives the builtdist/index.jsagainst real git remotes, a fakeghbinary and a fake GitLab API server, and asserts on the content of the branch that reached the remote — never on CLI output alone.--projectresolves each type from its own axisgitrules/fe-know/my-rule.md,skills/fe-skills/my-skill/SKILL.md,agents/fe-agents/vr.yamlon the branch; norules/my-rule.md, noagents/vr.yaml;state.placedRulesrecordedgithubgh pr create -R team/issue-649invoked, PR URL reported, same namespaced paths on the branchgitlabPOST /api/v4/projects/team%2Fissue-649/merge_requests, MR URL reported, same namespaced pathsgitNo new or modified resources; edited copy →(modified)back torules/fe-know/my-rule.md, never to the rootgit(new), lands in that member's own namespace, the author'srules/fe-know/my-rule.mdon the branch is byte-identicalgitmainkeeps the teammate's contentgitCannot resolve where new rules should go, no branch on the remote--dry-rungitgit(modified)rather than skipped as "no active source", and reachesagents/fe-agents/vr.yamlon the branchremove rules <bare name>for a rule published under a namespacegitfe-know/my-rule, drops the namespaced team file from the branch, and takes the author's root copy with it; the record stays until the removal merges, and the nextpulldrops itremove agents vr, then again while the first removal is unmergedgitvr was published as fe-agents/vr; its branch drops onlyagents/fe-agents/vr.yamland keepsagents/other-ns/vr.yaml; after merge +pull, no recordremove agents vrgitcould not be refreshed,Nothing was removed, no branch on the remote — the stale tree would have resolvedvrto the bare stem and removedother-ns/vrtoopullwhile the removal is open, merge,pull, then the copy written back andpush --allgitfe-agents/vr; the push lists no[agents] vrand puts novragent on any branchremove agents vrwith a copy in an excluded tool (enabledAgents: [claude],.codex/agents/vr.toml), merged, pulled, thenpush --allgit[agents] vrand puts novragent on any branchgitfe-know/my-rule; the local scan offering the bare name does not wingitfoo/bar, no branch on the remotegitagents/fe-agents/vr.yaml; the other namespace's agent is untouched on the branchgitrules/be-know/foo.md, no branch, the team rule intact onmainremove agents <bare stem>for an agent published under a namespacegitfe-agents/vr, drops only that file, leavesother-ns/vrintact, tombstones only the published namepull --force, twice, for an agent published into a namespace never activated heregitpull --forcefor a rule this machine placed in an ACTIVE namespacegit.claude/rules/be-know/my-rule.mdpush --role fe-knowgitawaiting review at rules/my-rule.md+separate PR; two branches on the remote, the pending one still holdsrules/my-rule.mdat v1, the new one holdsrules/fe-know/my-rule.mdknowledgenamespace after this clone's last pullgitpush --project front-appplaces atrules/fe-know-v2/my-rule.md, not the stalefe-knowpush --project front-appgitcould not be refreshed, no branch on the remotemainby a teammate, thenpull --force; then a teammate recreates the path andpull --forceagaingitstate.placedRulesno longer carries the record after the delete, and does not get it back after the recreate; the teammate's rule is delivered tobe-know/gitbe-know/, the author's root copy is untouchedrules/my-rule.mdat the shared rootgitpullwarnsnow exists at the shared root, drops the record, root copy = shared rule,be-know/my-rule.md= the author's; the next push of the root copy goes torules/my-rule.mdand leavesrules/be-know/my-rule.mdaloneEach agent authors the new agent in its own format, because push reverse-parses the local file before it can write the canonical
.yaml: TOML for Codex,.mdwith YAML frontmatter for the rest.Red before green. Reverting any one fix and rebuilding turns exactly its cases red and leaves the rest green:
Unit tests added or changed:
Evidence
Before, from the issue:
After, from the branch that push created:
The same case as a test, red before the change and green after:
Related Issues
Part of #649, the remainder left open after #653 and #654. No closing keyword, matching how those two were handled. Please close #649 only if you agree nothing else remains.
Notes for Reviewers
Behaviour change worth a decision. With no flag at all, a new rule or agent now resolves from the primary role's
knowledgeandagentsnamespaces, mirroring what new skills already did. The consequence is that an author whose role declares aknowledgenamespace can no longer push a rule that reaches the whole team, because every new root-level rule is placed and--rolehas no value meaning "shared". Skills have the same limit today, so this is consistent, but it is new for rules. Say the word and I will add a "shared" entry to the prompt for rules and agents.--projectresolution is per type. The skills axis resolves against the scan rather than the selection, because the same override also relocates modified skills and the listing has to show where they go, but the failure is held until the selection proves a skill is going out. Deselect the skill and a rules-only push still goes through.The project id is validated as soon as the manifest loads, so
--project typo-idfails even on a push where nothing needs placing.Review round 2. Three more changes, all from the last review. A roles manifest that EXISTS but cannot answer — unparseable, or missing the configured role — now stops the push instead of returning an empty namespace list, because that fallback sent a new rule or agent to the shared root and therefore to the whole team.
loadRolesManifestthrows a taggedRolesManifestNotFoundErrorso an ABSENT manifest still keeps the pre-manifest convention.syncTeamUpdatesToLocalfollows the sameplacedRulesrecord the scanner does, so a root-authored rule placed underrules/<ns>/takes part in the three-way sync rather than being pushed over a teammate's newer version. And--dry-runnow runs the recorded-destination and placement steps before it exits, so it reports where every new resource goes and fails on the same unresolvable project axis the real command refuses.Review round 3. Three findings about the placement itself, each reproduced against the real CLI before being fixed. A placement record only ever meant "push put this here", and the two sides that read one disagreed about how much it was worth:
placedResourcePathis now the single resolver, validating the record (inside the resource root, namespaced, no traversal, named after the resource), and both the push scanner and the pre-push sync go through it. The record also takes precedence over a shared-root file that appears later with the same basename, since mapping the author's copy onto somebody else's rule would push their content over it. Agents gained the analogue,placedAgents:AgentsHandler.scanLocalForPushonly accepts a team source whose namespace is ACTIVE in this directory, so an agent published with--role/--projectwas skipped as "no active source" on the author's very next edit — they could create it and then never maintain it. Andteamai remove rules <name>resolves the same record through a newpublishedNameForhook: the author's copy stays at the rules root, so the name they type is the bare one, and remove used to answer "not found" about a rule it had recorded publishing. It now reports which name it resolved to, deletes the namespaced team file, and takes the author's root copy with it — left behind, that copy re-publishes the rule on the next push.Review round 4. Four findings, all about the placement record rather than the placement: what it grants, and when it is written.
removeconsulted it only after a bare-name match failed, but the local scan contributes the bare name whenever the author's own copy has edits — soteamai remove rules my-ruledeleted that copy, reported success, and leftrules/<ns>/my-rule.mdpublished. The record is resolved first now. A record is written only for a resource push actually placed, meaningnewand namespaced by this run: recording amodifiedagent turned a namespace that happened to be active at edit time into standing permission to keep editing that agent long after the role or project granting it was dropped. Records are persisted per group, right after that group reaches the remote, rather than after every group completes — a failing later group returned early and took the earlier group's mapping with it, so a resource that was pushed came back misclassified once its PR merged. And the roles manifest is held to the same rule as--roleand the projects manifest, that a namespace is one path segment:foo/barwrote an agent below the depthpulllooks at, and read back as namespacefoofor a rule.Review round 5.
AgentsHandler.scanLocalForPushpicked which team file a local agent is an edit of by activity alone, and it runs before the destination is resolved — so anagents/other-ns/vr.yamlthis directory never activates madepush --project front-appreport "no active source" and push nothing, although the layout allows the same stem in several namespaces and the flag had named a different one. The scan now takes that namespace, through a new optionalScanForPushOptions: with one named, sources in other namespaces are other agents, and an absent one means this agent is new there. A shared-root copy still blocks, and now says why — both would be active at once, which is the collisionpullreports and skips.RulesHandler.removeItemswept the bare basename unconditionally, soteamai remove rules fe/foodeleted an unrelated personal.claude/rules/foo.md; the bare copy is only ours to delete when this machine's placement record says the two are the same rule. Andteamai push --helpdescribed both flags as targeting skills.Review round 6. Placement rewrote a new root-level resource to the resolved namespace without looking at what was already at that path. An unrelated local
foo.md— which the scanner rightly calls new, since no record maps it anywhere — landed onrules/<ns>/foo.mdand replaced somebody else's rule, silently, in a run they never reviewed. Push stops and names the file now, and the same guard covers the--role/--projectskills override for new skills only, since a modified one is meant to land on its own directory. This is the hole flagged in the first round's comment and left as a follow-up; that was wrong for rules and agents, which could not collide before this PR placed them at all.loadRolesManifestread throughreadFileSafe, which answers null for every failure, so a manifest that exists but cannot be read arrived looking exactly like a missing one — and a missing one is the pre-manifest layout, which sends new rules and agents to the shared root. The two are told apart now, and an empty manifest reaches the parser and fails as invalid rather than as absent. AndRulesHandler.removeItemtombstoned only the name it was given: removing through a placement record means the author's source is named<name>while the published file is<ns>/<name>, and the local sweep skips excluded tools, so a root copy could outlive the removal there and come back on the next push. Both names are tombstoned when the record vouches for the bare one. (Superseded in round 17: the bare tombstone swept other members' rules of that name, so only the published name is tombstoned, and excluded tools are no longer a push source.)Review round 7.
removeasks every handler for the published name, but only rules answered it. Soteamai remove agents vrmatched the bare stem, deleted everyvrin every namespace — other people's agents included — and left the namespacedplacedAgentsrecord behind, since the record is keyed by a path the removal never named.AgentsHandler.publishedNameForresolves it now, and the existing sweep already narrows a<ns>/<stem>to exactly one file, because the root directory is one of the directories it probes; the bare stem is tombstoned alongside the published one and swept from the tool directories, the same way rules are. The placement collision check tested the proposed path alone, butpullreads a legacy<stem>.mdas the same agent as<stem>.yaml, so a new.mdlanding beside an existing.yamlpassed the check and left two copies answering to one name; agents are checked under both canonical extensions.Rebased onto
9d8ed1f, which matters here: CI runs as root, wherechmod 0o000is a no-op, and that commit establishesit.skipIf(process.getuid?.() === 0)for exactly that case. The manifest-readability test added in round 6 useschmod, so it now carries the same gate and restores the mode in afinally— without it the test would have passed on CI while asserting nothing.Review round 8. Round 7 added
AgentsHandler.publishedNameFor, butremoveonly used its answer whenallNamesalso carried that spelling — andscanTeamForPullreports an agent by its bare stem, never<ns>/<stem>. The resolution was therefore inert on the real command path:teamai remove agents vrfell back to the bare stem and deleted everyvrin every namespace, exactly as before the fix. The cross-check is gone, sincepublishedNameForhas already proved the file is in the team repo — stronger evidence than membership in a list each type spells differently. The bare-stem tombstone from that round went with it: agents deploy FLATTENED, so both the push scan and the post-pull cleanup read a bare tombstone globally, and removingfe/vrsuppressed and deletedbe/vrthe moment that namespace became active. Only the published name is tombstoned now, and the author's own flattened copy is swept only where this machine's record says the file just removed is where push put it — without that, the copy on disk may be another namespace's deployment. Rules can carry the bare spelling because they keep their namespace directory locally; agents cannot, and that distinction is now written where the tombstone is created.The round-7 miss was a testing gap, not a reasoning one: both halves were unit-tested and the join between them never was. The new coverage drives
teamai remove agents vrthrough the built CLI instead.Review round 9.
--project <id>resolved the agents destination before scanning and dropped the failure on the floor, so a project with no agents namespace looked identical to a run with no flag at all: the scan skipped the agent as "no active source", the item never reached placement, and the command exited 0 with "No new or modified resources" — on a flag it could not honour. The error is carried forward and raised as soon as the scan contains an agent. It deliberately does not wait for the selection the way the skills axis does: the item that would prove the axis is needed is exactly the one the scan removes, so deferring means never raising it. The cost is that a modified agent already in an active namespace is also stopped under a--projectwhose axis is empty; that is a clear message rather than a silent drop, and threading the unresolved destination into the scan is the alternative if the stricter reading is preferred.placedResourcePathmatched the recorded filename by prefix, so a record pointing atrules/<ns>/foo.backup.mdwas trusted whenever that file existed, and scanning, the pre-push sync and removal would all follow it onto somebody else's file. The filename must now be exactly the resource's own —<name>.mdfor a rule,<name>.yamlor a legacy<name>.mdfor an agent.Review round 10. Four findings, all on the agent side of placement.
The single-repo canonical source in
.teamai/agents/is picked up directly, never reverse-parsed, and that branch ignored the placement record: a rootvr.yamlplaced atagents/fe/vr.yamlread as new on the next push, and the collision guard then refused the very agent this machine published. Removal missed the same directory, so the agent republished itself — which a bare-stem tombstone cannot prevent without suppressing that stem in every other namespace, since agents deploy flattened. That is the tension between this round and round 8: the tombstone format cannot express namespace-scoped removal for a flattened type, so the fix is to delete the source that would republish rather than to suppress it. A copy in an excluded tool can still survive, butisAgentExcludedgates every write in this codebase and that policy is not this PR's to override. (Round 17: that copy is still left alone, but the push scan no longer reads excluded tools, so it cannot republish.)The project agents-axis error now counts only agents that actually need a destination, through an explicit
needsDestinationflag the scan sets when it finds no writable source. A modified agent already in a namespace is written in place, so an empty agents axis is none of its business; blocking it contradicted the rule that only new shared-root resources are placed.And the record is no longer taken as licence to overwrite. It admits a namespace this directory never activates, which also means
pullnever refreshed a copy of it andsyncTeamUpdatesToLocaldoes not cover agents — so if the canonical file moved on since the last pull, push says so and asks for a pull instead of writing a stale rendering over whoever changed it. This is a guard, not a synchronization: agents have no three-way pre-push sync at all, which predates this PR and affects active namespaces equally. The path this PR opened is closed; the general gap is left for its own issue rather than widening this one.Review round 11. The staleness guard added in round 10 was defeated by the pull it recommended:
pulladvanceslastPullRevwithout deploying an inactive namespace, so the next push saw an unchanged canonical and wrote the stale rendering anyway. It also never fired right after the first PR merged, when the file did not exist atlastPullRevand the comparison had nothing to compare against.The guard is gone, and the cause with it.
pullnow delivers an agent whose placement record names it, so the local copy tracks the team file and the ordinary comparison is valid — the inactive case stops being special instead of acquiring its own machinery. This is a different route from the one the finding proposed (persist a per-resource baseline): the record already means "this author maintains this agent", andpullsimply had never honoured that, which is what let the copy go stale. A stem an ACTIVE namespace already claims is left alone, since agents deploy flattened and the active one is what is deployed here; the scan follows the same order, treating the record as a fallback rather than an extra candidate. The guard, its two tests and thegetFileContentAtRevimport all went with it.Worth weighing: this changes
pull, not onlypush. A member who published an agent into a namespace they do not activate now receives that agent on pull. It is the same file they already have locally, so it is not new exposure, but it is a wider blast radius than the earlier rounds.Pending-PR reuse matched on type and name alone, so an open PR holding a DIFFERENT resource of the same name captured a push that named another namespace and force-pushed into that review. Neither silent answer is safe — honouring the PR ignores the flag the user typed — so the flag decides, the open PR is left untouched, and the collision is reported. This supersedes the original #331/#654 rule that a PR's destination always won; that rule still holds whenever no destination is named, and its test was updated with the reasoning rather than removed.
Fallout fixed along the way: three
doctortest files mocked../config.jswithoutloadStateForScope, whichresolveDesiredAgentsnow needs — 19 failures that were missing mock surface rather than broken behaviour.Rebase onto
cd3e0e6. The branch conflicted with #700, which movedmatchesRolesand the logger out ofsrc/roles.tsinto the newsrc/membership.ts. The two hunks were import lists only:roles.tskeeps main's imports plus thepathExiststhis branch needs for the unreadable-manifest check, androles.test.tsdrops the stalematchesRolesimport that #700 had already removed from the module. No behaviour on either side changed; the placement code does not touch what #700 scopes (hooks, MCP, env).Self-review, then round 12. Before the next review pass I audited the whole branch adversarially, asking of every namespace list "what else reads this?". That caught three defects, all introduced by round 11:
pulldelivered an agent through its placement record andcleanupInactiveNamespaces— which knew nothing about records — deleted it again in the same run, byte-equal to the render so the data-safety gate passed it through, leaving the round-11 fix inert and the file churning on every pull;--dry-rungrouped against the unfiltered pending list, so it reported a destination the real push no longer used; and the partial-selection warning counted entries the run had already declined to reuse. Delivery and revocation now resolve through one exportedselectAgentsForDirectory, the same "one resolver, two callers" treatmentplacedResourcePathalready gives the push scanner and the pre-push sync.The review round that followed reported those first two independently, against the commit that predated the audit. Two further findings were new, and both are worth naming because the audit missed them.
pullAllRulessweeps a local rule whose name is absent from the desired set. A rule published into a namespace keeps the author's copy at the rules ROOT under its bare name, while the desired set holds<ns>/<name>— or nothing at all when that namespace is not active here — so the sweep deleted the author's own file, local edits included. This is the same bug class as the agent one the audit did find; it was missed because the audit only asked the question of the code round 11 had touched, and rules had been placed since round 1. The placement record now marks that copy as the author's, and only while the team file it points at still exists.Pending-PR conflict detection trusted
PendingPushItem.namespace, but two of the three agent item constructors record a namespaced destination without setting that field, so those entries slipped past the check and a push naming another namespace could force-push into the PR under review. The audit checked that the consumer read the field correctly and never checked that every producer wrote it. The namespace is now derived from the recorded path when the field is absent, and the scan sets the field as well — it was also the only thing tellingpendingNamespaceForwhere to put the resource.Docs updated in both languages:
docs/usage-guide.md,docs/usage-guide.zh-CN.md,docs/designs/multi-project-management.md, andskills/teamai/references/contribute-member.md. TheREADME*.mdfiles do not describe push placement, so they are untouched.Review round 13 (first review of the rebased branch). Four findings, none caused by the rebase.
The agents-axis check from round 9 fired before the listing for every agent that needed a destination, including a NEW one. A new agent is listed, so the user can deselect it — failing early blocked a rules-only
--projectpush on an agent that was never going out. The early check is now limited to the case round 9 was actually about: an agent the scan itself skipped (needsDestination), which never reaches the listing, so deferring its error would mean never raising it. A new agent that stays selected fails in step 4 with the same message, which is the skills-axis order.manifest/projects.yamlwas read inpush, beforepushCorepulled the clone, so a project whose namespaces changed on the remote placed this run's resources by the previous pull's mapping. The read moved intopushCoreafter the pull; in self mode that is the fresh worktree. The--role/--projectexclusivity check stays up front, since it needs no manifest.The round-11 conflict check answered "no conflict" for a recorded path with no namespace segment, so an explicit
--role/--projectreused a shared-root PR's branch and rebuilt it with the namespaced path — the scope of a review the user did not name went from "everyone" to one namespace, silently. The shared root is a destination like any other now, and conflicts with any namespace the flag names. The e2e case for this exposed a one-second window ingenerateBranchName: two pushes in the same wall-clock second are given the same branch name and the second lands on the first for that reason alone, which is what made the case fail once in three runs. The test waits the second out; the window itself predates this PR and is left alone.pulldelivered a rule this machine had placed at<tool>/rules/<ns>/<name>beside the author's copy at the rules root, so a tool that loads rules recursively applied both, and they disagreed as soon as the team file moved on. The round-12 fix only spared the root copy from the sweep; it never asked where delivery should go.deliveryTargetsnow resolves the local name through the placement record — the root copy when this machine placed the rule there, the namespaced path otherwise — andpullItemremoves the namespaced duplicate an earlier pull wrote (DeliveryTarget.supersedes).doctorreads the same targets, so it checks the copy pull actually writes. A record for a different namespace is not this rule's and changes nothing. The round-12 test that asserted "local edits preserved" now runs with that namespace inactive, which is the only case in which pull leaves the root copy alone; with it active, pull updates the copy the same way it updates every other delivered rule.Review round 14. Three findings.
A failed refresh of the clone in
pushCorewas warned about and the run went on, so under--projectevery destination came from the previous pull'smanifest/projects.yaml— round 13 moved the read after the pull, but a pull that failed leaves the same stale file in place.--projectnow stops with exit 1 and names--role <ns>as the way through. The same gate covers a new resource that would resolve frommanifest/roles.yaml, since that manifest is read from the same clone; a team with no roles manifest at all resolves from nothing that can go stale and keeps its pre-manifest behaviour, so a flaky network does not start blocking pushes for teams the feature never touched. This is a behaviour change for the roles-manifest case that the finding did not ask for; I would rather have one rule for "where does a new resource go" than two.In self mode the record redirected a root canonical agent to the recorded file, extension included, while
pushItemderives the extension from the source. Rewritingvr.mdasvr.yamltherefore wrote the.yaml, staged the.md, and pushed nothing. The destination keeps the record's directory and takes the source's extension; the retired file is carried on the item assupersedes, deleted bypushItem, staged bypushGroup(it is tracked, so staging its path stages the removal), and the record moves to the new path when the branch lands — otherwise the next scan finds nothing at the recorded.mdand reads the agent as new.Placement records had no lifecycle: written when a branch reached the remote, never removed. Closed unmerged, or deleted upstream later, a record pointed at nothing until another member created that path, at which point it came true again.
prunePlacementRecordsdrops a record whose target is neither on the default branch nor awaiting review on a branch origin still has;pushruns it after the pull and before the scan,pullafter its refresh and before delivery, so nothing reads a record the current tree does not back. A record is kept when origin cannot be asked, the same ruleprunePendingPushesfollows, andpushskips the cleanup entirely on a stale clone, since a file missing from an unrefreshed tree proves nothing. The residual window is between the PR closing and the author's nextpushorpull, which is the cadence everything else in the tool works at. (Superseded in round 15: the record is no longer written before the PR merges, so there is no window.)Review round 15. Three findings, one of which changed the design.
The third — a closed PR whose branch was kept counts as "awaiting review" forever — has no local fix, because no provider here can say whether a PR is open, and adding that to six providers (the plain
gitone cannot answer at all) is the wrong size of change for this PR. So the record's timing moved instead. Push no longer writesplacedRules/placedAgentsat all; it marks the placement on the pending PR entry (PendingPushItem.placed) together with the git blob of the file it wrote (blob). The record is written by a reconcile pass —reconcilePlacementRecords, run bypush,pullandremoveafter their pull and before anything reads the records — only when that blob is in the default branch's history for the path: the PR merged, whether by merge commit, squash or rebase. A PR closed unmerged therefore leaves no record, branch or no branch, and the path merely existing onmainis not enough, which closes the "another member later creates that path" case the finding named: that e2e case pushes, never merges, has a teammate create the very path, and checks the teammate's rule is delivered to its namespace with the author's root copy untouched. Nothing changes while the PR is open: every record consumer already required the target to exist onmain, so a record was inert during review anyway, and the pending entry is what routed the author's edits back to the PR (reuseRecordedDestinations, since round 3). The one consumer that did rely on the early record was the stale sweep inpullAllRules, which now also spares a root copy whose placement is on a pending entry. TheremoteBranchExistscheck from round 14 is gone;removegained the reconcile pass because it may be the first command to see a merge, and its e2e cases showed that.The first:
localNameForredirected a recorded rule onto the bare root path without asking whether a shared-root rule of the same name was being delivered too, so both landed on the one file in loop order — and with the namespace inactive, the shared rule always won, and the next push followed the record and carried it overrules/<ns>/<name>.md. Delivery keeps the namespaced path wheneverrules/<name>.mdexists (the shared root owns the root path in every tool directory), and the reconcile pass withdraws the record with a warning, because the author's root copy follows the shared rule from then on and no longer stands for the namespaced one. The e2e case checks both halves, including that the next push of the root copy goes torules/my-rule.mdand leaves the namespaced file alone.The second was a plain bug in round 14's prune: destructuring from the original map on every iteration put back what earlier iterations had removed, so only the last stale record actually went. The kept entries are rebuilt in one pass, and the test has three stale rules and two stale agents.
Docs: the two
usage-guidebullets on records (both languages) now describe the landed-only timing and the shared-root withdrawal.Review round 16. One finding, a hole in round 15's own design: recording a landed placement left the
placedmark on the pending entry. Had the team then deleted the file — which drops the record — and another member recreated the path, the next reconcile recorded it again, because the path existed and the blob push had written was still in the default branch's history, so both landing checks passed. The mark and the blob are cleared when the record is written, so a placement is recorded exactly once; the pending entry itself still lives and dies byprunePendingPushesas before. The e2e case for the deleted rule now goes one step further and has a teammate recreate the path.Review round 17. Three findings, all about records that ran ahead of the default branch.
removedropped the placement record right after pushing its branch, while the deletion was still only on that branch. A retry during review, or after PR creation failed, could no longer resolvevrtofe-agents/vr, fell back to the bare stem, andAgentsHandler.removeItem('vr')deleted every same-stem agent in every namespace.removeno longer touches the records. The reconcile pass drops a record once its file is gone from the default branch, which is when the removal has actually landed, and a removal PR closed unmerged leaves the record in place. The rule-removal e2e case used to assert the record was gone straight afterremove. That assertion encoded the bug, so it now checks the record survives the unmerged removal and is gone after merge andpull.Keeping the record opened a window the old code did not have: the removal merges, another member publishes their own resource at the same path, and the record then claims it. The reconcile pass now stores the default-branch commit it last checked the records against (
placementsCheckedAt). A record whose file was deleted inplacementsCheckedAt..HEADis dropped even if the path exists again. The same check covers the placement side: a placement whose path was deleted afterbaseis spent without a record, because placement refuses an occupied path, so that deletion came after it landed.The landing proof matched the pushed blob anywhere in the path's history, so identical content that sat at that path before the push counted as this PR having merged. Each pending entry now records
base, the full default-branch commit its branch was built on (getHeadCommitjust beforepushRepoBranch), andblobInHistorysearchesbase..HEADonly. A force-push update of an open PR rebuilds from the current default branch and records the new base. A PR-creation retry that pushes nothing keeps the entry's previousbaseand items, because the retry has no branch checked out to hash, and recomputing lost the blob.The excluded-tool finding was one symptom of a wider gap, which a self-review of this round turned up with reproductions against the built CLI. Removing a namespaced agent tombstones only
fe-agents/vr(round 8, so thatbe/vrsurvives). But agents deploy flattened: every member holds that agent as<agents>/vr, the tombstone never matched that copy, and the next push republished it as new, for the author and for every teammate with the namespace active.AgentsHandler.removedStemsreads a namespaced tombstone as the flattened stem while no namespace still has an agent of that stem. Both the pull tombstone cleanup and the push scan use it, so the copy is cleaned and never pushed again. While another namespace still has the stem, the flattened copy may be that agent's, so round 8's rule holds. The consequence matches a bare tombstone onmain: nobody can publish a new agent of that name until the tombstone line goes. (Refined in round 18: the test is what this directory receives, not whether the stem exists anywhere.)The push scans for agents and rules now skip excluded tools, the same gate
removeItem,pullanddoctoralready apply. The agents canonical-merge branch loses its now-redundant copy of that check. The cost is that a rule or agent authored only in a tool excluded on this machine is no longer pushed from it. That is consistent withuninstall --agent <tool>, after which teamai neither writes nor deletes there. With excluded copies no longer a source, rules no longer need round 6's bare tombstone. That tombstone was read by every member, so removingfe-know/my-rulealso swept and suppressed any unrelated rootmy-rule, and a shared-rootrules/my-rule.mdnever reached anyone. Only the published name is tombstoned now. The round-6 unit test asserted the bare tombstone, so it is rewritten to assert its absence.Single-repo
pullno longer reconciles placement records. Its refresh leaves the member's own checkout alone, a feature branch or an un-pulledmain, so a record it dropped against that tree never came back.pushandremovestill reconcile, in a freshorigin/<default>worktree. (Superseded in round 18:pullreconciles again, through the ref.)Known limitations, not addressed here:
main, so the placement is never recorded. The author's next push treats the root copy as new. With--projector--rolenaming the same namespace, the collision guard stops it and names the file. Without a flag, it is placed again, and the destination is printed. Closing this needs the pending branch's tip fetched duringpull, which is network work on every session start. (Round 21: still not recorded, but no longer silent. Reconcile says once that the file arrived with other content, and names it.)Docs, both languages: the records bullet in
usage-guidesaysremoveleaves the record until its deletion lands. TheenabledAgentsparagraph sayspushreads no rules or agents from an excluded tool. The agents section describes how a namespaced removal reaches flattened copies.Review round 18. Three findings, all about round 17's own additions.
The checkpoint was advanced only while a record survived, and step 2 checked every record against it, including one step 1 had just made. Take a record at a path, then its removal merges: the record goes, and
placementsCheckedAtstays behind. When the author later placed a resource at that path again, the new record was compared with the old checkpoint, the earlier deletion showed up, and the record was dropped on the spot. The checkpoint is now cleared with the last record. A record made in the current run is exempt from it, since step 1 has already checked that record against its ownbase.Round 17 took the reconcile pass out of single-repo
pullso it would not read the member's own checkout. As a result, a merged placement stayed unrecorded until the nextpushorremove, and delivery went on without the record in the meantime.reconcilePlacementRecordsnow takes an optional ref, and single-repopullpassesorigin/<default>. Existence is read as<ref>:./<path>and history as..<ref>, so the checked-out branch never enters into it.origin/<default>is whatever the business repo last fetched. That is always a real state of the default branch, possibly an old one, so at worst a placement is recorded late. A ref that cannot be resolved changes nothing: read as "every file gone", it would drop every record.removedStemsretired the flattened stem only when no namespace had it at all. Withfe/vrremoved and an unrelatedbe/vrstill present, an fe member kept the removed agent. The later inactive-namespace pass compared that copy withbe/vr, found it different, and preserved it as "modified". The question is now what this directory is meant to hold: the stem is retired unless the directory still receives an agent of that name, through the sameselectAgentsForDirectoryselection that pull delivers with (active namespaces plus records). A be member keeps theirvr, and an fe member's copy is cleaned.Review round 19. Three findings.
removeignored a failed refresh and reconciled the stale clone as if it were the default branch. A placement merged since the last pull was then never recorded, soremove agents vrfell back to the bare stem and removedvrfrom every namespace, and the push URL could still carry that removal to the remote.removenow stops with exit 1, says the clone could not be refreshed, and removes nothing. Skipping only the reconcile, aspushdoes, would not have been enough: the fallback to the bare stem is the harm, and it needs the fresh tree to be avoided. The e2e case makes the fetch URL unreachable while pushing still works, which is the case where the old code reached the remote.Under
--role/--project, an open PR counted as conflicting whenever its recorded namespace differed from the flag's. Only some items are actually moved by the flag: every selected skill, and a rule or agent only while it is new and at the shared root. A modified rule already infe-knowkept its path, but its open PR was treated as conflicting, so the edit went to a second PR on the same file. Conflicts are now judged on the scanned item: one the flag does not move keeps updating its open PR.The Test Plan named
16db08ewhile the head was4f059f9: the body was updated after that push, so the review ran on the previous one. This time the whole record above was produced on31207f1before it was pushed, and the body was updated before the push too.Docs, both languages: the
removebullet says a failed refresh stops the command with nothing removed.Review round 20. Three findings.
With
--role/--project, the requested namespace always chose which team file a local agent was compared with. That was round 5's rule, and it went too far. Avrdelivered from the activecommonnamespace was compared withfe/vr, differed, and was pushed over it without any local edit. Candidates now follow delivery order: an active source first (the shared root always counts as active), then the one this machine's record names. Only when neither exists does the flag decide, and the agent is then new in that namespace. Round 5's case, a same-stem agent in an inactive namespace, is unchanged. Two of its unit tests claimed to test an inactive namespace but ran with no roles, where every namespace is active, so they now set up a projects manifest that makes it true. Round 5's shared-root refusal can no longer be reached, because the root copy is always active and is edited in place. Its test now asserts that edit.A record becomes active as soon as the merge is seen, but
lastPullRevmay still predate the file. The pre-push sync then found no base version and skipped the rule. If a teammate edited it before the author's next pull, the stale root copy went up over their edit. A rule reached through its record now takes the version the file was added with (getFileContentWhenAdded) as its base, which is the content the author's copy started from. Agents have no pre-push sync, so a recorded agent whose team file has moved past that baseline (thelastPullRevversion, else the added version) is held, with a message to runteamai pulland reapply. That is round 10's guard brought back narrowly, now that round 11 makespulldeliver recorded agents: after a pull the baseline moves and the guard lets the edit through. Active agents still have no pre-push sync, as onmain.placedResourcePathrejected.and..segments but never checked the namespace segment itself. Inrules/..\..\victim/foo.mdthe backslashes are separators on Windows, sopath.joinwalked out of the resource root. The namespace must now passisSafeNamespaceSegment, the same check push applies when it writes one.Docs, both languages: the
--role/--projectagent bullet describes the source order and the pull-first hold.Not verified end to end (round 18): there is no single-repo e2e fixture in this repo, so the ref path is covered by a real-git unit test (a feature branch cut before the merge, reconciled through
main) and by the pull test that checks the ref passed.Rebase onto
a52374a. As requested, the branch is rebased onto currentmain(#711/#713, #718, #736, #739). Only one conflict came up:remove.test.ts'svi.mock('../config.js'), which main changed to spreadimportOriginal. Both changes are kept.git range-diffshows no other change to the 23 earlier commits beyond test-mock context. Main'sisInteractiveis now used by the namespace prompt (below), and the five push-related test files that mockutils/prompt.jsgained it.Review round 21. Four findings from the bot, and six more from an adversarial pre-review of the whole branch, all fixed in
859ac58.From the bot:
modifiedat the shared-root path. The open PR, matched by type and name, would then be rebuilt with the author's copy over the shared file, and the namespaced change dropped. That copy is now left out of the push with a warning, and the open PR stays as it is. This is the rule reconcile already applies to a record: the shared root owns the name in every tool directory. The bot also suggested restoring the recorded path instead. I chose not to, because after pull the root copy holds the shared file's content, so either reading of what the author meant is a guess.removeswallowed a failed reconcile or state save and went on to resolve names from disk. A record that should have been written was then missing, and the bare stem removed the agent from every namespace. It now stops with exit 1 and removes nothing.--projectwith no agents axis, one agent skipped for want of a destination (a stale edited copy from a dropped role, say) failed the whole push before selection. It is now skipped like any other agent, with the axis message as a warning. The error remains when nothing else would go out, which is round 9's case.toPendingItemsmarked a placement even whenhashObjectfailed, and reconcile then recorded it on the path existing. A placement is now marked only together with its blob, and a mark without one is spent rather than recorded. The reconcile unit tests that relied on existence-only landing now commit real git history.From the pre-review:
.teamai/rulessource wheneverenabledAgentswas set. Single-repo init always sets it, so rules authored there stopped being pushed. That source is not a tool and is now exempt, viaSELF_KNOWLEDGE_SCAN_KEY, which moved totypes.ts.removedStemsretired a flattened stem on members who never had the removed agent's namespace. On their next pull their own agent of that name was deleted, and it could never be pushed again. The stem is now retired only where the agent could have been delivered: its namespace is active, nothing is filtered, or this machine placed it. After the removal merges, reconcile drops the author's record, so it keeps that record asretiredPlacedAgentsand the author's own copy still counts.--role <ns>, where<ns>already holds an agent of that name that was never delivered here, overwrote that agent. Rules refuse the same case, and agents now do too..teamai/after placement, so after a teammate's edit (and even afterteamai pull) that copy went back over it. A root copy equal to an older version of its placed file is now held as stale, for agents and rules alike (isPastVersionOf).--rolehint, instead of throwing inside the prompt.hashObjectgot back its own JSDoc.The first run of the full e2e suite on this round caught a regression of my own. Spending a mark made the rules stale sweep stop sparing the author's root copy while the PR was still pending, so that copy was deleted. The sweep now spares any pending namespaced placement of a root rule, whether or not it is still marked. The e2e case "never records a placement whose PR was closed without merging" covers this, and so does a new unit test.
Still open, as in round 17: the JoyCode/OMP/Copilot rule residue, and the absence of a pre-push sync for agents in active namespaces, which predates this PR. Not verified end to end: single-repo mode, which still has no e2e fixture. Its new paths are covered by real-git unit tests.
Review round 22. Four P1s from the bot, all fixed in
23717ec:pushstill swallowed a failed reconcile or save of the placement records. The pre-push sync and the scan read the records back from disk, so a record that should have been withdrawn, because a shared-root file now shadows it, could still redirect the author's copy onto the namespaced file.pushnow stops with exit 1 and pushes nothing, the same ruleremovegot in round 21.manifest/roles.yaml. This is round 14's exemption, and it is gone. A manifest's absence is also clone state (one added remotely would move new rules and agents off the shared root), and so are the skills namespaces detected from the tree. Every unflagged placement now stops on a stale clone and asks for--role <ns>. This is stricter than round 14 for teams without a manifest. A push whose own refresh fails will usually fail to push its branch anyway.removeoffered namespaced agents only by their bare stem, because the team scan reports agents that way. A machine without the placement record could only typevr, which removesvrfrom every namespace.remove agents <ns>/<name>now names one agent. A bare name that only one namespace has resolves to that agent, so its tombstone is qualified too. A bare name found in several places is refused, with the qualified names listed, and nothing else in the same command is removed.retiredPlacedAgents, unlike the path for a file that is simply gone. So the author's flattened copy of the removed agent went unrecognised. Both paths now retire the record.push-skill-flag.test.tsneeded its git mock completed, withisDedicatedRepoRoot,getDefaultBranch,getFileContentAtRevandgetHeadCommit. Without them the refresh threw inside push and the clone counted as stale, which the stricter rule now correctly refuses to place from. No assertion changed.Docs, both languages: the stale-clone bullet covers every unflagged placement and an unsaved record state, and the agents section explains how
remove agentsnames one agent.