979 Commits
Author SHA1 Message Date
Saul Moro 85ff72c775 feat(recall): link an OMP subagent's session to its parent for adoption (#935)
* feat(recall): link an OMP subagent's session to its parent for adoption

OMP gives a subagent a session of its own, so a recall the teamai-recall
subagent ran never credited the main agent's later reads. OMP writes a
subagent's session to <parent>/<agent id>.jsonl beside the parent's
<parent>.jsonl, whose header names the parent session. The generated
extension now reads that header on a session's first tool_result and
sends the session_link the OpenCode plugin already sends, so the reducer
credits the root session. A session kept only in memory has no file and
still gets no link.

* fix(recall): keep looking up an OMP subagent's parent until it is linked (PR review)

A session was marked as looked up before its parent was found, so a parent session file not yet on disk lost the link for the whole session. Also: skill-data troubleshooting lists OMP's subagent path, the docs name the no-link case (--no-session), a row shows a linked subagent's own read still scores 0, and the header read limit is named.

* fix(recall): read an OMP parent's session header after its title slot (PR review)

OMP writes a padded title slot line before the session header, so the
parent lookup parsed the title line and never linked a subagent. The test
harness now writes OMP's real layout; verified in a live OMP 18.4.8
session.
2026-10-01 20:27:50 +08:00
Saul Moro 9bcd53e915 fix(rules): give Codex the team rules: session hooks in a project, its own AGENTS.md in user scope (#940)
* fix(rules): inline rules without frontmatter, naming path-scoped globs

hermesRulesText becomes inlinedRulesText, the one renderer for tools that read rules from a single instructions file. It strips frontmatter and leads a path-scoped rule with 'Applies to files matching: <globs>'. Hermes SOUL.md and doctor's Hermes check use it. Part of #938.

* fix(rules): inline team rules into Codex AGENTS.md in project scope

Codex reads instructions from AGENTS.md, not from .codex/rules, so the
copies pull wrote there never reached it (#938). The codex defaults drop
rules and gain claudemd (AGENTS.md, ~/.codex/AGENTS.md at user scope).
pullAllRules syncs a [teamai:team-rules] block into that file once per
path while an enabled, installed Codex-family tool maps it, and removes
it otherwise. syncManagedInstructions probes rules ?? settings, so a
machine without Codex gets no ~/.codex.

* fix(rules): render the Codex team-rules block with inlinedRulesText

Follows the T3 rename, so a path-scoped rule reaches AGENTS.md without frontmatter, after its Applies to line.

* test(rules): cover Codex AGENTS.md in user scope, culture, shared instructions and recall

Codex now reaches AGENTS.md through its default claudemd path; these tests
pin user scope (~/.codex/AGENTS.md), a full project-scope pull writing all
four teamai blocks into one AGENTS.md, and recall on/off. Also corrects the
injectRecallBlockIntoTools comment, which listed Codex as skipped.

* fix(rules): remove Codex team rules and their old copies on uninstall

Uninstall strips the [teamai:team-rules] block and scans the legacy .codex/rules directory for teamai's copies. A shared instruction file now keeps only the blocks a remaining enabled, installed tool would write, so --agent codex drops the team-rules block while Pi keeps its own.

* fix(doctor): check the team-rules block in Codex AGENTS.md

Codex reads no rules directory, so doctor now compares the team-rules
block in each AGENTS.md an enabled, installed Codex-family tool maps
with what pull renders. It fails on a missing or stale block, on an
AGENTS.override.md that Codex reads instead, and on an entry with no
claudemd path. The file list comes from RulesHandler, the same
resolver the pull writes through.

* fix(rules): remove the .codex/rules copies earlier pulls delivered

Codex never read the <rule>.md copies teamai wrote to .codex/rules (and
.codex-internal/rules, .tcodex/rules). Every rules sync now removes the
ones that still hold what teamai delivered, including teamai-recall.md,
keeps edited copies and names them, and leaves *.rules alone.

* docs(rules): document where Codex reads team rules (AGENTS.md)

* fix(rules): give Codex its AGENTS.md rules on an already-synced pull

A CLI upgrade with the team repo unchanged takes the "Already synced" fast path, which never ran the rules sync. So a member upgrading from 0.22.0 got no team-rules block in AGENTS.md and kept the old .codex/rules copies until the repo moved or they ran pull --force. The fast path now runs the Codex part of the rules sync (block + reclaim) and nothing else.

* fix(doctor): tell a Codex member a plain pull restores the team-rules block

The already-synced pull now rewrites the block, so the fix text no longer sends the member to pull --force.

* fix(rules): give codex-internal and tcodex the codex instructions layout

codex-internal and tcodex drop their rules path and read team rules from
AGENTS.md like codex. Codex-family instruction writers probe
rules ?? settings ?? skills, so a team entry with neither rules nor settings
no longer counts as installed. writesInstructionBlock is the one answer to
which blocks a tool gets, shared by pull's writers and uninstall.

* fix(rules): keep an empty AGENTS.md that held no team-rules block

removeClaudeMdSection now reports whether it removed a section and can delete a file left holding only whitespace. The team-rules sync and recall off use it, so a pull that removed nothing no longer deletes a member's own empty AGENTS.md.

* fix(doctor): pass the Codex team-rules check when no rule has a body

When every team rule is frontmatter only, pull writes no team-rules block, so a missing block is correct and an AGENTS.override.md shadows nothing. Also note that an empty override shadows AGENTS.md too.

* fix(doctor): a plain pull applies the Codex claudemd toolPaths fix

Editing teamai.yaml moves the team repo, so a plain pull does the full sync; --force is not needed. Matches the other Codex block checks.

* fix(uninstall): name the instruction file uninstall cleans

The summary and result lines said CLAUDE.md for every instruction file, including Codex's AGENTS.md. They now print the file's path under an 'Instruction-file blocks' heading.

* refactor(recall): gate the recall block on writesInstructionBlock

recall on now asks the same question as pull's recall writer and uninstall, so the three cannot drift. No behavior change.

* fix(rules): reclaim .codex/rules copies of rules the team removed

The tombstone cleanup walks toolPath.rules, which Codex no longer has, so a copy of a rule tombstoned after the member's last pre-#938 pull stayed forever. The legacy reclaim now removes tombstoned names too, keeping and naming a copy the member changed since delivery (#822).

* test(rules): cover codex-internal and tcodex in reclaim, doctor and uninstall

Parametrizes the project-scope legacy reclaim, the four Codex doctor failure cases and the full uninstall over the Codex family.

* test(pull): cover the project-scope Codex fast path after an upgrade

* test(local-agent): pin the prompt sync into Codex's AGENTS.md

With Codex's default claudemd (#938), an HTTP prompt command reported by Codex writes the shared-instructions block into ~/.codex/AGENTS.md when ~/.codex exists, and creates nothing when it does not.

* refactor(rules): move rulePaths into rule-format

It reads a tool-neutral rule's paths: frontmatter, which both the Copilot renderer and the inlined-rules renderer use; it no longer lives in the Copilot module.

* fix(doctor): ask nothing of a Codex entry that delivers no rules to it

A team toolPaths entry such as codex: { agents: .codex/agents } has neither
rules nor claudemd: the team sends Codex no rules on purpose, like any tool
without a rules path. Doctor failed it on every member's machine (E2E
doctor-delivery-cli). It now fails only an entry that still lists the
pre-#938 rules path without claudemd. Part of #938.

* fix(rules): keep a teamai-recall.md the member edited since the last pull

The legacy Codex reclaim removed any .codex/rules/teamai-recall.md that
opened with the recall heading, so text a member appended after the last
pull that wrote it was deleted. The built-in rule is not in the delivery
ledger, so match the file against the exact bodies teamai deployed
instead; an edited copy is kept and named like other legacy copies.

* fix(rules): keep a member's empty AGENTS.md when the team-rules block goes

Removing the last block deleted any instructions file it left empty, so
an empty AGENTS.md a repository tracked was deleted once Codex was
disabled or the team had no rules left. injectClaudeMdSection now creates
a file as just the block, and deleteIfEmpty deletes only a file that
opens with it; a member's empty file is written back empty.

Also describe the per-block cleanup of shared instruction files in the
deployed uninstall skill.

* fix(rules): address review on uninstall, remove, doctor and markers

- uninstall and the legacy [teamai:rules] strip remove blocks through
  removeClaudeMdSection, so a member's empty AGENTS.md survives them as it
  does pull. Removing a block at the top of a file keeps the next one
  there, so a file teamai created still goes with its last block.
- `remove rules` refreshes with the rules this member's pull delivers
  (role, project and tag selection) instead of every rule in the repo.
- doctor reports a Codex team-rules block left behind when the team has
  no rules, and nothing else in that case.
- teamRulesBlock strips the block markers wherever they appear in a rule,
  not only as whole lines, since the block is located by substring.

* refactor(rules): split legacy Codex rule-copy ownership out of the reclaim

legacyRuleCopies classifies each .codex/rules copy (and the codex-internal
and tcodex dirs) as teamai's or edited, on the ledger, team-render,
tombstone and shipped-recall proofs reclaimLegacyRuleCopies used inline.
Read-only and public so uninstall can apply the same check. No behavior
change.

* fix(uninstall): keep edited legacy Codex rule copies, remove removed rules' copies

Uninstall picked every .md in a legacy rules dir whose name matched a
current team rule or a built-in, so it deleted copies a pull had kept as
the member's edits, and left copies of rules the team had tombstoned.
It now takes the legacy dirs from RulesHandler.legacyRuleCopies, the
check pull reclaims by: owned copies go, edited ones stay and are named
in one English warning. The tool's current rules dir is unchanged.

Addresses the codex-review findings on #940.

* fix(rules): give Codex the team rules through its session-start hook

The project AGENTS.md the earlier revision wrote the rules into is read by
Cursor, Copilot, OpenCode and Claude too, which already get the rules in
their own format. The Codex family now gets them from the teamai
session-start hook instead, and pull writes no rule file for it.

- A team-rules handler adds the user-scope rules, then the cwd project's,
  as pull resolves them, skipping a scope that does not enable the tool.
  It adds nothing on resume, whose history already holds them.
- A SubagentStart entry gives a fresh subagent the same rules.
- Both entries set additionalContextLimit: 0; past 2,500 tokens Codex
  keeps only the start and end of a hook's context.
- doctor checks the limit on the session-start entry; the AGENTS.md
  block check, its markers and its uninstall handling are gone. The block
  never shipped.
- Culture, shared instructions and recall still go to AGENTS.md; the old
  .codex/rules cleanup and the remove-rules role filter are unchanged.

For #938.

* test(tool-roots): expect Codex's instruction file under CODEX_HOME

#942 pinned Codex's default paths before #938 moved its rules to the
session-start hook: no rules path, and an AGENTS.md that follows the
root in user scope.

* fix(rules): keep Codex's project content out of AGENTS.md (#945)

The project AGENTS.md is the owners' file, and Cursor, Copilot, OpenCode
and Claude read it too. The Codex family now takes its project content
from the session hooks and its user content from its own AGENTS.md:

- Project scope: no `claudemd` in the Codex-family defaults, so pull
  writes nothing to the project AGENTS.md. The session-start and
  subagent-start hooks add the project's rules plus the culture,
  claudemd/ and recall blocks, leaving out a block another tool already
  wrote into the project AGENTS.md.
- User scope: the team rules go into a team-rules block of
  ~/.codex/AGENTS.md (and the variants' homes), beside the other blocks;
  the hook adds nothing outside a project. The "Already synced" pull
  writes the block too.
- doctor checks that block in user scope, and both hook entries' limit
  in a project. uninstall removes the block.

For #938.

* fix(rules): keep tombstoned Codex copies without delivery records
2026-10-01 20:26:31 +08:00
Smilewithoutfalling bec06b3d2b test(code-knowledge): make the assignment case pin the binding fix (#943)
* test(code-knowledge): make the assignment case pin the binding fix

`ast-swift-module-scope.test.ts` asserted 2 edges for `alias = work()`. The name
on the right sits in a call callee, a position both walkers skip -- so the case
produced the same count before and after the fix it guards, and passed on the
base commit as well.

The fixture becomes `alias = work` followed by `work()`, and the expectation 1
edge. Measured on the two walkers, one variable:

  base a504f8af  alias = work()  2 edges   the old assertion passed here
  base a504f8af  alias = work    0 edges   the new assertion fails here
  main f73493d4  alias = work    1 edge    the new assertion passes here

Whole file: 2 failed / 36 passed on the base walker (the second failure is the
initializer case #928 fixed), 38 passed / 0 failed on main.

No source change: the walker on main already reads the position correctly.

* test(code-knowledge): pin both sides of the assignment, not just the callee

`ast-swift-module-scope.test.ts` asserted 2 edges for `alias = work()`. The name
on the right sits in a call callee, a position both walkers skip -- so the case
produced the same count before and after the fix it guards, and passed on the
base commit as well.

The fixture became `alias = work` followed by `work()`, and the expectation 1
edge. The review on this PR then pointed out that this exercises the *value*
side of the assignment while the case name describes the *target* side, and that
a regression reading assignment targets as declarations would still pass it.
That is right, so this revision covers both positions instead of renaming past
one of them:

  `does not let an assignment value mention stand in for a binding`
      var alias = 0; alias = work; work()   -- renamed to match its fixture

  `does not let an assignment target stand in for a binding`
      alias = work; alias()                 -- new, with func alias() sibling

Measured on the two walkers, one variable:

  base a504f8af  alias = work()                2 edges  the old assertion passed
  base a504f8af  alias = work + work()         0 edges  value case fails here
  base a504f8af  alias = work + alias()        0 edges  target case fails here
  main f73493d4  alias = work + work()         1 edge   value case passes here
  main f73493d4  alias = work + alias()        1 edge   target case passes here

  both sides cross-file, alias = work; alias(); work()
    base a504f8af  0 edges        main f73493d4  2 edges

Whole file: 3 failed / 36 passed (39) on the base walker -- the initializer case
#928 fixed plus these two -- and 39 passed / 0 failed (39) on main.

The target case needs its own fixture rather than a second assertion on the value
one: the value fixture's target is a name a real `var` already bound, so both
walkers suppress the call there and the assertion cannot distinguish them.

No source change: the walker on main already reads the position correctly.
2026-10-01 20:25:14 +08:00
Saul Moro b2d3598b38 fix(config): honor CODEX_HOME through toolRoots (#942)
* fix(config): honor CODEX_HOME through toolRoots

Codex keeps its home in $CODEX_HOME, but teamai resolved every Codex
path under ~/.codex, so a member who exports the variable got no
skills, hooks, agents or MCP in the directory Codex reads, and doctor
stayed green.

Extend the toolRoots relocation built for CLAUDE_CONFIG_DIR (#725) to
Codex. A RELOCATABLE_TOOLS table maps each tool to its variable; init
records CODEX_HOME into toolRoots.codex, a re-init that moves the root
releases the old hooks and managed MCP servers, and doctor adds a
"Codex root matches CODEX_HOME" check. The two Codex writes outside
toolPaths now follow the root: the co-author setting in config.toml and
the skill-existence probe used by usage tracking.

The doctor check compares the paths each root resolves to rather than
requiring a record, so an unrecorded CODEX_HOME=~/.codex passes while
an unrecorded CLAUDE_CONFIG_DIR=~/.claude still fails (it moves
.claude.json).

Closes #939

* fix(config): address review on CODEX_HOME relocation

Derive the Codex co-author file from the scoped paths, list relocatable
skill roots from RELOCATABLE_TOOLS, and skip the root-release warning
for a tool the member does not sync or whose old root does not exist.

* fix(coauthor): resolve the Codex config root, not the skills mapping

A team mapping toolPaths.codex.skills to .agents/skills sent the Codex
co-author setting to ~/.agents/config.toml. Resolve each Codex's own root
(toolRoots.codex or ~/.codex, ~/.tcodex, ~/.codex-internal) instead.
2026-10-01 16:30:07 +08:00
ydflowandydflow 6db9144fc2 fix(webhook): preview test endpoints without sending (#900) (#941)
* fix(webhook): honor --dry-run for test

* fix(webhook): preview zero matching endpoints

---------

Co-authored-by: ydflow <314143294+ydflow@users.noreply.github.com>
2026-10-01 16:29:32 +08:00
ydflowandydflow daa624a73a fix(mcp): honor --dry-run for remove (#900) (#937)
* fix(mcp): honor --dry-run for remove

* docs(setup): document MCP removal preview

---------

Co-authored-by: ydflow <314143294+ydflow@users.noreply.github.com>
2026-10-01 16:29:00 +08:00
Saul Moro 9d8ef7d81f fix(tests): clear agent root env vars once in the vitest setup file (#936) 2026-10-01 16:28:34 +08:00
ydflowandydflow 8322869256 docs(config): clarify unreadable project scope fallback (#934)
Co-authored-by: ydflow <314143294+ydflow@users.noreply.github.com>
2026-10-01 15:52:43 +08:00
Smilewithoutfalling 2fcc00b1d3 fix(code-knowledge): collect Swift shadowing from binding positions (#928)
Follow-up to #847 (merged as 2aaf3db). The second finding of that PR's last
re-review rode in with the merge and is live on `main`: `let alias = work;
work()` — the initializer's mention of `work` entered the shadowing set, the
resolver saw its callee in there and dropped the edge. It is the
argument-position finding one position over, and subtracting one position per
review does not converge.

The rule this replaces asked whether a name *occurs* anywhere in the declaring
declaration, minus the one argument position the previous round carved out. Every
other expression position still satisfies it, so a mention was collected as a
binding: `let alias = work`, `a = work`, `for work in xs` and the subject of a
`switch` all contributed names they merely used. That question has no closed
answer, which is why each review round produced one more position -- and why
carving out one argument position did not end it.

The rule now asks whether the identifier is where a name is *bound*, and reads
the answer off its parent. tree-sitter-swift marks every introduction site: a
grammar `name` field on a declaration, a parameter, a closure parameter or a
capture-list entry; a `bound_identifier` field on the name an `if let` /
`guard let` / `while let` introduces; and a `pattern` or `type_parameter`
container, which bind through no field at all. Everything else is a use by
construction -- an initializer, an argument, a receiver, an assignment target, a
bare expression. The set is fixed by the language rather than by the review,
which is what lets it terminate. What the file loses: the `insideArgument`
marker, the `call_expression` callee-skip, and the marker's propagation through
`value_arguments` / `lambda_literal`. The `pattern` / `type_parameter` check is
not a special case any more either -- it is the rule, inside
`isSwiftBindingPosition`. The added lines are the comments that state it.

Base. This is authored on main `128844fe6c4b` (not on the merged PR branch, which
is dead). The three files it touches are byte-identical on that base to `8b4e67c0`
-- blobs a504f8af, 8feaa29c, 63be4dac -- so the measurements below, taken across
`891da0826b` / `8b4e67c0` / this commit, describe the same code the PR lands on.

Real CLI verification, base against change. The package was built and its own
command run over a two-file fixture, with output read from the file on disk:

    Sources/App/Worker.swift   func work() -> Int { return 1 }
    Sources/App/B.swift        func run() { let alias = work; work() }

    teamai codebase --extract <fixture> --json

    walk.ts    cli report                       REFERENCES edges
    base       2 files, 2 facts, 4 nodes/1 edge  0
    change     2 files, 3 facts, 5 nodes/3 edges 1
               Sources/App/B.swift -> Sources/App/Worker.swift

and the extractor's own relation document, `teamwiki/evidence/code/<p>/relation-Sources.md`,
carries it as

    - `Sources/App/Worker.swift` <- `Sources/App/B.swift:3`

which is the call this change is about. That document is the whole of the delta
under `teamwiki/`: the two trees compared byte for byte differ by one added file
(that relation document) and eight edited ones, all of them edited because they
embed it -- the graph index, the facts and interface caches, the code-project
manifest, its index and overview pages, the root index, and the source manifest.
Nothing is removed. In `graph-index.json` the node set is identical and the edge
set goes 1 -> 3: the REFERENCES edge above, plus a CONTAINS edge for the new
document. (The CLI's own summary line counts graph nodes on a wider basis, so it
prints 4 nodes / 1 edge for the base run against 5 nodes / 3 edges here.)

- 19-shape matrix, base against this commit: 0 regressions, 1 repair, controls
  untouched. The repair is the reported shape:

    let f = work; work()         no edge -> 1 edge

  That row was scored `expect none` in the previous matrix -- the measured value
  written down as the required one, so the matrix certified the defect the
  re-review later reported. The expectation is corrected, and a second row was
  added for the same defect in an assignment rather than a declaration.

- Binding sets over 27 shapes, old rule against new: they differ on 8 rows, and
  every difference *removes* a name -- no row gains one, and no row loses a name
  the body can bind. The eight are the initializer's and the assignment's mention
  of `work` itself; the optional `opt` in `if let` and in `guard let`; the array
  `xs` in `for work in xs`; the subject `x` of a `switch` with `case let work`;
  the value `y` in a type annotation; and the external argument label of
  `func run(label work:)`, which the body cannot see at all.

- ast-swift-module-scope.test.ts 36 -> 38 cases, all pass; the two added pin the
  initializer and the assignment target. The four AST suites: 70 passed.

- Five mutations, each removing one branch of the new rule. Four are killed. The
  fifth removes the `user_type` guard and survives; it is an *equivalent* mutant
  under this grammar, not an unread branch -- with the guard removed, the binding
  sets over ten type positions are identical, because a `type_identifier` under
  `user_type` carries no field naming it. The guard is kept as the direct
  statement of "a type is not a binding" rather than left implicit. walk.ts is
  restored byte for byte after every run (sha256
  a09a497f60f096a25c511c00b25e3bdab0c219882d8d124f36746423c17b8e33).

- Cost is unchanged: one traversal per declaration. Across three runs at 1600
  calls the two versions land within a few percent of each other in either
  direction -- this commit 37.2 / 36.2 / 37.8 ms against the base's 35.0 / 39.8 /
  41.2 ms -- so the run-to-run spread is wider than the difference between them.
  The time grows 3.8-4.0x where the call count grows 8x; quadratic would be 64x.

- oxlint --deny-warnings --report-unused-disable-directives and tsc --noEmit:
  rc=0. The --type-aware half of the repo's lint runs in CI.

`AstCallSite.localBindings` and the walker's comments described the set as
"deliberately coarse", counting a name that merely occurs. It no longer does,
and both say so now.

Still open, unchanged and out of scope here: inherited and cross-file extension
members, tracked in #909. Answering those needs a member table the AST index
does not build today, so no amount of position handling reaches it.
2026-10-01 14:48:33 +08:00
Saul Moro 3030ffd8a8 fix(test): give each unit test file its own HOME (#927)
Commands resolve ~/.teamai from HOME, so a unit test that reached the real
home shared the sync lock with every teamai session on the machine.
push-env.test.ts failed while another session held ~/.teamai/.sync-lock
(#924). A setup file, listed first so module-level home paths see it too,
now points HOME/USERPROFILE at an empty temp dir per test file. The e2e
config keeps the real home, which CI prepares.
2026-10-01 14:48:05 +08:00
dayan fe5c3c5a2b feat(mcp): add Pi Coding Agent MCP support (#926)
* feat(mcp): add Pi Coding Agent support

Deliver stdio and HTTP servers to Pi user and project configs, preserve native codemode exposure, convert timeout units, and skip unsupported SSE. Cover reconciliation and local-agent delivery and update all affected documentation.

* docs(pi): place MCP delivery under the MCP section
2026-10-01 14:47:23 +08:00
ydflowandydflow 7bb06525ef feat(init): prompt for optional projects during interactive setup (#757) (#910)
* feat(init): prompt for optional projects during interactive setup

* docs(setup): ask before selecting init projects

---------

Co-authored-by: ydflow <314143294+ydflow@users.noreply.github.com>
2026-10-01 14:46:27 +08:00
ydflowandydflow 75a7782f74 fix(recall): honor --dry-run for enable and disable (#900) (#907)
Co-authored-by: ydflow <314143294+ydflow@users.noreply.github.com>
2026-10-01 14:45:57 +08:00
ydflowandydflow 6b1c9ed2c2 fix(exclude): honor --dry-run for skill exclude add/remove (#900) (#906)
* fix(exclude): honor dry-run when changing exclusions

* docs(exclude): show dry-run previews before changes

---------

Co-authored-by: ydflow <314143294+ydflow@users.noreply.github.com>
2026-10-01 14:45:22 +08:00
ydflowandydflow 7e6ebae2a4 fix(search): isolate IDF statistics by domain (#902)
* fix(search): isolate IDF statistics by domain

* test(search): make ranking fixtures cross-platform

* fix(recall): normalize all search candidates before limiting

---------

Co-authored-by: ydflow <314143294+ydflow@users.noreply.github.com>
2026-10-01 14:44:58 +08:00
NianJiu 83eb8a8083 fix(review): honor dry-run for apply, reject and batch decisions (#930)
* fix(review): keep dry-run decisions read-only

* docs(review): derive preview guidance from CLI help
2026-10-01 14:44:29 +08:00
ydflowandydflow df940cdd18 fix(config): refuse unreadable project scope fallback (#899)
Co-authored-by: ydflow <314143294+ydflow@users.noreply.github.com>
2026-09-30 22:11:21 +08:00
Saul Moro fb9f5a2c28 feat(mcp): keep project MCP configs with resolved tokens out of git (#882) (#886)
* feat(mcp): keep project MCP configs with resolved values out of git (#882)

A project-scope MCP config that carries a resolved ${VAR} sat untracked
and unignored in the business repo, one `git add -A` from committing the
token. After the reconcile writes such a file and git would track it,
teamai lists its path in the clone's .git/info/exclude inside a marked
block (resolved via `git rev-parse --git-path`, so linked worktrees and
submodules work). The committed .gitignore is never touched; an ignored
path or a config with no resolved value adds nothing; dry runs write
nothing. Project-scope uninstall removes only teamai's block, and doctor
reports such a file git would still commit.

The hook sits after the appliers in reconcileMcpForConfig, outside
desiredMcpForTarget/applyJson/applyCodex, so it merges cleanly with #880.

* fix(uninstall): count the .git/info/exclude block in the removal plan (#882)

The plan now records whether the project's .git/info/exclude holds
teamai's MCP config block (gitExcludeBlock). It counts toward
isPlanEmpty, is listed in the summary and dry run, and gates the
removal, so a plan whose only teamai leftover is the block removes it
instead of reporting "Nothing to uninstall".

* fix(mcp): keep the exclude block until every MCP config is clean (#882)

- uninstall keeps a repository's .git/info/exclude block while a config in
  it could not be parsed and still holds teamai servers, and warns
- uninstall finds and removes the block in nested repositories holding an
  MCP config, across every worktree
- the block opens at the last start marker, so an orphaned start never
  pairs with a later block's end and takes the member's lines
- doctor counts only servers the ownership manifest records, not a member's
  own server under a team name

* fix(uninstall): remove the exclude block only once its files are proven clean (#882)

A missing or unreadable managed-mcp.json made the MCP cleanup return early
without reporting anything, so uninstall removed the block while .mcp.json
still held the resolved token.

Uninstall now inspects every path the block protects after the cleanup. The
block goes only when each one is missing, or parses and holds none of the
team's servers that need a resolved ${VAR}. Anything it cannot check keeps
the block, with a warning naming the file. This replaces the leftInPlace
report from the reconcile, which the check subsumes.

* fix(mcp): protect every project MCP config holding a resolved value (#882)

Pull, doctor and uninstall each skipped a case they had not inspected and
treated it as safe. Now:

- pull lists a config in .git/info/exclude whether or not it delivered to
  it this run: a disabled or undetected tool's file, a team with automatic
  delivery off, an unreadable mcp.yaml (any teamai entry counts), a failed
  write to another tool's config, and a lost ownership manifest (the
  resolved value found in the file)
- doctor checks the same files, including one that does not parse, and
  counts a git error as a failure
- git check-ignore failing inside a repository is no longer read as "not
  tracked": the path is excluded anyway, or teamai warns with git's error
- uninstall also keeps the block while a file contains the value (8+
  characters, not a path or the login name) of a variable still set in the
  environment, which finds a server since dropped from mcp.yaml

* fix(mcp): judge MCP configs by disk and manifest, not current config (#882)

Pull, doctor and uninstall still decided "clean" from the current team
config in places. Now one function, resolvedValueEvidence, decides for all
three:

- a teamai-owned entry still in the file counts when its server has left
  mcp.yaml, as well as when it needs a resolved ${VAR} or mcp.yaml cannot
  be read (doctor no longer skips that case)
- targets include the built-in location of a tool the team dropped from
  toolPaths or moved
- doctor names a file two tools share once
- exclude updates take the existing acquireLock helper, re-read the file
  and write it atomically, so concurrent commands keep each other's paths
- uninstall inspects every worktree of each repository owning a block,
  including a nested repository's linked worktrees, and applies the
  manifest rule per worktree
- the kept-block warning names each file and why, such as the variable
  whose value matched

* fix(mcp): skip the .git/info/exclude write while another command holds its lock (#882)

After the 2.5 s wait for the exclude file's lock, updateExclude wrote without
it, so two writers could drop each other's pattern and leave a plaintext MCP
config committable. It now writes nothing and reports 'locked': pull warns that
the file is not excluded yet and to run `teamai pull` again (doctor's exclude
check keeps reporting it meanwhile), and uninstall keeps the block and warns.

* fix(uninstall): keep an exclude entry unless its MCP config is proven free of teamai's servers (#882)

Uninstall judged a protected file clean from the current mcp.yaml, manifest
and resolvable values, so with the manifest lost, the server gone from
mcp.yaml and its value unset, a plaintext token looked like the member's own
server and the exclusion went. It now fails closed and works per entry: a
pattern goes only when its file is gone, holds no server, or holds none of
teamai's servers with managed-mcp.json still there to say what teamai wrote.
A kept entry is named with its file, why, and how to clean it by hand, since
a rerun of uninstall finds no config after a full uninstall.

* fix(mcp): exclude a project MCP config from git before writing a resolved value into it (#882)

Pull listed the file in .git/info/exclude only after writing the plaintext,
and a failed exclusion only warned, so the secret-bearing file stayed
eligible for git add -A. The exclusion now comes first; when it cannot be
established (exclude file or .git/info not writable, lock held past the
wait, file already tracked, git error) the file is left as it was and the
warning names the reason and the fix.

* fix(mcp): report a server withheld from a file git would commit in mcp list and doctor (#882)

* fix(mcp): report a tracked file on a dry run and a withheld server already installed (#882)

Backports #880's merge 0d9f7fa7: a dry run (doctor, mcp list) names a
tracked file before any pull has listed it, mcp list reports withheld for a
server an earlier pull installed, and doctor's withheld note carries the
exclusion's own fix instead of the pull --force advice.

* fix(mcp): name a tracked MCP config before an unwritable .git/info/exclude (#882)

A tracked file needs `git rm --cached` whatever else is wrong, so
ensureExcludedFromGit checks gitTracks before the writability check, on a
pull and a dry run alike, and lists nothing for it.

* fix(mcp): take a project MCP config's exclude line back out once it holds no resolved value (#882)

A pull that lists a config in .git/info/exclude and then writes no value
into it (it does not parse, a member's server holds the team's name, the
write fails) removes the line it added. After a pull or `teamai mcp
remove`, a line whose configs are proven clean in every worktree, by the
proof uninstall uses (moved to mcp-reconcile.ts), is removed under the
lock; one not proven clean stays. A config listed before its write is
listed again after it, so a concurrent uninstall that dropped the line
between the check and the write does not leave the value unprotected.

* fix(mcp): judge a project MCP config by the manifest as it stood before the pull rewrote it (#882)

A pull whose manifest was lost before it ran recreates managed-mcp.json
while reconciling, so the clean-file proof read the new record and took
the exclude line out of a file still holding a teamai server that left
mcp.yaml with its variable unset. The proof now uses this worktree's
manifest as read before the reconcile.

* fix(mcp): log a rolled-back exclude line at debug level (#882)

A line this pull added and took back out, because it wrote no resolved
value into the file, was reported as removed although the member never
saw it added. Only removing a line an earlier run added stays at info.

* fix(mcp): keep a shared exclude line while another worktree's config holds a server (#882)

A pull or `teamai mcp remove` judged every linked worktree's MCP config
with today's definitions and values. Once a server's ${VAR} became a
literal, and the value was no longer set, a pull in worktree A took
worktree B's stale token-bearing entry for clean and removed the shared
/.mcp.json line, so `git add -A` in B staged the token.

These commands now release a line only when the current worktree's file
passes the full proof and every other worktree's file is missing or holds
no MCP server. `teamai uninstall` keeps its full proof in each worktree.

* test(mcp): build the other worktree from the real temp path so the test checks what it names (#882)

* fix(uninstall): list no worktrees for a project root that no longer exists (#882)

buildRemovalPlan now lists every worktree to find teamai's exclude blocks,
outside the MCP cleanup's try. simple-git throws synchronously for a missing
directory, so a project uninstall whose root is gone crashed; main's #878 test
caught it after the merge.

* fix(mcp): treat an empty, unparsable or tool-less managed-mcp.json as no record (#882)

The clean-file proof took any managed-mcp.json on disk as teamai's record,
so an empty or truncated one let a pull, `mcp remove` or uninstall judge a
file still holding a stale secret-bearing entry clean and drop its exclude
line. A file now counts as recorded only when the manifest parses and holds
an entry for that tool's file. A project record teamai empties stays as []
so a file left with only the member's own servers can still be released.

* fix(mcp): keep the exclude line of a config this pull wrote when a later step fails (#882)

* fix(mcp): read a check-ignore error as unsafe unless ls-files proves the config untracked (#882)

* fix(mcp): report withheld only for targets delivery would write the server to (#882)

* docs(mcp): describe the .git/info/exclude block in the setup skill and the stricter git check (#882)

* fix(mcp): keep the exclude line of an entry a pull wrote with a resolved value after its definition turns literal (#882)

* fix(mcp): judge a nested repository's linked worktree config by its sibling's tool (#882)

* feat(mcp): record the project MCP configs a pull wrote a resolved value to in managed-mcp-files.json (#882)

* fix(mcp): keep protecting a config a pull wrote under a toolPaths mapping the team has since changed (#882)

* fix(mcp): keep a config's exclude line past the pull that rebuilt its lost record (#882)

* test(mcp): pin today's exclude rules for a missing, corrupt or locked managed-mcp-files.json (#882)

* docs(mcp): describe managed-mcp-files.json and the configs it keeps protected (#882)

* refactor(mcp): keep the #882 record edits off the lines #880 changes (#882)

* fix(mcp): protect a config an older teamai wrote under a mapping an earlier teamai.yaml made (#882)

A teamai from before managed-mcp-files.json kept no record of the path it
wrote a resolved value to. Once the team changed that toolPaths mapping, no
pull visited the file. The first pull on this version now reads every
mcpProject path the team repo's history of teamai.yaml mapped, once per
worktree: a file under the project root that no current mapping or record
reaches, and that holds a resolved value, is listed in .git/info/exclude and
recorded. A git error leaves the read for the next pull; a shallow clone
reads the history it has.

* fix(mcp): keep a rebuilt record from persisting without its note of the file's other servers (#882)

When a pull rebuilt a lost managed-mcp.json and could not note the other
servers in the file (managed-mcp-files.json locked, an I/O error), it still
wrote the rebuilt record, so no later pull knew the record was rebuilt and a
stale server's line could go. The same manifest write now marks those records
unnoted: the file counts as having no record, so it keeps its line while it
holds a server, and the next pull notes them and clears the mark.

* fix(mcp): take back a managed-mcp-files.json record for a config the pull then did not write (#882)

A pull records a config before writing a resolved value to it. When the
write failed or did not happen (the file does not parse), the record stayed,
and once the mapping changed a config of the member's own at that path was
kept excluded while it held any server. The pull now takes back a record it
added for a file it did not write, as it does the file's exclude line; the
settle after records it again if the file holds a resolved value anyway.

* docs(mcp): describe the teamai.yaml history read, the unnoted rebuilt record and the record a failed write takes back (#882)

* refactor(mcp): keep the r3 edits off the lines #880 changes (#882)

* fix(mcp): also protect a config an older teamai wrote under a built-in default it has since changed (#882)

* fix(mcp): judge a project MCP config under a symlinked directory where the write lands (#882)

The appliers replace the file itself (tmp + rename) but follow its
directories. Every git check now judges realFilePath(file), the one
resolver the release keying already used: a directory linked out of any
repository no longer withholds the servers on git's "not a git
repository", and a tracked file there is named with both paths, with a
git rm --cached that works (git refuses the path through the link).

* docs(mcp): describe how a config under a symlinked directory is kept out of git (#882)

* refactor(mcp): keep realFilePath next to existingAncestor, without an import cycle (#882)

* fix(mcp): judge a config under an earlier teamai.yaml mapping as a recorded file, not by today's records (#882)

* fix(mcp): have doctor check the configs earlier teamai.yaml mappings reach until a pull reads them (#882)

* docs(mcp): describe how a config under an earlier teamai.yaml mapping is judged, and doctor's check of it (#882)

* fix(mcp): record a config under an earlier teamai.yaml mapping that git tracks, and judge it once git no longer does (#882)

* fix(mcp): keep judging a recorded config for a tool the team moved while another tool still maps it (#882)

* docs(mcp): describe the tracked config an earlier mapping reached, and a moved tool's config another tool still maps (#882)

* fix(mcp): find a config an older teamai wrote under an earlier mapping another tool maps today, and judge it by that tool's records (#882)

* docs(mcp): describe the history read's configs another tool maps today (#882)

* fix(mcp): prove a shared config clean only while every tool that wrote a resolved value there has its record (#882)

* fix(mcp): judge a built-in location no mapping reaches today as an earlier-mapped file, and a shared config no pull recorded by every tool mapping it (#882)

* docs(mcp): describe the built-in location of a moved or dropped tool, and a shared config no pull recorded (#882)

* fix(mcp): name the ignore rule that re-includes a config teamai just listed, instead of saying git tracks it (#882)

* fix(mcp): judge a moved tool's built-in location another tool maps for that tool too, and hold a config's line while no managed-mcp.json claims its servers (#882)

* docs(mcp): describe the no-manifest rule, a moved tool's built-in location another tool maps, and a re-including ignore rule (#882)

* fix(mcp): keep a tool's record as it was when its config does not parse, and take back each tool a write that did not happen recorded (#882)

* fix(mcp): mark the records a pull with no managed-mcp.json writes as unnoted until the servers no record claims are noted (#882)

* fix(mcp): have doctor judge a record marked unnoted like a missing managed-mcp.json (#882)

* fix(mcp): judge a config tools of different formats share in each of their formats before releasing its line (#882)

* fix(mcp): note the servers no record claims in the file of a tool whose record a pull writes first, as with no managed-mcp.json at all (#882)

* fix(mcp): take back a tool managed-mcp-files.json recorded before a write unless its own records hold a resolved value there (#882)

* fix(mcp): treat an installed tool's missing record as lost at every pull and in doctor, not only an empty managed-mcp.json (#882)

* fix(mcp): note the unclaimed servers under every format of a shared config, and pin an uninstalled tool's leftover config (#882)

* fix(mcp): count an uninstalled tool's missing record when no installed tool maps its file, and name a re-including .gitignore rule on a dry run (#882)

* fix(mcp): scope a shared config's claims to the tools reading the same key, and settle its notes on every format's view (#882)

* fix(mcp): keep suspect an uninstalled tool managed-mcp-files.json lists as a writer, though an installed tool maps the file (#882)

* fix(mcp): judge a moved tool's file by the records of the tools reading the same key today (#882)

* fix(mcp): read a Copilot project config's bare servers beside the mcpServers another tool added, and remove teamai's there (#882)

* fix(mcp): keep a project config the local agent writes a header or env value to out of git, and have doctor check it for HTTP teams (#882)

* fix(mcp): document the local agent's project-scope exclusion and Copilot's bare servers beside mcpServers (#882)

* fix(mcp): tell the local agent's withheld install to install the MCP server again, not to pull (#882)

* fix(mcp): replace a Copilot server's bare copy when pull or the local agent writes it again under mcpServers (#882)

* fix(mcp): count a local-agent install's arguments, URL user or query and command line as credentials, and scope the rebuild's claims by key (#882)

* fix(mcp): count any URL in a local-agent install as a credential, and have doctor judge a recorded HTTP-team file with no record (#882)

* fix(mcp): read OpenCode's command array, remove only teamai's own bare Copilot copy, hold a shadowed one, and judge a partly lost HTTP record by its entries (#882)

* fix(mcp): list a project config an older local agent wrote a credential into on the next sync or pull of an HTTP-backed team (#882)

* fix(mcp): document that the local agent's sync and pull list an older install's credential file (#882)

* fix(mcp): have the local agent's sync say a new session tries again and call the file's content a credential (#882)

* fix(mcp): run the local agent's sync protection after an uninstall_teamai too: a failed or partial one leaves what to keep out of git (#882)

* fix(mcp): judge a local-agent entry a failed write left by what it holds, not by the new install's resolved: false (#882)

install_mcp records the new entry before writing the config. When that
write fails, the older entry, credential included, stays in the file
while the record says resolved: false. localAgentCredentialFiles now
trusts resolved: false only while the entry on disk has the recorded
hash; otherwise it checks the entry itself. The doctor fixture that
used a placeholder hash now records the hash an install writes.

* fix(mcp): keep a moved Copilot's stale bare server apart from a name another tool owns under mcpServers (#882)

recordedMcpFileEvidence merged a Copilot project file's bare servers with
those under mcpServers by name, so Claude owning a nested jira read as
owning a bare jira Copilot wrote with a token before it moved. Bare
servers now count as owned only by a Copilot record: no other tool
writes there.

* fix(mcp): have the local agent's protection read CodeBuddy's former default and a bare Copilot entry apart (#882)

An HTTP team has no teamai.yaml history, so localAgentCredentialFiles never
visited a built-in default teamai has since changed: a credential a local
agent from before 57636a27 wrote to .codebuddy/mcp.json stayed committable.
It now also reads EARLIER_BUILTIN_MCP_PROJECT (earlierMappedMcpTargets with
history: false), in sync and doctor alike.

It also judged a Copilot project file through installedMcpEntries, which
merges bare servers with mcpServers by name, the keyed one winning. A
tokenized bare entry beside a credential-free mcpServers entry of its
name, both under one record, went unseen. Each entry is now judged on its
own (mcpEntriesByPlacement, which recordedMcpFileEvidence uses too).

* fix(mcp): prove bare ownership and persist installs before exclusions (#882)

* fix(mcp): keep bare ownership from claiming keyed Copilot entries (#882)

* fix(mcp): require evidence for unmarked Copilot ownership (#882)

* fix(mcp): preserve ownership across failed config updates (#882)
2026-09-30 22:10:35 +08:00
Saul Moro 260f1945e8 feat(agents): model aliases so one agent works in every tool (#830) (#924)
* refactor(agents): each tool reads its own tool_extras key (#830)

Qoder, Qoder CN, ZCode and OMP rendered tool_extras.claude while push
wrote their edits to their own key, so an edit never reached them.
tclaude and tcodex now read their own key over claude and codex; push
writes only the values that differ from the base and reports an edit
that removes an inherited field. Reverse parsing keys extras by tool,
so a new native agent's extras land where its renderer reads them.

* feat(agents): resolve team model aliases on pull for Claude and Codex (#830)

An agent can set model: strong, model: fast, or an alias the team defines
in models/aliases.yaml, which maps each alias per tool to that tool's own
model value and an optional effort. Pull writes the mapped model into each
tool's agent file, with effort as `effort` for the Claude family and
`model_reasoning_effort` for the Codex family. A tool the alias does not
map gets no model field; tool_extras.<tool>.model skips the alias.

Push compares deployed copies with the same resolved rendering, so a
pulled alias agent is not reported as edited. A non-string model fails
to parse, a legacy .md agent with an alias model is copied with a
warning, and an unreadable aliases file holds the agents that depend on it.

* feat(agents): resolve model aliases for every agent tool (#830)

* feat(agents): record alias resolution and redeploy on the pull fast path (#830)

* feat(agents): apply the member's local model alias override (#830)

* feat(agents): let tools switched to a model profile inherit their model (#830)

* feat(agents): keep model aliases intact on push and report drift (#830)

* feat(agents): hold alias agents on invalid alias files and drop bad entries (#830)

* feat(agents): namespaced model alias files (#830)

* feat(doctor): show how each agent's model alias resolved (#830)

* fix(agents): deliver agents held by a broken aliases file on an ordinary pull (#830)

* test(agents): stateful acceptance for model aliases and adoption docs (#830)

* fix(agents): keep push from promoting alias-owned or delivered fields (#830)

* fix(agents): deliver held agents and quiet the pull fast path (#830)

A pull that holds an agent for model resolution no longer records the team revision, so a local fix is delivered by the next pull. The fast path skips copies the member edited, re-renders untouched copies an older CLI rendered differently, and names what it wrote. Records carry the alias name, so removing an alias that gave a tool no model warns. A changed model no longer fails doctor's delivery check, and a switched tool gets no extras effort.

* refactor(agents): dedupe alias helpers (#830)

One extras base-tool table (EXTRAS_BASE_TOOL) for render and push, one model-record comparison for pull and doctor, and the per-tool renderers renderForTool no longer used are removed, their tests moved to renderForTool.

* test(agents): cover delivering held agents after the cause is fixed (#830)

* fix(agents): scope local override failures to alias agents and warn on inactive-only aliases (#830)

* fix(agents): reject aliases files without an aliases key, report fast-path holds, keep extras pins out of adoption (#830)

* docs(agents): note that an extras model pin never adopts an alias on push (#830)

* fix(pull): preview model holds in dry-run and do not report a held pull as complete (#830)

* fix(agents): hold only tools that fail resolution and keep resolved values out of alias adoption (#830)

* fix(agents): name the held tools when a broken aliases file holds only some of an agent's tools (#830)

* fix(agents): write a model edit over a tool's own extras pin back to that pin on push (#830)

* fix(agents): deliver a deleted recorded copy on the fast path, adopt an alias only for a changed model, keep unchanged own extras keys (#830)

* test(agents): cover push of a tclaude hand edit under each kind of model pin (#830)

* fix(pull): name an edited agent copy the fast path keeps for a changed model (#830)
2026-09-30 22:09:37 +08:00
Saul Moro 5fbaa377c7 feat(recall): attribute recall and adoption to the agent session in every agent (#925)
* refactor(usage): extract the locked JSONL store from the usage file (#884, ticket 01)

Move the usage file's lock, side-record, fold and rewrite protocol into
src/utils/jsonl-store.ts so the recall log can reuse it. The usage file
keeps its behavior: same waits, mode, side-file names and gitignore healing.

The store adds what the recall log needs: reads that include unfolded side
records, owner-only files by default, and a newline before an append when a
killed writer left a torn last line.

* feat(recall): attribute a Claude direct recall to the doc it opens (#884, ticket 02)

recall prints run=<id> on the region start line and appends a run record
(session from the environment, each doc's vote key, scope, printed path and
eligibility; never the query) to the active scope's recall log. A new
PostToolUse handler appends a claim for a Bash call that ran teamai recall,
and evidence for a Read under the knowledge roots. votes-sync now credits
through the recall-log reducer instead of the transcript parser and no longer
needs transcript_path.

* feat(recall): exclude the recall subagent's own reads, mark its runs with --caller (#884, ticket 03)

Claims and evidence carry the actor (session, agent_id, agent_type). A run
is marked as the teamai-recall subagent's by its hidden --caller or its
claim's agent_type; reads by that actor never count for it, while reads by
the main agent or any other subagent do. The recall agent passes
--caller teamai-recall on every recall and drops the unread
referenced-doc-ids instruction.

* feat(recall): settle each run by a valid claim or an unambiguous env session (#884, ticket 04)

A run now records its agent family, via (env or none) and whether the
environment held a single session id. The hook records a claim for every
run id a shell call printed, noting whether its command ran teamai recall
itself (parsed, so a quoted mention does not count); it also reads Codex's
string tool_response. The reducer applies only the earliest direct claim for
a run in the log, else the env session when unambiguous; unsettled runs
never vote. agentSessionIdFromEnv callers are unchanged.

* feat(recall): count a shell reader's read of a recalled doc, the Codex path (#884, ticket 05)

Add the tool-call classifier (src/utils/tool-call.ts): category, paths read
and status per PostToolUse, replacing recordToolCall's Bash and Read branches.
The shell tokenizer moves to src/utils/shell-command.ts and now returns each
simple command's operator and redirects; invokesRecall and the reader check
share it. A read is one reader command (cat, bat, batcat, less, more, head,
tail, nl, sed -n printing lines) alone or at the head of a pipeline; any ;,
&&, || or & makes the call no read. A failed read is not recorded; a read of
unknown status (Codex's string tool_response) counts only when simple.

* feat(recall): count a search that shows a recalled doc's lines, never a listing or a count (#884, ticket 06)

A search tool in content mode, or a shell grep/rg/ag/ack/git grep, records
evidence for each file under the knowledge roots whose lines its output
shows: a line that starts with the file's path and ':', under a searched
root, or the lone operand when the output is non-empty. The hook only scans
the output it already holds; it logs resolved paths, never the command or
the output. Listings (Glob, ls, find, fd, tree, rg --files, git ls-files,
grep -l/-L, a Grep file list) and counts (-c, --count, count mode) record
nothing. OMP's and Cursor's search tools produce no evidence yet.

* feat(recall): count Windows reads: PowerShell readers, drive paths, Git Bash /c/ (#884, ticket 07)

PowerShell (Claude, CodeBuddy, Copilot) is a shell tool. Get-Content, gc,
type and cat read their -Path, -LiteralPath and positional files. Paths are
resolved and compared by the new agent-path module, independent of the host:
drive-letter case, either separator and Git Bash /c/ name one file, and a
search line's drive colon is part of its path.

* fix(recall): compare Windows paths without case, look up shell verbs by own key (#884, ticket 07 follow-up)

Windows paths ignore case, so a drive-lettered path's comparison key is now
lowercased whole, as isUnderRoots did through path.win32.relative before
ticket 07. A verb such as `constructor` no longer resolves to an
Object.prototype member in the reader and search tables.

* feat(recall): count Cursor, Copilot, CodeBuddy, WorkBuddy, Qoder and ZCode reads (#884, ticket 08)

Session derivation reads Cursor's conversation_id after session_id and
sessionId. The classifier reads Cursor's tool_output JSON string and
Copilot's tool_result (text_result_for_llm, result_type), a read's path from
file_path, filePath or path, and the names view, ReadFile, bash, Shell and
run_in_terminal. Unverified payload shapes are marked in the row names.

* feat(recall): OpenCode bridge sends its session, output and status, and links task children to their parent (#884, ticket 09)

* feat(recall): Pi and OMP bridges send their session, output and status; OMP subagents their agent id and type (#884, ticket 10)

* feat(recall): credit late reads at SubagentStop and pull, and prune the recall log at pull (#884, ticket 11)

- Register SubagentStop for Claude Code, Codex, CodeBuddy and Qoder (and
  their internal builds); votes-sync runs the reducer there, without the
  end-of-turn summary.
- `teamai pull` credits sessions whose evidence is still pending, then
  prunes the log under its lock: 30 days, then 5000 lines, never dropping
  pending evidence younger than 24 h or the run, claim, credited reads and
  links it needs to vote. Evidence and its consumed lines go together.
- A run's doc is credited once, so a late read drained after the ledger's
  24 h window cannot vote twice.
- The store gains appendJsonlBatch (one lock, one write, one side record);
  a hook call and a reducer pass each make at most one write. Folds now
  work per line.

* refactor(recall): the upvote judge skips docs in the session's ledger, and the transcript parser loses its adoption logic (#884, ticket 12)

The opt-in judge (TEAMAI_UPVOTE_JUDGE) excluded the parser's adoptedDocIds on top of the session's upvote ledger. Since votes-sync credits from the recall log, a doc the parser counted but the hook path did not (a Glob listing, say) was neither credited nor judged. The judge now excludes only the ledger, and the parser keeps its recalled-doc detection, region parsing and recalled-doc-ids comment for the judge and older-format sessions.

* fix(recall): the upvote judge credits the turn before reading the ledger (#884, ticket 12 follow-up)

The dispatcher starts the detached judge before the foreground votes-sync
credits the turn, so a doc opened in that turn was sent to the judge CLI
once. The judge now runs the same reducer pass first; the ledger and the
consumed marks make a second pass a no-op.

* feat(stats): a recall section lists the last 10 sessions' runs, recalled and adopted docs (#884, ticket 13)

* fix(stats): name the agent whose hook claimed a recall run (#884, ticket 13 follow-up)

A claim now records the dispatch tool id of the hook that sent it, and the
recall section prefers it over the run's environment family. A Codex run
started from a Claude shell, or an OMP run, showed '-' before.

* docs(recall): one adoption section with the per-agent table and known limits (#884, ticket 14)

The usage guides gather adoption, the recall log, run ids, run ownership,
subagents, vote timing and the stats section under "Recall adoption and
upvotes", with a direct-recall / subagent-path table per agent and the
known limits. The core skill's troubleshooting reference says where a
recalled doc gets its upvote, and the recall subagent no longer says the
Stop hook parses its doc-id comment for adoption.

* docs(skill): CodeBuddy and WorkBuddy hooks are installed, not skipped (#884)

The core skill's hooks table said CodeBuddy and WorkBuddy hooks are skipped
by design, which contradicts the settings.json injection in src/hooks.ts and
the per-agent adoption list this PR adds to the same file.

* test(hooks): Claude settings carry the SubagentStop hook (#884, ticket 11 follow-up)

Ticket 11 registers votes-sync on SubagentStop for Claude; the hooks e2e
still expected four events. Cursor keeps four.

* fix(recall): a search line counts only from a file-name prefix, and the log keeps only .md paths (#884, review finding 1)

A one-file search's lines carry no path, so text before a colon (Cause:,
a -n false Grep line, Pi's basename) no longer becomes <doc>/<text> in the
recall log. Other lines need a prefix ending in a file name, then :<line>:,
or : in grep/rg path:text and OpenCode's header. The recorder drops any
evidence path that is not .md.

* fix(recall): a one-file search counts only when it shows a line, not a tool's no-match text (#884, review finding 2)

OpenCode's No files found and Pi's and CodeBuddy's No matches found made a
grep of the doc that found nothing credit it. The target now needs a line
that is neither blank nor a search tool's status line. Shell searches
print none, so their lines are never taken for one.

* fix(recall): the upvote judge keys each doc by the key its run recorded, not the parser's basename (#884, review finding 3)

The transcript parser names learnings/setup and a skill's SKILL.md by
their basename, so a doc the hook path had credited was judged again and
upvoted under another doc's key. recalledKeyOf maps the printed path to
the run's key for the ledger check and the upvote; a path no run printed
keeps the parser's id.

* fix(recall): SubagentStop credits locally and leaves the vote push to the next Stop (#884, review finding 4)

The main agent waits on SubagentStop, so the git round trip of the
reports push (and the ledger prune) blocked it mid-turn, up to the
handler budget, after every subagent while deltas were pending. Stop
and pull push what it credited. Stop is unchanged.

* docs(hooks): state only what is known about Codex and an unknown hook event key (#884, review finding 6)

Codex 0.159 knows SubagentStop, and Codex main's HookEventsToml has no
deny_unknown_fields. How older hook-capable builds parse an unknown key is
unverified.

* docs(skill): restore the Claude hooks row to its main wording (#884, review finding 7)

The CodeBuddy/WorkBuddy row keeps its fix, which the adoption section
below needs; the Claude row's rewording was unrelated to #884.

* refactor(recall): the adoption window and retention constants are module-private (#884, review finding 8)

Nothing imports them, tests included.

* fix(recall): an unquoted # that starts a word begins a shell comment (#884, PR review finding 1)

`cat other.md # /kb/doc.md` reads only other.md, yet the doc counted as
an operand. The comment runs to the end of the line; `a#b` and a quoted
`#` stay literal. A claim with a trailing comment still claims.

* fix(recall): an unquoted backslash escapes a space or shell character, and keeps a Windows path whole (#884, PR review finding 2)

`cat /kb/my\ doc.md` was split into two operands. Outside quotes a
backslash now escapes whitespace and ' " \ $ ` ( ) & ; | < > * ? # !;
before any other character it stays literal, so an unquoted
`C:\kb\learnings\x.md` is unchanged. Double quotes already followed
POSIX for \" and \\, and single quotes stay literal.

* fix(recall): a claim skips the root program's options before recall (#884, PR review finding 3)

`teamai -v recall "q"` is a valid recall, but its claim was marked
indirect, so a run with no session variable (OMP, via: none) never
settled. The root options now live in one table that index.ts registers
on the program and the claim parser reads through Commander's Option, so
it also skips the value of an option that takes one. `npx teamai-cli[@v]`
is handled as before.

* fix(recall): with no status, a shell call that printed only its command's errors failed (#884, PR review finding 4)

Codex sends no status, so `grep needle /kb/doc.md` answering
"grep: /kb/doc.md: Permission denied" counted as the doc's content and
upvoted it; `cat doc` answering "cat: doc: No such file or directory"
did the same. When the status is unknown, output lines that start with
the command word (as written or by name) and ": " are errors, not
content. A read or search that printed nothing else is a failure and
records no evidence. Both usage guides say so.

* fix(recall): Copilot's SessionEnd credits and pushes the session's votes (#884, PR review round 2 item 1)

Copilot's last turn can end with SessionEnd and no Stop, so votes-sync now
runs on session-end too: foreground, git only, with Stop's timeout. It
pushes as Stop does and prints no adopted summary. The Copilot rows end with
the real SessionEnd payload instead of a synthetic stop.

* fix(recall): a search with no hits prints its run id, so its shell call can claim it (#884, PR review round 2 item 2)

The no-hit line now ends in run=<id>, and the claim pattern reads it after
the logger's glyph. An OMP recall with no hits and no session variable
settles through its bash call's claim and shows in teamai stats. --check
still prints no run id and records no run.

* fix(recall): type and gc read only in PowerShell or for a Windows path, and a shell's own diagnostic is an error line (#884, PR review round 2 item 3)

In bash, type is a builtin that prints what a name is, so Codex's
type /posix/doc.md under Linux gave a false upvote with no status. type
and gc now count under a PowerShell tool, or when every file is a Windows
path. Get-Content counts everywhere. With no status, bash:, sh: and zsh:
diagnostics are error lines too.

* fix(recall): with no status, a read drops a file its command's error names (#884, PR review round 2 item 4)

In cat a.md doc.md where only doc.md errors, doc.md still counted. An error
line <verb>: <file>: … now removes that file from the read's paths, matched
as written and as resolved against the cwd; the other files still count.
2026-09-30 22:08:44 +08:00
Ruben 128844fe6c fix(pull): name the skills a pull removes because they are no longer delivered (#911) (#917)
* fix(pull): name the skills a pull removes because they are no longer delivered (#911)

* fix(pull): keep the tags subscribe hint when the removed skill copy is byte-identical to its inactive namespace source (#917 review)
2026-09-30 16:04:19 +08:00
ydflowandydflow d4d1c6418e fix(projects): honor dry-run when selecting projects (#905)
Co-authored-by: ydflow <314143294+ydflow@users.noreply.github.com>
2026-09-30 15:04:29 +08:00
Saul Moro 7c929a996b feat(env): team-declared secrets with member-local values (#875) (#880)
* refactor(entries): let a namespaced entry reader declare its own layout (#879)

An entry reader took its directory, file name, activation key and failure
wording from its EntryType. It can now declare them as an EntryLayout,
defaulting to entryLayout(type), which gives today's values. This lets a
later reader read env/secrets.yaml and env/<ns>/secrets.yaml activated by
resources.env. No behaviour change: env, hooks, MCP and models resolve and
report as before.

Part of #875.

* refactor(models): share the team values path and stdin reader with a second store

getTeamValuesPath takes the store directory (defaulting to models/teams)
and keeps its <team>-<hash>.json naming. The piped-stdin reader moves to
utils/prompt.ts as readStdin; the --api-key-stdin checks and messages stay
in the models command. No behaviour change.

Refs #879 (S2), #875

* feat(env): declare team secrets in env/secrets.yaml and show their state (#879)

A team repo can declare the secrets its members need, with no value, in
env/secrets.yaml and env/<ns>/secrets.yaml (key, optional description and
url). They resolve like env.yaml: active through resources.env, a namespace
entry replaces the root entry with the same key. The declarations are
absent, valid or failed; a broken file fails the secrets only, is reported
in secret wording by pull, env list and doctor, and env variables are still
delivered.

- env list and list env show each declared secret as environment or
  missing, never its value, --reveal included.
- doctor fails "Team secrets can be resolved" on a broken file, and its
  notes name env/secrets.yaml, not env/env.yaml, for an override or a key
  repeated in legacy mode (describeEntryNotes takes the reader's layout).
- push lists a changed secrets.yaml, in single-repo mode too.
- docs/designs/team-secrets.md and .zh-CN.md start here, with the #818
  boundary; usage guide, product overview, multi-project, management
  backend and the admin reference updated.

Part of #875.

* feat(env): declare a team secret with env add --secret (#879)

env add <key> [value] --secret [-d] [--url] [--role|--project] writes
env/secrets.yaml or env/<ns>/secrets.yaml with no value; a value is
rejected and never printed. env remove removes a declared secret when
env.yaml does not set the key, and --secret removes only the declaration
for a key both files carry. entryNamespaceFromFlags takes a layout so
the --role warning names secrets.yaml.

* feat(mcp): keep the entry an earlier pull wrote when a declared secret is missing (#879)

The session-start pull inherits the agent's environment, which often lacks
the member's shell export, so it removed the MCP entry the interactive pull
had written. A server whose only missing variables are declared secrets now
keeps its entry and ownership record; it is removed when it leaves mcp.yaml
or by removeAll. A failed secrets declaration keeps managed MCP state.

* feat(env): keep a member's value for a team secret and resolve it in MCP servers (#879)

teamai env set KEY (hidden prompt, --stdin, --from-env VAR) and env unset KEY
store a member's value per team repo in ~/.teamai/secrets/teams/, 0600,
accepting only keys the scope declares as secrets. ${VAR} in MCP servers
resolves a declared secret from that value, then from the member's own
environment, which leaves out values a teamai env.sh exported (Conflict 10).
A key declared as a secret and set in env.yaml resolves as the secret: its
repo value leaves env.sh, the env backup, both list renderers and doctor's
expected set (Conflict 13). A failed declaration leaves env.sh and the backup
as they are (Conflict 14). env list shows team.

* docs(env): document team secret values, storage and MCP resolution (#879)

* feat(env): set one value for a team secret for every team on the machine (#879)

env set/unset --global keep the value in ~/.teamai/secrets/machine.json.
Resolution becomes team value > machine value > the member's environment,
for MCP servers and env list (state `global`). In a scope --global still
accepts only a declared secret; outside any scope it accepts any valid key
and notes that no team declares it yet.

* docs(env): document the machine value for team secrets (#879)

* feat(mcp): keep project MCP configs with resolved values out of git (#882)

A project-scope MCP config that carries a resolved ${VAR} sat untracked
and unignored in the business repo, one `git add -A` from committing the
token. After the reconcile writes such a file and git would track it,
teamai lists its path in the clone's .git/info/exclude inside a marked
block (resolved via `git rev-parse --git-path`, so linked worktrees and
submodules work). The committed .gitignore is never touched; an ignored
path or a config with no resolved value adds nothing; dry runs write
nothing. Project-scope uninstall removes only teamai's block, and doctor
reports such a file git would still commit.

The hook sits after the appliers in reconcileMcpForConfig, outside
desiredMcpForTarget/applyJson/applyCodex, so it merges cleanly with #880.

* feat(env): name a missing team secret and the command that sets it (#879)

Interactive pull, mcp list, env list and doctor print one line per declared
secret with no value, naming the MCP servers that use it, `teamai env set KEY`
and the declared url. doctor prints it as a note and no longer fails the MCP
delivery check for a server skipped only for a missing declared secret. Pull
and doctor also note a kept entry that may hold an old value and a key
declared as a secret and set in env.yaml. The silent pull prints nothing.

The lines come from one envAdvisories() result that later pull notices extend.

* docs(env): document the missing-secret advisory in pull, doctor and the lists (#879)

* fix(uninstall): count the .git/info/exclude block in the removal plan (#882)

The plan now records whether the project's .git/info/exclude holds
teamai's MCP config block (gitExcludeBlock). It counts toward
isPlanEmpty, is listed in the summary and dry run, and gates the
removal, so a plan whose only teamai leftover is the block removes it
instead of reporting "Nothing to uninstall".

* feat(env): run a command with the directory's team env and secrets via env exec (#879)

* docs(env): document env exec, what the command can reach and the ANTHROPIC_* caveat (#879)

* fix(tests): isolate Claude config dir from model tests

(cherry picked from commit e567ac31e5)

* fix(env): warn when env add --secret updates a declaration an unknown key keeps undeclared (#879)

* feat(env): tell agents at session start which team secrets exist and to run their CLIs through env exec (#879)

* docs(env): teach agents to use team secrets through env exec and leave values to the member (#879)

* feat(env): resolve plain env variables in one order, with a member override (#879)

The environment no longer overrides an env.yaml variable in MCP servers,
env exec or env.sh. A member sets their value for a team with
`teamai env set KEY`; an interactive pull and doctor say when an export
differs and is ignored. env.sh exports a literal override and leaves out a
--from-env one; doctor's env delivery check expects the same.

* docs(env): document the variable order and the member override (#879)

* fix(mcp): keep the exclude block until every MCP config is clean (#882)

- uninstall keeps a repository's .git/info/exclude block while a config in
  it could not be parsed and still holds teamai servers, and warns
- uninstall finds and removes the block in nested repositories holding an
  MCP config, across every worktree
- the block opens at the last start marker, so an orphaned start never
  pairs with a later block's end and takes the member's lines
- doctor counts only servers the ownership manifest records, not a member's
  own server under a team name

* fix(env): discount an old env.sh export in every later command (#879)

A shell opened before a pull keeps the values env.sh exported then. Only
the process that rewrote env.sh discounted them, so the next command read
the team's old token as the member's own (Conflict 10). Each env.sh now
keeps env.sh.exports.json beside it: SHA-256 of KEY=VALUE for the last 20
values per key, mode 0600, never a value. memberEnvironment discounts any
recorded export, which replaces the in-memory snapshot.

* fix(uninstall): remove the exclude block only once its files are proven clean (#882)

A missing or unreadable managed-mcp.json made the MCP cleanup return early
without reporting anything, so uninstall removed the block while .mcp.json
still held the resolved token.

Uninstall now inspects every path the block protects after the cleanup. The
block goes only when each one is missing, or parses and holds none of the
team's servers that need a resolved ${VAR}. Anything it cannot check keeps
the block, with a warning naming the file. This replaces the leftInPlace
report from the reconcile, which the check subsumes.

* fix(mcp): protect every project MCP config holding a resolved value (#882)

Pull, doctor and uninstall each skipped a case they had not inspected and
treated it as safe. Now:

- pull lists a config in .git/info/exclude whether or not it delivered to
  it this run: a disabled or undetected tool's file, a team with automatic
  delivery off, an unreadable mcp.yaml (any teamai entry counts), a failed
  write to another tool's config, and a lost ownership manifest (the
  resolved value found in the file)
- doctor checks the same files, including one that does not parse, and
  counts a git error as a failure
- git check-ignore failing inside a repository is no longer read as "not
  tracked": the path is excluded anyway, or teamai warns with git's error
- uninstall also keeps the block while a file contains the value (8+
  characters, not a path or the login name) of a variable still set in the
  environment, which finds a server since dropped from mcp.yaml

* refactor(env): resolve a scope's env once per command (#879)

resolveTeamEnv (src/env-resolution.ts) reads env.yaml, secrets.yaml,
both value stores and the env.sh exports once. buildVarTable, the MCP
reconcile, envAdvisories, env exec, doctor and pull take that result:
an interactive pull resolves once per scope in its env stage and reuses
it for MCP and the advisories (was up to four secrets.yaml reads).

- Variable and secret values move out of resources/secrets.ts, which now
  holds declarations only (R3); one StoreResolution<V> and storeEntry
  (R2); resolveSecretDeclarations takes { active } instead of a
  tri-state array (R4); reportMissingSecrets lives in env-advisories (X1).
- ENV_KEY_RE moves to resources/env-key.ts, so env.ts imports
  SECRETS_LAYOUT statically (EV1).
- env list and `teamai list env` print one listing (env-listing.ts);
  a variable shows the value it resolves to and its source, team or
  env.yaml, like a secret (S1).
- A failed declaration is never "no secrets": the listings show no
  variable value then, --reveal included, and a server skipped for a
  missing variable is kept (S2).
- SecretState gains `unreadable` for a store that can't be read,
  handled with a never check (R1).

* fix(mcp): mcp list reports a broken secrets.yaml and exits 1 (#879)

A failed declaration read as no secrets, so every server variable came
from the raw environment, another team's token included, and mcp list
said 'all set' while pull kept MCP frozen. It now names the file, exits
1, and shows a server's variables as 'not resolved' (Conflict 14, C3).

* fix(env): env commands outside a scope say so and exit 1 (#879)

env list, add, remove, set and unset threw NotInitializedError with a
stack trace outside any teamai scope. They now print its message and
exit 1; env set/unset --global still work there.

* fix(env): env exec refuses a command without -- before it (#879)

Commander drops --, so `teamai env exec gh pr list --dry-run` read the
command's flag as teamai's and ran nothing. env exec now takes what was
typed after `exec`, and without -- before the command it says
"Put -- before the command: teamai env exec -- <command>" and exits 2.
teamai's own options may still come before --.

* feat(doctor): check that the member's secret values can be read (#879)

While teams/<team>.json or machine.json can't be read, every secret has
no value and MCP keeps what the last pull wrote, yet the MCP check
passed and only a pull warning said why. doctor now fails
'Your team secret values can be read' with the reason, for a scope whose
secrets or variables read those files.

* fix(env): name the unset variable a --from-env secret reads (#879)

The missing-secret line told the member to run `teamai env set KEY`,
which replaces the reference they chose. For a secret whose deciding
entry reads an unset variable it now says: KEY reads VAR, which is not
set. Set VAR, or run `teamai env set KEY [--global]` to replace the
reference.

* test(mcp): env unset keeps the entry, still owned, until a new value (#879)

env set, pull, env unset with nothing exported, pull: the entry keeps
the old value and the manifest still claims it, so the next value
replaces it instead of colliding with a user-owned server.

* refactor(env): expected env set/remove failures as values, one wording (#879)

- secretInput returns a tagged result; an unexpected stdin or prompt
  failure propagates instead of printing as a user error (E1).
- Every error path in env-commands uses fail() and invalidKeyMessage(),
  so each exits 1, env add's invalid key included (E2).
- removeSecret returns removed | absent | reported: env remove of a
  variable env.yaml lacks still says it is not found next to a broken
  secrets.yaml (E3).
- env add branches on --secret, not on a missing value (E4).
- env unset with declarations that can't be read no longer calls the key
  a variable (E5).
- "Secret is not declared" names the file and the next step (E6).
- Messages call the machine store "global value (every team on this
  machine)", matching --global and the env list label (R5).

* refactor(entries): EntryFailure always carries its layout (#879)

An optional layout fell back to the type's, so a failure site that forgot
it would word a broken secrets.yaml as env's. activeEntryNamespaces now
takes the layout and every failure sets it (N1).

* refactor(doctor): name entry resolution checks with their layout (#879)

The check names were looked up by the layout's message label, so a
wording change to a label would silently drop its doctor check. Each
entry set now carries its check name (DD1).

* refactor(secrets): report an unparsable value store by path only (#879)

- Drop src/utils/json-position.ts, a second JSON grammar kept only to
  print a line and column: a parse error now reports the path alone,
  which is what the spec asks (never the input), and a disagreement with
  JSON.parse can no longer leak the parser's quote (SS3).
- The unreadable-store error names what to check and the way out, and
  reads the error code without a cast (SS1).
- SecretStore is inferred from its schema (SS2).
- The design doc says an unreadable store leaves secrets 'unreadable'.

* test(secrets): typed fixtures instead of casts in the new tests (#879)

The LocalConfig and TeamaiConfig fixtures in env-exec, mcp-secrets and
secret-values are now checked against the types, so a new required
field breaks them instead of testing a shape production never has (T1).

* docs(env): env set takes variables, listings show sources, exec needs -- (#879)

- Usage guide: `env set` accepts an env.yaml variable without --global,
  not only a declared secret (D1/C2); `env exec` links the resolution
  order instead of "the order above", which the section never gave (C4).
- team-secrets.md intro states the variable order; a --from-env variable
  override leaves the key out of new shells entirely.
- Document the shared listing (variable source, `unreadable`, no values
  while the declarations fail), `mcp list`'s `not resolved`, the doctor
  check for an unreadable values file, and `env exec`'s exit 2 without --.

* fix(env): refuse env set/unset/list when the project config can't be read (#879)

Detection returned null for an unreadable project config, so env set fell
back to the user scope and stored the value for that team. Report the file
and why, exit 1, and write nothing, as pull and env exec do.

* fix(env-exec): exit 128 + signal for a command killed by SIGPIPE or SIGUSR1 (#879)

Re-raising SIGPIPE on teamai does nothing (Node ignores it) and SIGUSR1
starts the inspector, so teamai exited 0. Set the shell's exit code first
and re-raise only the signals Node ends on.

* fix(env-exec): don't send the command a second SIGINT on Ctrl-C (#879)

The terminal sends Ctrl-C and Ctrl-\ to the whole foreground process group,
so the command already has them; forwarding sent a second SIGINT, which
tools such as terraform take as force-quit. Ignore SIGINT and SIGQUIT while
the command runs and keep forwarding SIGTERM and SIGHUP.

* docs(env): exec signal handling and env set on an unreadable project config (#879)

* fix(mcp): judge MCP configs by disk and manifest, not current config (#882)

Pull, doctor and uninstall still decided "clean" from the current team
config in places. Now one function, resolvedValueEvidence, decides for all
three:

- a teamai-owned entry still in the file counts when its server has left
  mcp.yaml, as well as when it needs a resolved ${VAR} or mcp.yaml cannot
  be read (doctor no longer skips that case)
- targets include the built-in location of a tool the team dropped from
  toolPaths or moved
- doctor names a file two tools share once
- exclude updates take the existing acquireLock helper, re-read the file
  and write it atomically, so concurrent commands keep each other's paths
- uninstall inspects every worktree of each repository owning a block,
  including a nested repository's linked worktrees, and applies the
  manifest rule per worktree
- the kept-block warning names each file and why, such as the variable
  whose value matched

* fix(env): the env commands and env exec load config through --dry-run (#866)

#866 threaded { dryRun } through the loaders pull, push, status and list
use. The scope lookups this branch added did not take it, so
`teamai --dry-run env list|set|unset|add|remove` and `env exec --dry-run`
still persisted the legacy role migration, adopted a pre-#546 partition
or ran the self-mode bootstrap.

- resolveConfigForDir takes LoadOptions and passes them to both loaders.
- scopeHere/requireScope (env commands) and commandEnvironment (env exec)
  forward options.dryRun. Without the flag nothing changes: env exec still
  runs the migrations every command runs (spec #879 Conflict 12).

Five cases added to dry-run-load-path.test.ts; each failed before this
change (config.yaml rewritten, partition renamed).

* fix(secrets): name the team values file by the repo identity hash alone (#879)

Renaming team: in teamai.yaml changed <team>-<hash>.json and orphaned every
member's values. The secrets store now uses ~/.teamai/secrets/teams/<hash>.json;
the models key files keep their names. The path has not shipped, so there is
no migration.

* fix(env): keep a __proto__ env key through the store, MCP and env exec (#879)

ENV_KEY_RE accepts __proto__, but z.record dropped it from the value store,
assigning it on an ordinary object hit the inherited setter, and reading it
unset returned Object.prototype. The store, the MCP var table and the env exec
overlay are now built without a prototype (envTable), and the member's
environment and --from-env references are read as own keys (envValue).

* fix(env): env list loads config with --dry-run always (#879)

env list only reads, so like status and list since #866 it never migrates
the config it loads, with or without --dry-run. The dry-run load-path test
now runs env list without the flag, which is the case that used to migrate.

* revert: drop the #890 cherry-pick from this branch

2e15c3f6 was picked only to protect the local Claude config while this
branch's tests ran; #890 lands it on main on its own.

* fix(env): env exec applies no team env while the declarations fail (#879)

A legacy GITHUB_TOKEN in env.yaml plus a secrets.yaml that fails gave the
command the repo value, though the key may be a declared secret. Like
env.sh and the backup (Conflict 14), env exec now overlays nothing on a
failed declaration: the command gets the inherited environment, and
stderr names the failure.

* fix(fs): create the atomic-write temp file with the target mode (#879)

writeFileAtomic and writeJsonAtomic wrote the temp file with the umask's
default mode (0644 under umask 022) and narrowed it by chmod afterwards,
so a secret or model key was readable by other users until then. The temp
file is now opened exclusively with the target mode; the chmod stays for
the bits the umask removes.

* fix(env): env set --dry-run previews without asking for the value (#879)

`teamai --dry-run env set KEY` prompted for the value, or read stdin with
--stdin, before printing the preview, so it failed without a terminal. The
preview now comes first, and no value is read.

* fix(env): serialize env set and env unset on the values file (#879)

Two env set or env unset runs at once each read the store, changed it and
wrote it back, so the later write dropped the other's change. Both now go
through updateSecretStore, which takes <store>.lock (the acquireLock
helper), re-reads the file inside it and writes the result. A --dry-run
takes no lock.

* fix(env): keep a secret's stored value from becoming a variable override (#879)

Secrets and variable overrides share the team store. env set now records
kind: secret | variable from what the scope declares; variable resolution
uses only variable entries, secret resolution only secret ones, and an entry
without kind counts as a secret. env list flags an entry of the other kind
with the fix.

* fix(mcp): write an MCP config that holds a resolved value 0600 (#879)

writeJsonAtomic preserved an existing file's mode, so a 0644 .mcp.json or
~/.claude.json kept 0644 after teamai wrote a resolved secret into it. A
config holding a resolved ${VAR} value (a kept entry included) is now
written 0600; one without keeps its mode.

* fix(mcp): create the Codex config temp file 0600 (#879)

applyCodex wrote config.toml.<pid>.tmp with the umask's mode and chmodded it
after, so a resolved secret was briefly readable at 0644. Both Codex writes
now go through writeFileAtomic with mode 0600 (random temp name, created
with the mode, symlinked targets written through).

* docs(env): say env exec ignores a SIGINT or SIGQUIT sent to teamai alone (#879)

* fix(mcp): tighten an unchanged config that holds a resolved value to 0600 (#879)

* fix(env): mark what each env.sh exported so a value from one no scan finds is not the member's (#879)

* fix(env): pass on a SIGINT or SIGQUIT sent to env exec outside the terminal's foreground (#879)

* fix(env): keep marking what an env.sh exported before a rewrite dropped it (#879)

* docs(env): say a SIGINT sent to a foreground env exec alone is not passed on (#879)

* fix(secrets): name the team values file by the configured team repo URL, not teamai.yaml's repo: (#879)

A copied or hostile team repo could claim another team's repo: and receive
that team's stored values. The secrets file now hashes the URL from the
member's own config, normalized so ssh, https and credentialed forms match.
The models key store keeps its naming (#894).

* fix(env): remove what a teamai env.sh exported from env exec while the declarations fail (#879)

An invalid secrets.yaml left the inherited environment untouched, so a
shell that had sourced an env.sh still passed the repo's GITHUB_TOKEN to
the command. Every inherited value the member-environment rule discounts
is now removed, named by key on stderr; the member's own exports stay.

* fix(mcp): never write a declared secret into a project MCP config git tracks (#879)

.git/info/exclude (#882) stops git add, not a file git already tracks. For
such a file the server is skipped for that tool and an entry an earlier pull
wrote stays as it is; pull warns, mcp list shows it as withheld and doctor
fails the delivery check, each naming the file and git rm --cached.

* fix(env): leave no duplicate declaration of a key env add --secret or env remove edits (#879)

A key declared twice fails every read of secrets.yaml, and both commands
edited only the first declaration. env add --secret now updates the first
and removes the rest; env remove removes every one; both say how many.

* test: keep the real normalizeRepoUrlForCompare in utils/git mocks the secrets store reaches (#879)

* fix(env): keep the port in the URL that names a team's secrets file (#879)

normalizeRepoUrlForCompare drops explicit ports, so two team repos on one
host with different ports shared one values file and one team's secret
reached the other. The file is now named by the URL's scheme family,
lowercased host, non-default port and path; only credentials, the ssh user,
a trailing .git and slashes are dropped. The scp form and the ssh URL of a
repo still share a file; its ssh and https URLs no longer do.

The utils/git mocks that kept the real normalizeRepoUrlForCompare for the
store are no longer needed and are reverted.

* fix(mcp): skip the .git/info/exclude write while another command holds its lock (#882)

After the 2.5 s wait for the exclude file's lock, updateExclude wrote without
it, so two writers could drop each other's pattern and leave a plaintext MCP
config committable. It now writes nothing and reports 'locked': pull warns that
the file is not excluded yet and to run `teamai pull` again (doctor's exclude
check keeps reporting it meanwhile), and uninstall keeps the block and warns.

* fix(uninstall): keep an exclude entry unless its MCP config is proven free of teamai's servers (#882)

Uninstall judged a protected file clean from the current mcp.yaml, manifest
and resolvable values, so with the manifest lost, the server gone from
mcp.yaml and its value unset, a plaintext token looked like the member's own
server and the exclusion went. It now fails closed and works per entry: a
pattern goes only when its file is gone, holds no server, or holds none of
teamai's servers with managed-mcp.json still there to say what teamai wrote.
A kept entry is named with its file, why, and how to clean it by hand, since
a rerun of uninstall finds no config after a full uninstall.

* fix(env): keep http and https team repos in separate secrets files (#879)

The store identity mapped https and http to one `http` family and dropped
each default port, so `http://host/team.git` and `https://host/team.git`
read one file: if the two endpoints serve different repos, one team got the
other's stored secrets. The identity now keeps the scheme, still dropping
443 and 80; the ssh forms (`ssh://`, `git+ssh://`, `ssh+git://`, scp) stay
one family.

* fix(env): strip teamai env.sh exports in env exec when the project config can't be read (#879)

With an unreadable project config, env exec passed the inherited
environment unchanged, so a value another scope's env.sh exported (a legacy
token, a member override) reached the command while the warning said no team
values were applied. It now removes what the member-environment rule
discounts (env.sh file, record or marker, the env.sh beside the unreadable
config included), keeps the member's own exports, and names the removed keys,
never values, as the failed-declaration path does. With no config at all the
inherited environment is still passed as is.

* fix(mcp): exclude a project MCP config from git before writing a resolved value into it (#882)

Pull listed the file in .git/info/exclude only after writing the plaintext,
and a failed exclusion only warned, so the secret-bearing file stayed
eligible for git add -A. The exclusion now comes first; when it cannot be
established (exclude file or .git/info not writable, lock held past the
wait, file already tracked, git error) the file is left as it was and the
warning names the reason and the fix.

* fix(mcp): report a server withheld from a file git would commit in mcp list and doctor (#882)

* fix(secrets): keep the ssh user in a team's values-file identity (#879)

alice@host:team.git and bob@host:team.git can be different repos in each
user's home; they no longer share one values file. The scp and ssh:// forms
of one user, host, port and path still match; http(s) credentials are still
dropped.

* fix(env): overlay and remove env exec keys case-insensitively on Windows (#879)

Windows environment names are case-insensitive, so a declared api_url left an
inherited API_URL in place (Node keeps the first name of a case-folded pair)
and a removed secret survived in another case. On win32 a key now replaces or
removes every case variant; elsewhere nothing changes.

* fix(mcp): report a tracked file on a dry run and a withheld server already installed (#882)

Backports #880's merge 0d9f7fa7: a dry run (doctor, mcp list) names a
tracked file before any pull has listed it, mcp list reports withheld for a
server an earlier pull installed, and doctor's withheld note carries the
exclusion's own fix instead of the pull --force advice.

* fix(mcp): name a tracked MCP config before an unwritable .git/info/exclude (#882)

A tracked file needs `git rm --cached` whatever else is wrong, so
ensureExcludedFromGit checks gitTracks before the writability check, on a
pull and a dry run alike, and lists nothing for it.

* fix(mcp): take a project MCP config's exclude line back out once it holds no resolved value (#882)

A pull that lists a config in .git/info/exclude and then writes no value
into it (it does not parse, a member's server holds the team's name, the
write fails) removes the line it added. After a pull or `teamai mcp
remove`, a line whose configs are proven clean in every worktree, by the
proof uninstall uses (moved to mcp-reconcile.ts), is removed under the
lock; one not proven clean stays. A config listed before its write is
listed again after it, so a concurrent uninstall that dropped the line
between the check and the write does not leave the value unprotected.

* fix(secrets): tell an scp path in the ssh user's home from an ssh:// path from the root (#879)

git@host:acme/team is relative to the ssh user's home, ssh://git@host/acme/team
is absolute; on a plain ssh host they can be different repos, yet they shared
one values file. An scp path starting with neither / nor ~ is now keyed as
~/path, the path ssh://host/~/path names; host:/abs and ssh://host/abs still
match. The scp form without a user (host:path) is now read as ssh too.

* fix(mcp): judge a project MCP config by the manifest as it stood before the pull rewrote it (#882)

A pull whose manifest was lost before it ran recreates managed-mcp.json
while reconciling, so the clean-file proof read the new record and took
the exclude line out of a file still holding a teamai server that left
mcp.yaml with its variable unset. The proof now uses this worktree's
manifest as read before the reconcile.

* fix(mcp): log a rolled-back exclude line at debug level (#882)

A line this pull added and took back out, because it wrote no resolved
value into the file, was reported as removed although the member never
saw it added. Only removing a line an earlier run added stays at info.

* fix(mcp): keep a shared exclude line while another worktree's config holds a server (#882)

A pull or `teamai mcp remove` judged every linked worktree's MCP config
with today's definitions and values. Once a server's ${VAR} became a
literal, and the value was no longer set, a pull in worktree A took
worktree B's stale token-bearing entry for clean and removed the shared
/.mcp.json line, so `git add -A` in B staged the token.

These commands now release a line only when the current worktree's file
passes the full proof and every other worktree's file is missing or holds
no MCP server. `teamai uninstall` keeps its full proof in each worktree.

* test(mcp): build the other worktree from the real temp path so the test checks what it names (#882)

* fix(env): strip teamai env.sh exports from env exec with no config or an HTTP scope (#879)

Neither path applies team values, yet both passed on whatever a sourced
teamai env.sh exported, another team's credentials included. Remove every
inherited value that is not the member's own, as the unreadable-config
path does, and name the removed keys on stderr.

* fix(secrets): name a team's values file by the full SHA-256 digest (#879)

The file name is the boundary between teams, and 40 bits let a hostile
team search for a repo URL whose name matches another team's file and
read its values. Nothing has shipped, so no migration.

* fix(env): name the env.sh provenance marker by the full SHA-256 of its data home (#879)

* test(push): push publishes the secrets.yaml env add --secret leaves (#879)

* fix(uninstall): list no worktrees for a project root that no longer exists (#882)

buildRemovalPlan now lists every worktree to find teamai's exclude blocks,
outside the MCP cleanup's try. simple-git throws synchronously for a missing
directory, so a project uninstall whose root is gone crashed; main's #878 test
caught it after the merge.

* fix(secrets): keep the URL query and fragment in a team's values file name (#879)

* fix(mcp): treat an empty, unparsable or tool-less managed-mcp.json as no record (#882)

The clean-file proof took any managed-mcp.json on disk as teamai's record,
so an empty or truncated one let a pull, `mcp remove` or uninstall judge a
file still holding a stale secret-bearing entry clean and drop its exclude
line. A file now counts as recorded only when the manifest parses and holds
an entry for that tool's file. A project record teamai empties stays as []
so a file left with only the member's own servers can still be released.

* fix(env): compare exported, recorded and marked keys case-insensitively on Windows (#879)

* fix(uninstall): list no worktrees for a project root that no longer exists (#879)

* fix(mcp): keep the exclude line of a config this pull wrote when a later step fails (#882)

* fix(mcp): read a check-ignore error as unsafe unless ls-files proves the config untracked (#882)

* fix(mcp): report withheld only for targets delivery would write the server to (#882)

* docs(mcp): describe the .git/info/exclude block in the setup skill and the stricter git check (#882)

* fix(mcp): keep the exclude line of an entry a pull wrote with a resolved value after its definition turns literal (#882)

* fix(mcp): judge a nested repository's linked worktree config by its sibling's tool (#882)

* feat(mcp): record the project MCP configs a pull wrote a resolved value to in managed-mcp-files.json (#882)

* fix(mcp): keep protecting a config a pull wrote under a toolPaths mapping the team has since changed (#882)

* fix(mcp): keep a config's exclude line past the pull that rebuilt its lost record (#882)

* test(mcp): pin today's exclude rules for a missing, corrupt or locked managed-mcp-files.json (#882)

* docs(mcp): describe managed-mcp-files.json and the configs it keeps protected (#882)

* refactor(mcp): keep the #882 record edits off the lines #880 changes (#882)

* test(mcp): keep excluding a config whose entry a pull kept for a missing declared secret (#875, #882)

* fix(mcp): protect a config an older teamai wrote under a mapping an earlier teamai.yaml made (#882)

A teamai from before managed-mcp-files.json kept no record of the path it
wrote a resolved value to. Once the team changed that toolPaths mapping, no
pull visited the file. The first pull on this version now reads every
mcpProject path the team repo's history of teamai.yaml mapped, once per
worktree: a file under the project root that no current mapping or record
reaches, and that holds a resolved value, is listed in .git/info/exclude and
recorded. A git error leaves the read for the next pull; a shallow clone
reads the history it has.

* fix(mcp): keep a rebuilt record from persisting without its note of the file's other servers (#882)

When a pull rebuilt a lost managed-mcp.json and could not note the other
servers in the file (managed-mcp-files.json locked, an I/O error), it still
wrote the rebuilt record, so no later pull knew the record was rebuilt and a
stale server's line could go. The same manifest write now marks those records
unnoted: the file counts as having no record, so it keeps its line while it
holds a server, and the next pull notes them and clears the mark.

* fix(mcp): take back a managed-mcp-files.json record for a config the pull then did not write (#882)

A pull records a config before writing a resolved value to it. When the
write failed or did not happen (the file does not parse), the record stayed,
and once the mapping changed a config of the member's own at that path was
kept excluded while it held any server. The pull now takes back a record it
added for a file it did not write, as it does the file's exclude line; the
settle after records it again if the file holds a resolved value anyway.

* docs(mcp): describe the teamai.yaml history read, the unnoted rebuilt record and the record a failed write takes back (#882)

* refactor(mcp): keep the r3 edits off the lines #880 changes (#882)

* fix(mcp): also protect a config an older teamai wrote under a built-in default it has since changed (#882)

* fix(mcp): replace a symlinked Codex config instead of writing into the file it links to (#875, #882)

* fix(env): drop what a teamai env.sh exported from env exec when env.yaml or the values file fails (#875)

* fix(env): on Windows, match a declared secret to an env.yaml variable in any case (#875)

* fix(mcp): judge a project MCP config under a symlinked directory where the write lands (#882)

The appliers replace the file itself (tmp + rename) but follow its
directories. Every git check now judges realFilePath(file), the one
resolver the release keying already used: a directory linked out of any
repository no longer withholds the servers on git's "not a git
repository", and a tracked file there is named with both paths, with a
git rm --cached that works (git refuses the path through the link).

* docs(mcp): describe how a config under a symlinked directory is kept out of git (#882)

* refactor(mcp): keep realFilePath next to existingAncestor, without an import cycle (#882)

* refactor(mcp): key each worktree's targets with realFilePath, the one rule for where a write lands (#882)

* fix(mcp): judge a config under an earlier teamai.yaml mapping as a recorded file, not by today's records (#882)

* fix(mcp): have doctor check the configs earlier teamai.yaml mappings reach until a pull reads them (#882)

* docs(mcp): describe how a config under an earlier teamai.yaml mapping is judged, and doctor's check of it (#882)

* fix(mcp): record a config under an earlier teamai.yaml mapping that git tracks, and judge it once git no longer does (#882)

* fix(mcp): keep judging a recorded config for a tool the team moved while another tool still maps it (#882)

* docs(mcp): describe the tracked config an earlier mapping reached, and a moved tool's config another tool still maps (#882)

* fix(mcp): find a config an older teamai wrote under an earlier mapping another tool maps today, and judge it by that tool's records (#882)

* docs(mcp): describe the history read's configs another tool maps today (#882)

* fix(mcp): prove a shared config clean only while every tool that wrote a resolved value there has its record (#882)

* fix(env): on Windows, set and unset a key under the name the scope declares, in any case typed (#875)

* fix(mcp): judge a built-in location no mapping reaches today as an earlier-mapped file, and a shared config no pull recorded by every tool mapping it (#882)

* docs(mcp): describe the built-in location of a moved or dropped tool, and a shared config no pull recorded (#882)

* fix(env): on Windows, match a secret's declaration and stored value in any case (#875)

* fix(mcp): name the ignore rule that re-includes a config teamai just listed, instead of saying git tracks it (#882)

* fix(mcp): judge a moved tool's built-in location another tool maps for that tool too, and hold a config's line while no managed-mcp.json claims its servers (#882)

* docs(mcp): describe the no-manifest rule, a moved tool's built-in location another tool maps, and a re-including ignore rule (#882)

* fix(env): key a team's values by its repo URL when the configured remote is only an alias (#875)

* fix(env): on Windows, recognise a secret's env.yaml value under another case of its name in the environment (#875)

* fix(mcp): on Windows, keep an inherited value under another case of a team variable's name out of MCP servers (#875)

* fix(env): on Windows, replace and remove every stored entry under another case of the key (#875)

* fix(mcp): keep a tool's record as it was when its config does not parse, and take back each tool a write that did not happen recorded (#882)

* fix(mcp): mark the records a pull with no managed-mcp.json writes as unnoted until the servers no record claims are noted (#882)

* fix(mcp): have doctor judge a record marked unnoted like a missing managed-mcp.json (#882)

* fix(mcp): judge a config tools of different formats share in each of their formats before releasing its line (#882)

* fix(mcp): note the servers no record claims in the file of a tool whose record a pull writes first, as with no managed-mcp.json at all (#882)

* fix(mcp): take back a tool managed-mcp-files.json recorded before a write unless its own records hold a resolved value there (#882)

* fix(mcp): treat an installed tool's missing record as lost at every pull and in doctor, not only an empty managed-mcp.json (#882)

* fix(mcp): tighten to 0600 every project config the protection pass keeps out of git, not only the ones a pull writes (#879)

* fix(mcp): on Windows, fill a placeholder from the variable of the same name in another case (#875)

* fix(mcp): note the unclaimed servers under every format of a shared config, and pin an uninstalled tool's leftover config (#882)

* fix(mcp): count an uninstalled tool's missing record when no installed tool maps its file, and name a re-including .gitignore rule on a dry run (#882)

* fix(mcp): on Windows, match a placeholder to its secret in any case in mcp list and the missing-secret notice (#875)

* fix(mcp): scope a shared config's claims to the tools reading the same key, and settle its notes on every format's view (#882)

* fix(env): keep a file:// repo's .git suffix in its values file identity: team and team.git are two directories (#875)

* fix(env): on Windows, update and remove an env.yaml variable typed in another case (#875)

* fix(mcp): keep suspect an uninstalled tool managed-mcp-files.json lists as a writer, though an installed tool maps the file (#882)
2026-09-30 15:02:30 +08:00
Jay0130-aandJay0130-a 4a87fe5d26 test: resolve temp roots to the long path on native Windows (#870)
* test: resolve temp roots to the long path on native Windows

* test: canonicalize the local-agent temp root as well

---------

Co-authored-by: Jay0130-a <309071492+Jay0130-a@users.noreply.github.com>
2026-09-30 15:00:44 +08:00
Smilewithoutfalling 2aaf3db543 fix(code-knowledge): resolve Swift symbols across files in the same module (#847)
* fix(code-knowledge): resolve Swift symbols across files in the same module

Swift declarations are module-scoped, but the AST track resolved calls and
conformances with a file-scoped model: declared in this file, or reachable
through a resolved import. That model is correct for TS/Go/Python, where a
cross-file symbol has to be imported. Swift needs no import inside a module,
so a conformance or a call to a symbol in a sibling file emitted nothing.

Add a third, Swift-only level of lookup after the existing two: a declaration
elsewhere in the same module, with the module boundary read from the layout
SwiftPM mandates (Sources/<Target>/, Tests/<Target>/). Outside that layout no
scope is claimed, and a name declared more than once in the module emits
nothing rather than picking one arbitrarily.

Fixes #843

* fix(code-knowledge): index only module-visible Swift declarations

Addresses the three P1 findings the automated review raised on the first
revision of #847. All three were real.

The fallback widened the candidate set without narrowing eligibility. The
candidate set is "every declaration in the module"; what a bare name can
actually reach is strictly smaller, and that has to be decided where the
declaration and its modifiers are still in hand.

- module-scope.ts: the module key now keeps the package root, so
  Packages/A/Sources/App and Packages/B/Sources/App stay two modules
  instead of merging into Sources/App.
- walk.ts: a declaration enters the module index only when it is top-level
  and not private/fileprivate. Neither fact survives into AstSymbol, so it
  is computed in the walker and surfaced as FileWalkResult.swiftModuleSymbols.
- index.ts: feeds that subset to the module index.

queries.ts and the shared AstSymbol type are untouched, and the tie rule
agreed in #843 is unchanged: only module-visible declarations can tie.

Verification on the merge result (main a8ab8e00 + this change):
- real-CLI e2e, 15/15 assertions, three new negative probes each in a file
  that declares nothing else; both cross-package directions stay clean while
  each package resolves to its own Proto.swift
- four mutations, each removing one guard, each caught by exactly the
  matching test and nothing else
- ast-swift-module-scope.test.ts 15 cases; related suite 68 -> 83 passed
- oxlint --deny-warnings and tsc --noEmit: rc=0 on this change and rc=0 on
  unmodified main, same binary and flags, so the comparison is real

* fix(code-knowledge): take the innermost SwiftPM marker as the module boundary

Addresses the P1 the automated review raised on the previous head.

Keeping the package root only fixes packages that sit side by side. A package
vendored under the outer package's own Tests/ has two markers on its path, and
the scan took the first: Tests/Fixtures/A/Sources/App and
Tests/Fixtures/B/Sources/App were both scoped to Tests/Fixtures, which is the
same merge as the earlier package-root finding, reached one level out.

The scan now runs from the end, so the innermost marker wins. The direction
also decides how a layout this function misreads can fail: an inner marker can
only yield a scope nested inside the true module, which loses a resolution,
while an outer one can span two real modules and fabricate an edge. The layer
already prefers a missing edge to a wrong one, so the bias is deliberate and
is stated in the docstring and in the tests.

Verification on the merge result (main a8ab8e00 + this change):
- real-CLI e2e, 20/20 assertions; both vendored fixture packages now resolve to
  their own Proto.swift while neither cross-package direction produces an edge
- five mutations, each removing one guard, each caught by exactly the matching
  tests and nothing else
- ast-swift-module-scope.test.ts 18 cases; related suite 68 -> 86 passed
- oxlint --deny-warnings and tsc --noEmit: rc=0 here and rc=0 on unmodified
  main, same binary and flags

* fix(code-knowledge): a name an enclosing scope binds is not a module reference

Addresses the P1 the automated review raised on the previous head.

The fallback answered "which module does this bare call belong to" without
first asking whether the call is a module-level reference at all. With
A.swift declaring func work() and B.swift declaring
func run(work: () -> Void) { work() }, Swift calls the parameter, and the
lookup resolved it to A.swift and emitted a fabricated cross-file REFERENCES
edge. The receiver fallback had the same hole for a local value shadowing a
type name.

The walker now records, per call site, the names an enclosing scope binds:
function and closure parameters, local let/var, and what a
for / if let / guard let / catch let / case let introduces. Both module-wide
lookups decline when the callee or receiver is one of those names. The walk
stops at the call own ancestors, so a binding in an unrelated function of the
same file shadows nothing; it over-collects only within the scopes it does
visit, which costs a resolution rather than inventing an edge.

This is the same mistake as the earlier four findings on a different axis:
eligibility was narrowed at one end of the edge (can a sibling reach this
declaration) and not at the other (can this call reach anything module-level).

Verification on the merge result (main a8ab8e00 + this change):
- real-CLI e2e, 24/24 assertions; ShadowParam.swift (its only call bound by
  its own parameter) ends with 0 outgoing edges, ShadowMixed.swift (one bound
  call, one unbound) with exactly 1, and the heuristic track is byte-identical
- seven mutations, each removing one guard, each caught by exactly the matching
  tests and nothing else; originals restored byte for byte
- ast-swift-module-scope.test.ts 24 cases; affected set derived from the
  changed modules: 93/106 passed on unmodified main and 93/106 here
- Node 24.14.0, the runtime the matrix gained while this sat in review, on the
  merge result (main 5fb316c7, which carries #861): the two Swift AST test
  files pass, 2/2. On this branch tree alone Node 24 aborts during the first
  Swift parse (Fatal process out of memory: Zone) and a command-line
  --wasm-tier-up-filter does not help, which is why #861 has to set the flag
  inside the worker.
- oxlint --deny-warnings and tsc --noEmit: rc=0 here and rc=0 on unmodified
  main, same binary and flags

* fix(code-knowledge): keep a call's own name from counting as a shadow

Two follow-ups from review.

The closed rule collected every identifier in the enclosing scopes, and that
included the callee of a sibling call: in `func run() { work(); work() }` each
call read the other's `work` as evidence, so both cross-file resolutions were
suppressed. The name a call goes through is a use, not a binding. Arguments are
still walked, so a closure that does bind a name keeps counting.

`SWIFT_IDENTIFIER` also admitted ASCII only, so a parameter named `π` was
dropped from the set and a sibling `func π()` won the fallback. Swift
identifiers are not ASCII.

Both shapes now have cases; all four mutations the rule has to survive are
killed by the suite.

* fix(code-knowledge): collect Swift shadowing once per declaration, for any identifier

Two follow-ups from review, both in the same set of names.

The character class guarding `addSwiftName` admitted Unicode letters after the
previous pass but still rejected escaped identifiers such as `repeat` -- which
Swift requires when a name collides with a keyword -- and symbol or emoji
names. A parameter with such a name never reached `localBindings`, so a sibling
`func` of the same name won the fallback and a fabricated cross-file edge was
emitted. Every caller passes the text of a `simple_identifier` or a
`type_identifier`, which is the grammar's own verdict that the token is a name,
so the class was the whole defect and nothing else is tested there now.

The set was also rebuilt for every call by re-walking that call's ancestors.
The union over those ancestors is exactly the top-level declaration that holds
the call -- every ancestor sits inside it, and the declaration is one of them --
so the declaration is now read once per file and each call site looks its answer
up. Same names, minus the quadratic term: a function holding N calls went from
roughly N^2 AST visits to a single traversal per declaration.

Reading the declaration whole is a shade coarser than the per-call walk: a name
occurring only inside the call now counts as well. That costs nothing where the
field is consumed, because the resolver asks about the callee and the receiver,
and both are callee positions, skipped below as uses. A name can only enter the
set through a position that is not the very call it would resolve.

Verification against the pushed head 891da0826b, same fixtures, same runtime:

- 16-shape before/after matrix (5 shadowed shapes, 2 of the dropped identifiers,
  2 scope cases, 7 controls). The only rows that move are the two identifier
  shapes, each 1 fabricated edge -> 0. Every other row is identical, including
  the controls for those two shapes, which still resolve.
- The two new identifier cases fail on the unmodified head (2 failed | 31 passed
  of 33) and pass here (33/33), so they pin the defect rather than the change.
- Five mutations, each removing one guard: reinstating the ASCII shape test,
  keeping the callee as evidence, scoping to the nearest function instead of the
  declaration, looking the declaration up by endIndex, collecting type
  positions. All five are killed; walk.ts restored byte for byte afterwards.
- ast-swift-module-scope.test.ts 29 -> 33 cases. The four AST suites: 65 passed.
- The quadratic term, measured on one function holding the same call N times:
  200 -> 800 calls is 477 ms -> 8428 ms on the head (x17.7, and it overruns the
  15 s test timeout) against 12.6 ms -> 29.5 ms here.
- oxlint --deny-warnings --report-unused-disable-directives and tsc --noEmit:
  rc=0. No reference to the two helpers removed here is left in src/.

* fix(code-knowledge): treat an argument as a use, not a binding, in Swift shadowing

Two follow-ups from review, both about positions that mention a name without
binding it.

The review bot reported that `consume(work)` before a bare `work()` suppresses
the call: the argument's mention of `work` entered the shadowing set, the
resolver saw its callee there and dropped the edge. The bot labels it P2; it is
also older than this branch. The per-call walk it replaced had the same hole --
it excluded the call's own subtree, but a mention in a *sibling* call was still
counted, and `consume(work)` is a sibling of `work()`.

Rebuilding the set from the whole declaration lost that exclusion entirely, and
that loss was a real regression the bot did not report: `work(work)` counts its
own argument as evidence and stops resolving through its callee. The previous
commit message claimed reading the declaration whole "costs nothing where the
field is consumed, because ... callee positions are skipped". That is wrong.
Skipping the callee position says nothing about a mention of the same name
reaching the set from anywhere else in the declaration -- its own argument list
included.

Both are the same defect in one direction: an argument is an expression
position, so it can mention a name but never bind one. A marker now follows the
walk into `value_arguments` and is cleared on the way into a `lambda_literal`,
since a closure handed over as an argument still opens its own scope and its
parameters and captures do bind. Nothing enumerates binding constructs, so the
rule stays closed.

Verification spans three states of walk.ts, same fixtures, same runtime:
891da0826b (the branch's starting point, before the previous pass), da6f894e
(the pushed head, after it) and this commit.

- 18-shape matrix, covering every shape the review rounds produced: four
  shadowed shapes (parameter, stored property, generic parameter, closure
  capture list), three identifier shapes (Unicode, escaped, symbol), two
  repeated-call shapes (two calls, twelve calls), the inherited-member shape,
  the bot's two argument shapes, `work(work)`, a closure passed to itself, a
  bare mention beside a call, and three controls. Against the pushed head no row
  is worse and three rows move up, all of them positions this commit is about:

    bot shape, argument mention after the call   no edge -> 1 edge
    bot shape, argument mention before the call  no edge -> 1 edge
    work(work)                                   no edge -> 1 edge

  The third is the regression above: it resolves on 891da0826b, does not resolve
  on the pushed head, and resolves again here. Against 891da0826b no row is
  worse either; the escaped and symbol shapes go from 2 edges to 1, because
  before the previous pass a name written with backticks or as a symbol was
  dropped from the set, so the call inside the shadowing declaration resolved
  along with the one that should.
- Every shadowed shape -- parameter, stored property, generic parameter,
  closure capture list, Unicode parameter, inherited member -- still resolves to
  no edge, and the three controls still resolve.
- ast-swift-module-scope.test.ts 29 -> 33 -> 36 cases. The four AST suites: 68
  passed. With the test file at 36 cases and walk.ts at 891da0826b the suite is
  3 failed | 33 passed -- escaped, symbol and the argument mention, which is
  what pinning those defects looks like. With walk.ts at the pushed head it is
  2 failed | 34 passed, the argument mention and `work(work)`: the two shapes
  this commit repairs. The closure-parameter case passes against both, so it
  guards a rule rather than recording new behaviour.
- Four mutations, each removing one guard: ignoring the argument marker, not
  clearing it inside a closure, never marking arguments, and no longer skipping
  callee identifiers. All four are killed; walk.ts restored byte for byte
  afterwards (sha256 62332202bcc5c72a53417bd0fc9be8d0b88f674237a606104d20a5f7816c2673).
- oxlint --deny-warnings --report-unused-disable-directives and tsc --noEmit:
  rc=0. The repo's lint script also adds --type-aware; that half is exercised by
  CI.

Still open, unchanged and out of scope here: inherited and cross-file extension
members, tracked in #909. It needs a member table the AST index does not build
today, so no amount of argument-position handling reaches it.
2026-09-30 14:01:02 +08:00
daoiqi a8ab138b0a feat(models): support Pi Coding Agent (#918)
Pi becomes the sixth agent `teamai models switch` can point at a team
gateway. TeamAI writes one provider into `~/.pi/agent/models.json`, holding
every catalog model; a member's other providers in that file are never
touched.

- **Key is the profile ref** (`team:<id>` / `local:<id>`), not the bare `id`
  — an `id` is unique only within one catalog file, so a root and a namespace
  profile may both be `tokenhub`, and a bare id would make the second switch
  silently overwrite the first gateway. Pi shows the profile's `name`.
- **One provider covers all three protocols.** Pi resolves the api and URL
  per model: the first in Chat Completions, Responses, Anthropic order
  becomes the provider's own, and a model reached through another carries
  `api` and `baseUrl` itself. A model served both ways is registered once,
  preferring an OpenAI one; an `anthropic`-only group pins a Claude model to
  Anthropic Messages.
- **An environment key is written as `$VAR`**, and `settings.json` is left
  alone, so the default model stays the member's choice.
  `PI_CODING_AGENT_DIR` is honored the way `CODEX_HOME` is.
- **Restore** removes the key the last switch created, even after the catalog
  re-points the profile at a different ref. Restoring without `--agent` now
  covers Pi too.

Rebased onto current `origin/main` (3a9a24a). The only conflict was the
`./profile.js` import in `src/models/switch.ts`, where this change adds
`ModelProtocol` and #894 adds `LocalConfig` / `sameTeamIdentity`; resolved as
the union of both, and `git range-diff` confirms that line is the sole
difference from the original commit.

Verification on the rebased tree: `tsc --noEmit` and `lint` clean. Unit
suite 5501 passed (the same three `push-env` cases fail on clean
`origin/main`, verified in a separate worktree). Pi e2e suite 4 passed. Real
built CLI against a scratch `HOME`: `models add` -> `models switch --agent pi`
writes one provider keyed `local:tokenhub` with `settings.json` untouched;
`models list` reports Pi active; `models restore --agent pi` returns the file
to `{}`; a pre-existing user provider under the same ref is refused with
`pi already has a user-owned provider named local:tokenhub` and left byte
for byte intact; `PI_CODING_AGENT_DIR` redirects the write to a custom
directory.
2026-09-30 13:59:19 +08:00
3a9a24a6f4 fix(env,hooks,mcp,status): name the entries that are not delivered (#822) (#851)
* fix(env,hooks,mcp,status): name the entries that are not delivered (#822)

env list, mcp list, hooks list, status and list <env|hooks|mcp> resolve the
entry types to show what reaches this directory, but dropped the resolution
notices: an entry an unknown key (a mistyped role:) or a removed key (roles:
on env, projects:) takes out of the delivered set was silently missing from
the list, and status counted around it. pull and doctor report these; now the
list commands do too, via reportEntryResolution.

env add on a variable carrying a removed per-entry key kept reporting plain
'Updated' — #833 taught it to warn for keys the schema does not know, but a
removed key is in the shape on purpose (so it can be detected), so it stayed
silent. Warn the same way for those.

* test(e2e): match the delivered DEVOPS_ONLY form, not its name in the notice

env list now reports the withheld per-entry `roles:` variable by name
(#822), so the whole-output not.toContain('DEVOPS_ONLY') assertion tripped
on the delivery notice itself. The variable stays out of the delivered
list; match the listed form `DEVOPS_ONLY=` instead.

* fix(env): point env add at the namespace file, not a key drop

The review of #851 found the update-path warning told users to remove a
per-entry `roles:`/`projects:` key in place, which delivers a root-scoped
secret to the whole team. The remediation now reuses `moveTo`, the same
remedy pull's notice names, so it points at the namespace file to move the
entry into (with the manifest declaration to add when nothing declares it).

`moveTo` and `TargetFiles` move from module-private to exported for this.

The review also found skill-data/setup/references/manage-admin.md still
said only pull and doctor report undelivered entries, while this branch
made the list commands and status report them too. It now names them, as
docs/usage-guide.md does.

* fix(entries): report notices alongside resolution failures

* fix(entries): scope list warnings and document env updates

* docs(entries): align changelog with list and env warnings

---------

Co-authored-by: ydflow <ydflow@users.noreply.github.com>
Co-authored-by: ydflow <314143294+ydflow@users.noreply.github.com>
2026-09-29 23:15:08 +08:00
Saul Moro ed95357fe8 fix(dry-run): let --dry-run reach recall maintenance and recall promote (#900) (#903)
* fix(dry-run): let --dry-run reach recall maintenance and recall promote (#900)

The root program declares --dry-run, and Commander gives it to the root
wherever it is written, so these two actions, which read only their own
options, never saw it: --prune --dry-run pruned and published,
--update-quality --dry-run wrote AI drafts, and promote --dry-run promoted and
published. Both actions now merge program.opts() as the other actions do, and
load with { dryRun }. --confidence-writeback, which ignored the flag, now
reports what it would write and publishes nothing.

* fix(dry-run): create the promotion target directory only when promoting (#900)

executePromotion created <repo>/<category>/ before its dry-run return, so
recall promote --dry-run left an empty directory in the team repo.

* refactor(promote): drop the ensureDir that writeFile already does (#900)
2026-09-29 21:17:22 +08:00
Changsu SeongandCodebuff 92e6e2f060 fix(models): key team values by repo identity, migrating legacy slug names (#894) (#895)
* fix(models): key team values by repo identity, migrating legacy slug names (#894)

* fix(models): address review on the repo-identity values scheme (#894)

- never migrate the legacy values file in a dry run: `pull --dry-run` and
  `models switch --dry-run` thread { dryRun } into migrateTeamValuesPath
- match the legacy digest the old implementation actually keyed on — the
  repo: claim in teamai.yaml overrode the identity there, so an SSH remote
  beside an HTTPS repo: no longer orphans keys and switches
- when several legacy files share a digest, migrate the newest one, so
  keys re-entered after a team rename win over stale copies
- sync docs/designs/model-profiles.zh-CN.md with the hash-only file name

🤖 Generated with Codebuff
Co-Authored-By: Codebuff <noreply@codebuff.com>

* fix(models): preview migration in dry runs, stop alias collisions, race-safe link

- dry run returns the newest legacy file's path so it reads exactly the
  keys the real run would migrate and read, instead of reporting a key
  missing from a not-yet-existing hash-only file
- a bare remote alias (`fork`) no longer names the values file: two
  checkouts sharing the alias hashed to one file and could read each
  other's keys, so hashing falls back to the URL, then the local path
- migration links the legacy file to the target instead of renaming:
  a concurrent migration that links first makes ours fail with EEXIST,
  so stale keys can never overwrite fresh ones on POSIX

🤖 Generated with Codebuff
Co-Authored-By: Codebuff <noreply@codebuff.com>

* fix(models): narrow the path digest to path-only configs, copy when links fail

- the legacy local-path digest is a candidate only when the old
  implementation would have keyed on the path itself (no repo: claim,
  no configured remote, no URL): a default-path checkout re-initialized
  for another team with a URL no longer adopts the previous team keys
- when a filesystem rejects hard links (EPERM, ENOTSUP, ...), migration
  falls back to an exclusive, never-overwriting copy (open wx mode,
  partial writes removed) instead of skipping every candidate and
  returning a path with no file behind it

🤖 Generated with Codebuff
Co-Authored-By: Codebuff <noreply@codebuff.com>

* fix(models): publish the copy fallback atomically via a temp file

The copy fallback wrote the target in place after an exclusive open, so
a concurrent reader could observe the empty or partially written values
file and fail to parse it. The content is now written to a unique temp
file and linked into place: until the publish link succeeds the target
does not exist, so a concurrent reader sees either the complete previous
file or none, and two migrations still cannot overwrite each other (the
loser publish link fails and its temp file is removed).

🤖 Generated with Codebuff
Co-Authored-By: Codebuff <noreply@codebuff.com>

* refactor(models): read legacy values in place instead of migrating files

Rename-and-migrate had to be atomic, no-clobber, link-optional, and
partial-write-free all at once, and each fix round surfaced another way
the migration could strand or clobber a secrets file. Drop the machine:
while the hash-only file does not exist, findTeamValuesPath returns the
newest legacy file and it is read where it lies; the next save writes
the hash-only name, which then shadows the legacy file. Reads no longer
mutate anything — a dry run needs no special casing — and no link,
rename, or copy stands between the keys and their reader.

🤖 Generated with Codebuff
Co-Authored-By: Codebuff <noreply@codebuff.com>

* fix(models): identify alias-digest legacy files by slug as well

Two checkouts that shared a bare non-origin alias (`fork`) hashed the
same legacy digest, so findTeamValuesPath could read one team's values
for the other. A repository-bound digest (URL, repo: claim, local path)
still matches by digest alone, since the slug drifts on team renames;
a file keyed by an alias digest is adopted only when its slug is this
checkout's too, the discriminator the old scheme kept those files
apart by.

🤖 Generated with Codebuff
Co-Authored-By: Codebuff <noreply@codebuff.com>

* fix(models): align switch-record matching with file matching, mirror old precedence

- sameTeamIdentity applied the alias slug check only when reading a
  values file, not when matching a switch record: two teams sharing
  remote fork could treat each other managed switches as their own.
  The slug requirement now covers every non-repository-bound digest.
- legacyTeamValueHashes mirrored the old precedence too loosely: with a
  repo claim present, the old implementation hashed only the claim,
  so the remote, URL, and path candidates never applied. A replaced
  checkout at the same path can no longer adopt a path-digest record
  the old code would never have produced for it.

🤖 Generated with Codebuff
Co-Authored-By: Codebuff <noreply@codebuff.com>

* fix(models): prefer the repo: claim over the local path for the values file name

A config with remote origin and no repo.url hashed the checkout path,
so reusing that path for another team shared the first team's hash-only
values file and its identity: team B read team A's API keys and claimed
its managed switches. The claim is the remaining repository identity
before the path, so it now wins whenever the remote and the URL name
no repository.

🤖 Generated with Codebuff
Co-Authored-By: Codebuff <noreply@codebuff.com>

* fix(models): qualify provider-relative identities with the provider

A path-shaped identity like owner/repo names a different repository on
each provider, so two teams with the same repo value on GitHub and
GitCode hashed to one values file and one identity: opening team B read
team A keys and accepted its managed-switch identity. The provider now
qualifies path-shaped identities; host-bearing ones (URLs) already
carry the host and stay as they are.

🤖 Generated with Codebuff
Co-Authored-By: Codebuff <noreply@codebuff.com>

* fix(models): use the effective provider and slug-check legacy claim digests

- the values file identity qualified a path-shaped source with the local
  provider override, normally absent, so two teams declaring the same
  repo: value on GitHub and GitCode still hashed to one file; the
  teamai.yaml provider now wins for its own claim, and the local
  override applies only when the claim carries none
- legacy candidates marked a path-shaped claim digest repository-bound,
  so sameTeamIdentity adopted another team's switches and
  findTeamValuesPath read its file under the matching digest; a
  URL-shaped claim stays repository-bound while a path-shaped one is
  matched under this team's slug, like an alias
- also fixes the candidate guard inverted while landing the above:
  with a claim present the remote, URL, and path candidates were
  dropped, and without one they were duplicated

🤖 Generated with Codebuff
Co-Authored-By: Codebuff <noreply@codebuff.com>

* fix(models): read legacy claim files by digest and slug-guard path-only records

Path-shaped repo claims now adopt their legacy values files by digest
alone, so a team rename no longer orphans its keys; the slug check
moves to switch-record matching, where a claim digest names no single
repository. Path-only configs mix the team slug into the hash-only
identity, so a checkout path reused by a differently named team can no
longer read the previous teams keys.

🤖 Generated with Codebuff
Co-Authored-By: Codebuff <noreply@codebuff.com>

* docs(models): describe the path-only slug-fallback hash in both languages

🤖 Generated with Codebuff
Co-Authored-By: Codebuff <noreply@codebuff.com>

* fix(models): slug-guard provider-ambiguous legacy files, repair renames by gateway origin

A legacy file's digest hashes the bare path-shaped claim name with no
provider, so GitHub team Alpha and GitCode team Beta can produce the same
old digest. The previous fix read such files by digest across renames,
letting the second provider adopt the first's file and API key; slug-guarding
records instead orphaned a renamed team's own switches. Gate both on the
one evidence that survives both a rename and a provider change: the
gateway origin each stored key is bound to under `<team:<id>@<origin>`.

Path-shaped claim digests now require this team's slug for files and
switch records, and are re-admitted under a different slug only when the
legacy file provably stores keys bound to this team's gateways — which a
rename keeps and another provider cannot claim. Keys still in a
beta-era `team:<id>` form (no origin) are not evidence, so their files
are left unread rather than guessed at. Callers thread the resolved team
profiles through `findTeamValuesPath`, `sameTeamIdentity`,
`switchedGatewayOrigins`, `activeAgentsFor`, and the pull re-apply.

* fix(models): prove rename-repaired legacy files from this machine's own switch history

Gateway-origin key binding is not a team identity: two teams sharing a
profile id and gateway URL behind the same path-shaped claim would pass
the provenance check, letting the second read the first's file and claim
its switches. But refusing differ-slug files entirely orphans a renamed
team, whose legacy `<slug>-<hash>.json` carries its own old slug.

The trustworthy, machine-local evidence is managed.json: this checkout
records the exact identity each past `team:` switch used. A differ-slug
provider-ambiguous file or switch record is re-admitted only when that
identity appears in this checkout's own switch history — a renamed team's
file, recognized without trusting a gateway a foreign team could share, a
key binding another team's file may also carry, or a slug a rename just
moved. Foreign teams hold only their own slug in managed.json and are
refused; beta-era unbound keys need no binding to be recognized this way.

Provider-relative remotes (`owner/repo`) also get the provider
qualification already given to path-shaped claims, so two providers using
the same checkout path and slug no longer share one values file.
Design docs synced in both languages.

* fix(models): never auto-adopt provider-ambiguous legacy values — adopt explicitly

Two review rounds proved no silent rule can attribute a legacy file or
switch record whose digest never encoded the provider:

- a same-slug provider-ambiguous file is read across providers (a GitHub and
  a GitCode 'Alpha' on the claim 'acme/widgets' hash the same file; the slug
  is not provenance), and
- machine switch history (managed.json) is global and self-validating: one
  file is shared by every checkout on the machine, so the record being
  proved sits in the same store a foreign team's checkout reads, letting a
  differently named team claim a same-digest record or file.

Provider-ambiguous legacy values files (path-shaped claim, provider-relative
remote, bare alias, path-only) are now never read by a silent rule, same slug
or not. The CLI lists them once (unadoptedLegacyFiles) and the user
explicitly adopts the exact `<slug>-<digest>` identity for this checkout;
the read then happens where the file lies and the next save migrates the
keys to the provider-qualified hash-only name, which shadows the legacy file
forever (no re-ask, no ambiguity left). Non-interactive and --dry-run runs
never adopt: they report the file and leave it unread. Differ-slug
provider-ambiguous switch records are refused — no machine-global proof is
ever consulted again. URL-bound legacy identities still match by digest
alone: the host is in the identity.

Design docs synced in both languages. Tests: same-slug cross-provider never
auto-reads, foreign file/record refusal, explicit-adoption read + unbound-key
migration, alias digest under this team's adoption.

* fix(models): do not offer a legacy file for adoption once its target exists

A past adoption and migration is the permanent record: the
provider-qualified hash-only target shadows every legacy name forever, so
unadoptedLegacyFiles returns no candidates when getTeamValuesPath exists.
Without this, the in-memory adoption set (which resets per invocation)
combined with the intentionally-kept legacy file re-prompted on every later
interactive command and misled non-interactive/--dry-run runs with a stale
warning. Test: after migration the file is shadowed and never offered again.

* fix(models): scope legacy adoption to the provider-qualified team and config

Two review findings on the adoption layer:

1. adoptedLegacyValues was global, keyed only by the legacy basename. In a
   pull with inheritUserScope, user scope runs first; a same-slug, same-claim
   team on a DIFFERENT provider resolves to the same adoption key, so
   confirming the user-scope file also silently authorized the project scope
   to read and migrate that key in its own provider space. Adoption is now
   keyed `${target}::<slug>-<digest>` — the adopting scope's own
   provider-qualified file path — so a confirmation scopes to exactly that
   team identity, and any other scope/provider must opt in separately.

2. adoptMigrated was a process-global boolean ("any adoption happened"). After
   adopting a user-scope file, declining a project-scope candidate still set
   it true, so the project loaded {} from its nonexistent target and wrote an
   empty hash-only file, permanently shadowing its legacy keys and killing
   future prompts. The write is now decided per config: only when readFrom
   differs from the target (a real legacy read) is the migration saved; a
   declined candidate is read nothing, written nothing, and re-offered next
   run.

Tests: cross-provider scope regression (GitHub adoption under its target never
admits the file for the GitCode scope; same-scope adoption of the same
basename does), namespaced adoption keys everywhere, migration-shadow check.

* fix(models): refuse shadowing writes while adoption pending; never claim ambiguous records by slug

Two review findings:

1. A non-interactive `models configure team:<id> --from-env X` reads {} past an
   unadopted legacy file, then writes ONLY the configured key to the
   hash-only target — permanently shadowing the legacy file (the target's
   existence silences every future adoption prompt) and orphaning its other
   API keys without a warning anyone can act on. configure and switch now
   refuse the write while unadopted legacy files remain, naming the
   interactive adoption path. A pull is unaffected: its read-time save is the
   migration itself and already requires a real legacy read.

2. A same-slug provider-ambiguous switch record matched Team B's checkout. The
   old name never encoded the provider, so a GitHub and a GitCode team both
   named `Alpha` on the bare claim `acme/widgets` share the identical
   `alpha-<digest>` form: with Team B holding its own hash-only key, pulling
   B claimed an agent switched to Team A and overwrote it. A legacy record
   under a provider-ambiguous digest never matches now — slug equality is not
   ownership across providers, consistent with the differ-slug refusal.
   Repository-bound records still match by digest (the host is in the
   identity). Adoption of the values file re-establishes the team; its
   switched agents are re-recorded by the next `models switch`.

Docs synced (en + zh-CN). Tests: path-only/alias/claim records all refused
even under the exact slug, cross-provider same-slug record regression, and
the 4 prior adoption-scoping expectations.

* fix(models): a provider-relative remote is the top identity; never a Windows drive as a URL

Two review findings on identity derivation:

1. A provider-relative remote never got its documented precedence.
   isRepoReference('owner/repo') is false, so the selector picked the URL or
   teamai.yaml claim instead; two checkouts with DIFFERENT remotes
   (acme/team-a vs acme/team-b) but the same claim/provider therefore shared
   one hash-only secrets file and could consume each other's keys. A
   configured non-origin remote now names the repository with the highest
   precedence whether URL-shaped or provider-relative (which names one repo
   together with its provider); only a bare alias (no slash, no scheme) falls
   through. Different remotes -> different files, always.

2. new URL('C:\\teams\\repo') accepts c: as a scheme, so Windows drive paths
   were hashed as phantom URLs and a path-only config skipped the
   provider-and-team-slug fallback; replacing that checkout with a
   differently named team reused the file. URL parsing now rejects
   single-letter schemes, keeping drive paths in the path form whose slug
   separates teams at one checkout.

Tests: two different provider-relative remotes with a shared claim collide no
more; same remote+provider names one repo across paths; bare aliases and
drive paths never hash as URLs. Full suite 5285 passed.

* fix(models): use the teamai.yaml provider for provider-relative remotes; bare aliases never key the file

Two more identity findings:

1. A provider-relative remote ignored the provider declared in teamai.yaml
   when no local override existed, so GitHub and GitCode teams sharing
   `remote: owner/repo` both hashed `tgit:owner/repo`. The effective provider
   now consults the teamai.yaml `provider:` (via the claim or, when the claim
   is absent, directly) before the local override and the tgit default — the
   same qualification the path-shaped claim already applied. Provider survives
   a team rename, so the file stays bound to the repository.

2. A bare remote alias (`remote: fork`, no URL, no claim) was still hashed
   directly as `tgit:fork`, so two checkouts sharing the alias shared one
   hash-only secrets file. A bare alias names no repository, so it now falls
   through to the provider/slug/path identity, whose slug keeps teams sharing
   a checkout path apart. Legacy `<slug>-<digest>` files for alias-keyed
   checkouts remain migration candidates.

Tests: each checkout declares its own teamai.yaml provider and stays distinct;
a bare alias no longer changes the file over the path-only form and two teams
sharing it stay separated by the slug. Full suite 5285 passed.

* fix(models): partial adoption never orphans a declined legacy file; adoption merges all adopted keys

Two defects in the read-time migration:

1. Declining one of several matching provider-ambiguous legacy files then
   saving the hash-only target stranded the declined file forever: the very
   existence of the target silences every future adoption candidate, so keys
   unique to the declined file became unreachable with no further prompt
   possible. The migration save now happens only when NO candidate remains
   for this team (adopted or gone); a declined file stays a candidate, keeps
   its keys reachable, and is re-offered on the next run.

2. A single-file read kept only the newest adopted identity's keys. When
   several files shared this checkout's digest, adopting all of them still
   dropped every non-newest identity's unique keys before the target
   shadowed them. Adoption now merges the keys of every adopted identity into
   the migrated target (new mergeModelInputs; the later map wins).

Tests: with two matching files, adopting one still surfaces the other to the
migration guard; adopting both leaves no candidates and the merge keeps both
identities' keys. Full suite 5286 passed.

* fix(models): member provider overrides the team's; declines are durable and no longer block migration

Two review findings, one shared root: the migration's consent model could not
distinguish "not yet decided" from "deliberately declined".

1. The effective provider put teamai.yaml's provider before the member's own,
   so a team declaring GitHub with the same bare claim made a member
   initialized with --provider gitlab share the GitHub team's hash-only file.
   The member's own provider now wins (localConfig.provider ?? team provider ??
   tgit), as everywhere else in the CLI.

2. Requiring every same-digest file to be adopted blocked migration behind a
   foreign team's file: declining GitHub Alpha to adopt GitCode Beta left an
   unadopted candidate forever, so no target could ever be created. Declining
   now records a durable, target-scoped ${target}::<slug>-<digest> refusal in
   the models manifest. A declined identity is neither re-offered on every run
   (no re-prompt loop) nor silently shadowed when migration proceeds (the
   choice is explicit, visible state, reversible by removing the key or the
   file) and does not count toward the migration guard — adopting only your
   own team's file no longer forces you to merge a foreign team's keys.

unadoptedLegacyFiles and findTeamValuesPath now take { adopted, declined };
findTeamValuesPath reads an adopted identity and skips a declined one.

Tests: found the regression itself (adopt one of two, decline the other leaves
no candidate and reads the adopted file only), the provider-override case, and
a refuseLegacyValue/refusedLegacyValueKeys manifest round-trip with a
fail-closed schema check. Full suite 5288 passed.

* fix(models): serialize the migration target against a concurrent writer by merging before save

The migration write into the new hash-only target is the one place a values
file is created from a different file, so it must not clobber a target a
concurrent process wrote between the legacy read and the save: writeJsonAtomic
prevents torn files, not lost updates. A pull reading the legacy file while a
configure creates the target could otherwise overwrite the newly configured
key with its stale snapshot. The migration now re-reads the target and merges
before saving (the concurrent content wins collisions).

The merge runs only in the migration branch (readFrom !== target). When the
run merely re-saves the file it already read, the target holds the same
content this bind just consumed — merging would re-inject the raw legacy
entries the bind renamed, so there is nothing to guard against there.

Full suite 5288 passed.

* fix(models): hold the team values lock across the whole read-modify-write

Re-reading the migration target and saving it were still two separate
operations, so the race the review flagged only shrank: a pull could re-read
the target, a concurrent configure could save a new key, and the pull could
then overwrite it with its stale merged snapshot. Team values writers now
share an advisory lock on the target file, and each of them runs its entire
read-modify-write cycle inside it:

- the migration's was already read-merge-save; it now runs that whole cycle
  under the lock instead of only the merge step;
- models configure and the models switch first-use prompt re-read the target
  inside the lock, so their write cannot clobber a migration or a sibling
  configure that landed in the same window.

withTeamValuesLock reuses the existing owner-verified acquireLock/releaseLock
primitive (dead-owner stale locks are reclaimed), so a crashed holder does not
block pulls or configures. Local values writes are untouched; no legacy
migration can race them.

Full suite 5288 passed.

* fix(models): resolve the profile from the key saved by the first interactive switch

The lock round-trip saved the first-use key into the freshly re-read snapshot
(current), but the rest of the command resolved the profile against the
original in-memory values, which had no key yet — so the switch stored the key
and then failed with "no API key", demanding a rerun. Apply the same key to
the in-memory copy after the locked write, and add a regression test for the
first interactive team switch (ask, save, and resolve in one run) so the
behaviour cannot silently split again.

Full suite 5289 passed.

* fix(models): save the awaited key with the first interactive local switch

Moving the getStoredApiKey update after the branch left the local path saving
values.json without the key the prompt returned: the switch succeeded from the
later in-memory copy, but the next invocation found values.json missing the
key and reported it not configured. Put the key in the in-memory copy before
the branch, so the local save already carries it; regression tests now cover
the first interactive switch for both the personal and the team profile.

Full suite 5290 passed.

---------

Co-authored-by: Codebuff <noreply@codebuff.com>
2026-09-29 21:16:59 +08:00
Hill PatelandClaude Sonnet 5 1d945b6f36 fix(init): seed a custom agent's configured root dir on regular init, not just self-mode (#867) (#873)
* fix(init): seed a custom agent's configured root dir on regular init, not just self-mode

Problem: in a multi-repo setup, `teamai init --agent AA` with `AA` a
custom agent whose skills/rules root is configured only through
`teamai.yaml`'s `toolPaths` (e.g. `AA: { skills: 'a/skills' }`) silently
delivers nothing, forever. `pull`'s `isToolInstalled` treats a missing
root directory as "not installed" and skips the tool — correct for a
real third-party tool the user has to install themselves, but wrong for
a purely teamai-managed directory convention that nothing else will ever
create. `--agent AA` is the only "installation" such an agent has (#867).

First approach, reverted: generalizing `isToolInstalledForConfig`'s
existing Copilot-only `enabledAgents` short-circuit to any tool broke
`doctor`'s own "`<tool> is installed`" diagnostic (#598), which reuses
the exact same function (via `skillsDirForTool`) to deliberately flag a
tool the user *claimed* via `enabledAgents` but never actually
installed — precisely the scenario that check exists to catch, not
paper over. Confirmed by running the full suite with each version: the
broad fix broke 3 `doctor.test.ts` cases ("buildChecks — a tool enabled
but not installed") that assert exactly this.

Actual fix: single-repo mode already solves the identical "the tool's
root doesn't exist yet, but the user explicitly asked for it" problem
via `seedSelfModeToolDirs`, called before hook injection specifically
because "a teammate's fresh clone has no `<repo>/.claude` yet, so
nothing would ever inject." That function is not actually self-mode
specific (it only uses `resolveBaseDir` + `enabledAgents`, neither of
which is self-mode-only) — it was just never called anywhere else.
Added the same call, in the same position (before hook injection), to
both the regular git-mode `init` and `initHttp`, and updated its
docstring to describe the now-dual motivation. `doctor`'s diagnostic is
untouched and still correctly flags a genuinely-uninstalled built-in
tool.

Test plan: new unit test in self-mode-agents.test.ts proving
`seedSelfModeToolDirs` seeds a custom agent's root outside self mode
(user scope, `toolPaths`-only entry). Real e2e verification via a
scratch script against the actual built CLI: confirmed `pull` silently
skips delivery and `doctor` correctly reports "AA is installed: false"
before the fix; after seeding, `pull` delivers both a skill and a rule
into the custom root for real, and `doctor` reports the check passing.
Full regression suite unchanged from the pre-existing baseline (48
failed files / 166 failed tests / 5072 passed) plus the one new test
passing (5073 passed) — no new failures.

Fixes #867.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix(init): address automated review findings on custom-agent seeding

The codex-review bot on PR #873 found three real gaps in the previous
fix (seedSelfModeToolDirs reused wholesale for regular/HTTP init):

1. It seeded *every* enabledAgents entry, including built-in tools. On
   a machine without Claude installed, `teamai init --agent claude`
   would silently create ~/.claude/skills, defeating doctor's "is
   installed" diagnostic (#598) — the whole point of which is to catch
   a claimed-but-absent tool instead of manufacturing a fake root for
   it. Fixed: outside self mode, only agents not in KNOWN_AGENTS (true
   custom agents) get seeded; built-in tools must already exist on
   disk, same as before.

2. It read teamConfig.toolPaths directly instead of resolving through
   scopedToolPaths, so a custom agent with a userScope path override
   would have its default path seeded while pull/doctor resolved the
   user-scope path — permanently mismatched. Fixed: use
   scopedToolPaths(teamConfig, localConfig), the same resolver every
   other call site (doctor, hooks, local-agent) already uses.

3. It only ever probed the `skills` path, so a custom agent configured
   with only e.g. `rules` (no `skills`) was never seeded and stayed
   "not installed" forever. Fixed: probe skills ?? rules ?? agents ??
   settings ?? hooks, the same order doctor.ts's buildEnabledToolChecks
   already uses for its own probe.

Also updates the --agent help text (src/index.ts,
skill-data/core/references/commands.md) and the FAQ in
docs/usage-guide.md (+ zh-CN) to describe the custom-agent seeding
exception, per the bot's P2 finding.

Test plan: added 3 tests to self-mode-agents.test.ts covering each
fixed scenario (built-in tool not seeded outside self mode, userScope
override honored, rules-only custom agent seeded) — 25/25 passing.
Full regression suite: 48 failed files / 166 failed tests / 5076
passed / 8 skipped (5250 total), exactly matching the pre-existing
baseline (166 failed, all pre-verified unrelated) plus these 3 new
passing tests. Real-CLI e2e whitelist test (#510) still passes
unchanged, confirming a built-in tool named in enabledAgents is still
gated on actually being installed.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix(init): seed the tool root, not the resource path, for file-valued fields

Second round of codex-review findings on PR #873:

1. settings/hooks/claudemd are FILE paths in ToolPathsSchema (e.g.
   "a/settings.json", "a/AGENTS.md"), unlike skills/rules/agents which
   are directories. The previous fix called ensureDir on whichever
   probe path matched first, so a custom agent configured with only
   `settings: "a/settings.json"` got a directory literally named
   settings.json created — hook reconciliation then can't write the
   real file there, so init reports success while installing nothing.

2. claudemd was missing from the probe order entirely (copied verbatim
   from doctor.ts's own probe, which has the same gap), so a custom
   agent configured with only `claudemd` was never seeded and stayed
   "not installed" forever.

Fixed: skills/rules/agents (directories) still seed the full resource
path as before; settings/hooks/claudemd (files) now seed only their
parent tool root via toolInstallRoot(), added claudemd to the probe.

Test plan: 2 new tests (settings-only and claudemd-only custom agents)
— 27/27 passing in self-mode-agents.test.ts. Typecheck clean, real-CLI
e2e whitelist test (#510) still passes. Full regression suite: 48
failed files / 166 failed tests / 5078 passed / 8 skipped (5252
total), exactly matching the pre-existing baseline (166 failed, all
pre-verified unrelated) plus these 2 new passing tests.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix(init): seed every configured root, including the HOME hook-scope root

Third round of codex-review findings on PR #873:

1. seedToolRoots (formerly inline) picked only one directory path via
   nullish coalescing (skills ?? rules ?? agents), so a custom agent
   configured with both e.g. skills and rules at different roots only
   got its skills root created — rules stayed "not installed" forever.
   Fixed: collect every distinct configured root into a Set and
   ensureDir each one.

2. A bare root-level file path (no "/", e.g. claudemd: "AGENTS.md")
   made toolInstallRoot return the path unchanged, so the previous fix
   would ensureDir a bogus directory literally named "AGENTS.md".
   Fixed: skip a file path whose toolInstallRoot equals itself — there
   is no parent directory to create for it.

3. Non-self project scope injects hooks into HOME, not the project
   root (resolveHookScope, #264 — `~/.claude` always exists for a
   built-in tool, so that gate passes without help). Seeding only
   under the project root left a custom agent's HOME root missing, so
   `init --scope project --agent <custom>` created its resource dirs
   correctly but silently skipped its session-start auto-pull hook.
   Fixed: seedSelfModeToolDirs now also seeds the hook-scope root when
   it differs from the config's own base dir.

Test plan: 3 new tests (multi-root seeding, bare-file-path no bogus
dir, HOME hook-scope root in project scope) — 30/30 passing. Typecheck
clean, real-CLI e2e whitelist test (#510) still passes, targeted
hooks-cmd/hook-dispatch-scope/init/doctor tests match their
pre-existing baselines exactly (verified via git stash comparison).
Full regression suite: 48 failed files / 166 failed tests / 5081
passed / 8 skipped (5255 total), exactly matching the pre-existing
baseline (166 failed, all pre-verified unrelated) plus these 3 new
passing tests.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix(init): seed bare settings/hooks files, restrict HOME pass to hook paths

Fourth round of codex-review findings on PR #873:

1. Skipping a bare root-level settings/hooks path (no parent directory
   to create) left hook installation permanently broken instead of
   fixed: reconcileHooksToAllTools's gate is
   `pathExists(join(baseDir, toolInstallRoot(paths.settings)))`, and
   toolInstallRoot degenerates to the path unchanged when there is no
   "/" — so the gate checks whether the settings FILE itself exists,
   which seeding never created. reconcileHooks already treats a
   missing settings/hooks file as `{}` (readJson(...) ?? {}), so this
   now seeds an empty JSON object at the bare path instead of skipping
   — satisfies the gate and gives it something valid to merge into.
   claudemd is deliberately NOT included: its own bare-root "installed"
   check in local-agent.ts keys off a `.${tool}` directory convention
   unrelated to the claudemd path itself, so a bare claudemd path is
   still left alone (extending seeding there would guess at a
   convention no custom agent is guaranteed to follow).

2. The HOME hook-scope second pass (added last round for #264) was
   seeding every resource root — skills/rules/agents/claudemd — under
   HOME, not just what hook installation reads. A custom agent with
   only `skills` configured (no settings-based hook surface at all)
   got a meaningless `~/.aa` created for nothing. Extracted the
   settings/hooks-only logic into seedHookInstallRoot and restricted
   the HOME pass to just that, leaving the full seedToolRoots pass for
   the config's own base dir only.

Test plan: 2 new tests (bare settings path seeds `{}`, not a
directory; HOME pass skips skills-only custom agents) — 32/32 passing.
Typecheck clean, real-CLI e2e whitelist test (#510) still passes. Full
regression suite: 48 failed files / 166 failed tests / 5083 passed / 8
skipped (5257 total), exactly matching the pre-existing baseline (166
failed, all pre-verified unrelated) plus these 2 new passing tests.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* docs(init): scope the custom-agent --agent seeding claim to git-backed init

Fifth round of codex-review findings on PR #873 (P2 non-blocking):
seedSelfModeToolDirs cannot seed a custom agent in HTTP init. initHttp
writes a local teamai.yaml stub with no toolPaths at all (Step 2,
'sharing: {}' only) — HTTP mode deliberately never clones a real
teamai.yaml; delivery works entirely through its own report/sync/ack
mechanism instead (refreshTeamRepo's http branch: "no repo tree to
pull here"). So `--agent AA` in HTTP mode records AA in enabledAgents
but seedSelfModeToolDirs finds no configured paths for it and creates
nothing.

Fetching a custom agent's remote paths at HTTP init time would need a
new API call HTTP mode doesn't have today — real work disproportionate
to a P2, and orthogonal to #867 (a git/self-mode custom-agent-root
bug). Took the bot's own suggested alternative instead: narrowed the
"(any mode)" claim in the --agent help text (src/index.ts,
skill-data/core/references/commands.md) and the FAQ
(docs/usage-guide.md + zh-CN) to say plainly that custom-agent seeding
is git-backed-init only, and HTTP init only ever seeds built-in tools
that are already installed.

Docs/help-text only — no logic changed. Typecheck clean, build clean,
self-mode-agents.test.ts still 32/32 (unaffected).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-29 21:01:36 +08:00
Saul Moro f8335ec40d fix(dry-run): stop dry-run and read-only commands persisting config migrations (#893) (#901)
* fix(dry-run): load mcp inject and mcp list without persisting a migration (#893)

mcp inject loaded its scope bare, so --dry-run still saved a pending role
migration, partition rename or self-mode bootstrap. It now forwards dryRun;
mcp list is read-only and loads with dryRun: true unconditionally, as status
and list do since #866.

* fix(dry-run): forward dryRun to the loader in roles init/add/remove/update (#893)

roles list is read-only and loads with dryRun: true unconditionally.

* fix(dry-run): forward dryRun to the loader in projects add/update/remove (#893)

projects list and projects members are read-only and load with dryRun: true.

* fix(dry-run): forward dryRun to the loader in tags add/remove (#893)

tags list is read-only and loads with dryRun: true.

* fix(dry-run): forward dryRun to the loader in source add/remove/add-http (#893)

source list and source browse are read-only and load with dryRun: true.
source list also reached a second bare load through loadLocalAgentConfig,
whose HTTP backfill reads only repo.kind and repo.url; it now loads with
dryRun: true, and the migration persists on the next command that writes.

* fix(dry-run): forward dryRun to the loader in remove and uninstall (#893)

* fix(dry-run): forward dryRun to the loader in packages install (#893)

* fix(dry-run): forward dryRun to the loader in import --from-iwiki/mr/claude/repo (#893)

--from-repo-list and --from-org reach the same load through importFromRepo.

* fix(dry-run): forward dryRun to the codebase loader; --lint and --status load read-only (#893)

* fix(dry-run): forward dryRun to the loader in models switch; models list loads read-only (#893)

* fix(dry-run): load read-only commands without persisting a migration (#893)

hooks list, members, exclude list, recall status and doctor load with
dryRun: true unconditionally, as status and list do since #866.

* docs(designs): name which commands take the dry-run detection path (#893)

* test(dry-run): pin that every loader on a dry-run or read-only path migrates nothing (#893)

LOAD_ONLY_COMMANDS gains the read-only commands and the previews that stay
clean on the legacy-role fixture. PREVIEWS covers the commands that fail past
the loader on that fixture: it asserts only config.yaml, with the same call
without --dry-run as the positive control that the load is on the path.

* fix(dry-run): forward dryRun through resolveMemberToolRoots for import --from-claude (#893)

scanCandidates resolves Claude's tool root before the loader import.ts already
fixed, through a bare load in resolveMemberToolRoots, so the dry run still
saved the migration. resolveConfigForDir and findUnreadableProjectConfig take
the same optional LoadOptions for the read-only callers below; hook and usage
callers pass nothing and behave as before.

* fix(dry-run): pass dryRun into loadLocalAgentConfig instead of forcing it (#893)

Forcing dryRun there made real hook runs print the [dry-run] migration
preview and stop persisting it. source list now passes it through
describeLocalAgent; every other caller is unchanged. source add-http forwards
{ dryRun: options.dryRun } like the rest of the file.

* fix(dry-run): load skill list/show, webhook list, stats and digest read-only (#893)

detectTeam takes optional LoadOptions; the Stop-hook share gate passes none.

* docs(designs): name read-only commands by example, not as every list subcommand (#893)

* test(dry-run): prove each row reached the load, and cover the remaining changed sites (#893)

Every dry-run row now asserts the loader logged its migration preview, so an
early return cannot pass. Adds source browse, codebase --lint, skill list/show,
webhook list, digest, projects update/remove, import --from-claude, and a
config-only table for members, projects members and stats.

* fix(dry-run): keep the pre-command migration from adopting a partition on a dry run (#893)

The preAction hook passes dryRun to maybeMigrate, but planMigration and
queueKeptInCheckout resolved the partition bare, so pull/push --dry-run
still renamed a pre-#546 partition. Both now take the option.

* fix(dry-run): load skill get/path read-only through the share gate (#893)

blockReason fell back to shareGate(), a bare load, when no team was passed;
only skill get, skill path and the catalog reach that fallback. The Stop
hook still asks shareGate directly and is unchanged.

* refactor(dry-run): give loadWebhookConfig the option instead of copying its branch (#893)

Also note in loadLocalAgentConfig that dryRun covers only its config.yaml load.

* revert(dry-run): leave stats out of this change (#893)

stats also loads bare per session through the dashboard scope helpers on the
pull/report path; a partial fix would claim more than it does. Tracked in the
follow-up issue with the other dry-run gaps.

* test(dry-run): cover the pre-command migration and skill get/path share (#893)

* refactor(dry-run): give shareGate the option instead of repeating it in blockReason (#893)

Also correct the test comment on when the pre-command migration runs.

* fix(dry-run): keep loadLocalAgentConfig from writing config.json under dryRun (#893)

The option reached only the config.yaml load. The legacy group-binding
cleanup, the binding-key canonicalization and the HTTP backfill still saved
config.json, so `teamai source list` could rewrite or create it. Under
dryRun they now stay in memory, and the cleanup prints a `[dry-run] Would
remove` preview instead of `Removed`.

* fix(dry-run): keep models list from saving a re-bound beta key (#893)

models list loaded the scope read-only but read the team keys through
loadTeamValues without the option, so a key a 0.26.0 beta stored under the
profile id alone was bound to its gateway and the values file rewritten.
It now binds the key in memory only; the next write command saves it.
2026-09-29 21:00:19 +08:00
Saul Moro 417e70427c fix(pull): keep a skill, rule or agent you edited instead of overwriting it (#822) (#865)
* fix(pull): keep a skill, rule or agent you edited instead of overwriting it (#822)

Pull records the sha256 of the bytes it writes at each skill, rule and
agent destination in the checkout's record (`delivered`), and the
pre-push sync records its writes too. On a full sync, a copy that no
longer matches the record is kept and named, with a warning when the
team version moved since; push repeats that warning. Tombstone cleanup
and the rules sweep keep an edited copy the same way. `--force` keeps
edits, `--dry-run` prints `Would keep`, and `doctor` does not fail on a
kept copy. Without a record (first pull on this version, a new
worktree) pull overwrites as before.

* fix(rules): drop a copy the rules reclaim removes from the delivered record (#822)

#815 removes an unedited copy of a team rule when none reaches the
directory, but the pull's delivered record kept its hash. Forget it there,
as the stale-rule sweep does, so the record matches what is on disk.
2026-09-29 18:25:23 +08:00
Smilewithoutfalling 671f509f70 fix(dry-run): thread { dryRun } into the queue lock, so a preview writes nothing (#896)
`--dry-run` promises no changes made. On a fresh self-mode clone one write still
gets through: an empty `<home>/.teamai/locks/` is created and left behind. It is
only the directory, not a lock file, but it is a real filesystem change and the
suite can see it -- `dry-run-load-path.test.ts` declares it as a tolerated entry
for `pull` today, which is the honest way of saying the preview is not clean.

The mechanism is already on main: #866 gave `acquireLock` an `options.dryRun` that
reads the lock state instead of taking it, and `pull.ts:1873`, `push.ts:784` and
`push.ts:839` pass it. The queue lock does not:

  learnings-publish.ts:75  syncLock === null || await acquireLock(syncLock)   <- not passed
  learnings-publish.ts:93  listPendingForInstall(localConfig)                 <- not passed
  pending-learnings.ts:77  withQueueLock(...) -> acquireQueueLock(home)       <- not passed
  pending-learnings.ts:66  if (await acquireLock(lockPath))                   <- takes the real lock

`pull` counts the queue so it can report how many learnings it would publish, and
counting takes the queue lock, because the queue owns it. Taking that lock is a
write: `acquireLock` ensures the lock parent (`update.ts:404`) and `releaseLock`
removes the lock FILE but not that directory (`update.ts:464`), so the directory
outlives the command.

This threads the flag down. Three signatures gain an optional `{ dryRun?: boolean }
= {}` and forward it; existing callers (`migrate.ts:413`, `migrate.ts:891`, and the
`withQueueLock` inside `savePendingLearning`) send `{ dryRun: undefined }` and are
unchanged.

`readPendingForInstall` deliberately does not get the parameter: its only caller is
behind `if (locked && !dryRun)` (`learnings-publish.ts:94`), so a preview never
reaches it.

The test gets stricter -- `pull`'s allowed list goes from `[PULL_LOCK_DIR]` to `[]`.
`snapshotTree` records directories as `"<rel>/" = 'dir'`, so an empty directory is
counted; that is why the entry had to be written at all.

Verification:
- before: `vitest run src/__tests__/dry-run-load-path.test.ts` -> 22 passed
- after: same -> 22 passed, with `pull`'s allowed list empty
- mutation: reverting `acquireLock(lockPath, options)` to `acquireLock(lockPath)`
  turns the case red and names the cause, "appeared": ["home\\.teamai\\locks/"]
- no collateral: `lock-atomic` 22/22, `pull-post-checks` 13/13,
  `pull-placement-reconcile` 2/2; `tsc --noEmit` rc=0 and
  `oxlint --deny-warnings` rc=0
- the locally-failing files (`pending-learnings`, `git-kind-learnings`,
  `checkout-refusal-silent`) fail identically on the unmodified base: POSIX path
  assertions on Windows and EBUSY from git-worktree fixtures in workers
2026-09-29 16:17:19 +08:00
JeffandCursor 5fddf0c5d1 fix(test): keep CI validate from timing out on the usage lock and an unborn HEAD (#897)
A busy event loop stretched the usage rewrite wait past its 5s budget because the wait counted sleeps. Bound it by wall time. The viz fixture also cloned a bare repo whose HEAD stayed on master under git 2.39, so worktree add failed with "invalid reference: HEAD".

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-29 13:44:25 +08:00
Saul Moro cd3a914112 fix(env): keep the user scope's env block when a project pulls (#878)
* fix(env): make doctor and uninstall act on their own scope's env block (#876)

A shell profile can carry a user-scope block and a project-scope block, but
doctor, uninstall and the active-profile resolver only ever read the first
`# [teamai:env:start]` pair. Doctor in a project then blamed the #661
backslash for the user scope's block, uninstall's plan missed a second block
of its own, and its cleanup step removed the first block whatever scope it
belonged to.

Add findEnvBlocks/findEnvBlockFor, which pick a block by the env.sh it
sources (the ownership test uninstall already used), and use them in all
three consumers. With only another scope's block present, doctor now says the
profile carries no block for this env.sh. The ownership test also accepts the
all-backslash spelling of the path, so a #661 block of this scope keeps its
diagnosis. The marker format and how pull writes the profile are unchanged.

* fix(env): keep the user scope's env block when a project pulls (#876)

Each pull replaced the first `# [teamai:env:start]` block in the shell
profile, whichever scope it belonged to, so a member with a user and a
project scope got only the last-pulled scope's env in new shells.

Injection now picks the block by the env.sh it sources. A scope replaces
its own block. Otherwise a project takes over another project's block,
never the user scope's (recognised by the env.sh the user-scope config
resolves to), and a user scope goes in right before the project block. The
user block therefore precedes the project block, so a project value wins on
a shared key, and the project block stays last-wins. Other blocks are left
alone; the marker format and the skip-when-unchanged write are unchanged.

* fix(env): address #876 review findings on env block ownership

- Drop the all-backslash spelling from candidateSpellings; the #661
  doctor tests now use a Windows-form data home instead.
- envBlockReferencesDataHome requires a path boundary before a raw
  spelling, so /home/me/.teamai/env.sh no longer claims a block that
  sources /data/home/me/.teamai/env.sh.
- resolveActiveShellProfile falls back to the first file along the
  sourcing chain that holds any teamai block when none holds this
  scope's, so a first user pull or a second project pull on the Git
  Bash chain lands next to the existing block instead of after the
  .bashrc source line.
- injectShellProfile uses one "user scope's block" predicate for both
  scopes; a block that sources no env.sh (pre-env.sh inline exports)
  is the user scope's, so a project pull no longer takes it over.
- Tests build well-formed blocks with EnvHandler.generateShellBlock.

Refs #876

* fix(env): match an env.sh path only up to its end (#876)

* fix(env): keep the user block when the user config does not parse (#876)
2026-09-29 11:09:05 +08:00
Saul Moro f19f9de23a ci(lint): enable type-aware linting with no-floating-promises and no-misused-promises (#836) (#863)
* ci(lint): enable type-aware linting with no-floating-promises and no-misused-promises

tsc does not report a promise nobody awaits or an async callback handed to an
API that ignores its result. In a CLI that is an error nobody sees. oxlint's
--type-aware mode checks both through oxlint-tsgolint.

- package.json: add oxlint-tsgolint 7.0.2003 (oxlint 1.85.0 needs
  >=7.0.2001) and pass --type-aware to `npm run lint`. The package ships
  prebuilt binaries as optional dependencies with no install script, so CI's
  `npm ci --ignore-scripts` needs no change.
- .oxlintrc.json: turn on typescript/no-floating-promises and
  typescript/no-misused-promises. Under src/__tests__/**, no-misused-promises
  skips checksVoidReturn.arguments: vi.spyOn(fse, ...) types the spy from
  fs-extra's last overload, the callback one that returns void, so every
  correct async mockImplementation is flagged.
- --type-aware also turns on oxlint's default type-aware rules. The ones
  with hits on main stay off, since each is its own evaluation under #836.
  The rest of the defaults have no hits and stay on.

Fixes, all behavior-neutral:
- dashboard.ts: the SSE debounce and the PID check become named async
  functions called with `void`; both already catch everything they throw.
  The HTTP handler becomes handleRequest, and createServer catches its
  rejection, logs it and answers 500.
- import-repo-list.ts: track in-flight imports in a Set, so the unused array
  splice returned is gone.
- scripts/mock-teamai-server.mjs: same handler wrapper as the dashboard, so a
  malformed JSON body answers 500 instead of an unhandled rejection.
- ai-client.test.ts: schedule the mock process events with queueMicrotask.

Refs #836.

* ci(lint): turn on typescript/no-useless-default-assignment

1 prod hit, type-only: syncTeamUpdatesToLocal's last parameter was
`placedRules: Record<string, string> | undefined = undefined`; it is now
`placedRules?: Record<string, string>`.

Refs #836.

* ci(lint): list only the type-aware rules that would otherwise run as off

consistent-return, no-unnecessary-boolean-literal-compare,
no-unnecessary-type-arguments, no-unnecessary-type-assertion,
no-unnecessary-type-conversion, no-unnecessary-type-parameters and
no-unsafe-type-assertion are not default rules, so listing them as off
changed nothing. The config keeps off only the nine default type-aware rules that
--type-aware would turn on.
`oxlint --type-aware --print-config` gives the same set of active rules
before and after.

Refs #836.
2026-09-29 11:07:58 +08:00
Saul Moro facc6104f6 fix: keep self-update off npm link checkouts, and report hook injection only on change (#871) (#874)
* fix(update): leave an npm link checkout alone instead of replacing it with the registry package

When the running CLI is not under node_modules (an npm link checkout),
resolveInstallPrefix returns null and doUpdate ran a prefix-less
npm install -g. That lands in npm's default global prefix, where npm link
put its symlink, so the next hook run swapped the checkout for the
published package. Skip the install and say how to update instead.

* fix(hooks): report OMP, OpenCode and Pi hook injection only when the file changes

These injectors rewrote their file and logged success on every pull, so
an up-to-date pull listed them as injected while Claude and Codex, which
already compare before writing, printed nothing. Use writeIfChanged and
log at debug level when the file is current.

* fix(update): refuse an unsupported install before prompting, and keep the skip in debug.log

Review follow-ups: resolve the install target before the prompt policy so
nobody confirms an update that is then skipped; persist the skip warning
because hooks discard stderr; word it for every null target, not only a
link; tell the user to pull before rebuilding; pass --registry in the
suggested install command.

* fix(hooks): report Hermes and OpenClaw hook injection only when something changes

Both are reached by the pull reconcile and still logged success on every
run. OpenClaw now writes its two files with writeIfChanged; Hermes also
reports whether its config.yaml entry and allowlist were written.

* fix(update): persist the vendored-install skip to debug.log too

Adversarial review follow-up: the Stop hook discards stderr, so the
vendored-layout refusal left no trace while the linked-checkout refusal
next to it is persisted.
2026-09-29 11:06:54 +08:00
ydflow c7874434c9 fix(dashboard): keep SSE live after event log compaction (#888) 2026-09-29 09:52:24 +08:00
ydflow a5b36a8a73 fix(hooks): release stdin after read timeout (#889) 2026-09-29 09:51:44 +08:00
Saul Moro 4284918b83 fix(tests): isolate Claude config dir from model tests (#890) 2026-09-29 09:50:47 +08:00
ydflow 479f811b52 fix(recall): normalize scores across knowledge sources (#891) 2026-09-29 09:50:12 +08:00
Saul Moro 11657bcfab fix(recall): read each agent's session variable so recall quality joins its session (#887) 2026-09-29 09:49:01 +08:00
Saul Moro f836db4230 fix(push): publish the env files env add leaves in a standalone clone (#881) (#885) 2026-09-29 09:48:00 +08:00
Jin 732c42cb6d fix(rules): reclaim delivered team rules when none is selected (#802) (#815) 2026-09-29 09:46:56 +08:00
Smilewithoutfalling b3b3a0b03c fix(dry-run): thread { dryRun } through the loaders pull, push, status and list use (#866)
* fix(dry-run): thread { dryRun } through the loaders pull, push, status and list use

`--dry-run` is documented as previewing without making changes, and #837 made
that true for `tags subscribe`, `tags unsubscribe` and `roles set`. `pull`,
`push` and `status` still wrote: each runs its scope-detection block before any
dry-run guard, and that block called config loaders that were never given the
flag. A preview could therefore persist the legacy role migration, and in a git
repo adopt a pre-#546 partition or run the single-repo self-heal bootstrap.

The loaders already take LoadOptions — #853 threaded them through
`loadLocalConfigForScope` for contribute / session save / recall. These
commands simply did not supply the flag.

- pull.ts: both loaders take { dryRun: options.dryRun }.
- push.ts: autoDetectInit takes it.
- status.ts and list: { dryRun: true } unconditionally, because both are
  read-only and should never migrate, adopt a partition or bootstrap.

Callers that pass nothing behave as before, the same compatibility promise
#837 made. The one observable change is that the preview path logs, so
`status`/`list` now surface a "[dry-run] Would ..." line where a migration or
bootstrap is pending; the PR description asks for a decision on that label.

Verification: seven new command-level cases, each failing on unmodified main
with the identical test file (the project-scope three need a git project with
a pre-#546 partition name, which the existing user-scope fixture never
reaches); and the issue's own real-CLI reproduction, 6/6 — the control writes
the reported fields, this change writes nothing.

oxlint --deny-warnings and tsc --noEmit: rc=0 here and rc=0 on unmodified main.

Closes #850

* fix(pull,push): a --dry-run must leave a fresh self-mode clone alone

Resolves both P1 findings on this PR.

pull.ts:1891 - the previewed self-mode config reached lockScope(), whose
acquireLock() calls ensureDir() on the lock's parent. On a fresh clone the
partition does not exist yet, so the directory was created and stayed:
releaseLock removes the lock file, not its parent. The guard sits inside
lockScope(), the one choke point all three call sites share.

push.ts:731 - the same previewed config ran the whole self-mode setup before
pushCore reached its own dry-run guard at push.ts:1577: the sync-lock,
migrateSelfModeGitignore(), and the disposable knowledge worktree.

Both guards are deliberately narrow. A blanket early return before the
git-mode branch would also skip resetToCleanMaster/pullRepo (push.ts:934),
which a dry run performs on purpose so it can name the destination the real
command would use. Only writes that outlive the command are gated.

The preview still reads the uncommitted teamai.yaml that pushCore receives
as initialPendingTeamConfig; that block is now pendingSelfTeamConfig(), the
identical read, so the preview keeps describing the config edit it exists to
describe.

Fixture gap, also flagged: dry-run-load-path.test.ts already had a
fresh-self-mode-clone fixture, but only tags/roles ran against it. pull and
push ran at user scope, or on a project partition that already exists - never
on the one shape where acquireLock has something new to create. Two cases
added there; the unfixed tree fails them at
fs.existsSync(<HOME>/.teamai/projects) with "expected true to be false".

Not fixed here, and named in the PR description: pull --dry-run on a fresh
clone still creates an empty <HOME>/.teamai/locks/, via listPendingForInstall
in utils/pending-learnings.ts. That call is unchanged by this PR and the file
is outside its scope; the test declares and counts the entry, so anything
else appearing still fails.

* test(pull): pin the loader call shape the widened signature produces

Fixes the red Lint & Test on the previous head (all four matrix entries).

pull-scope-isolation.test.ts asserted the exact argument list of
loadLocalConfigForScope, which this change widens to carry LoadOptions:

  expect(loadLocalConfigForScope).toHaveBeenCalledWith('user');
    received ['user', undefined, { dryRun: undefined }]

The shape is not a free choice: recall.ts:464 and recall.ts:515 already pass
the same third argument, landed with #853, so pull.ts matches the merged
precedent. recall-scope-isolation.test.ts never asserts the argument list,
which is why the same change left it green.

The affected set is derived from the changed symbols rather than from the
topic: every test file that mentions loadLocalConfigForScope,
detectProjectConfig or autoDetectInit (76 files, 1221 tests).

* fix(update): a --dry-run asks the lock for its state instead of taking it

`acquireLock` is not a read. It `ensureDir`s the lock's parent, which on a
fresh self-mode clone is a `<getDataHome>` partition that does not exist yet —
and `releaseLock` removes the lock FILE, not that directory, so the directory
outlives the command. Any preview that calls it therefore writes, which is the
defect this PR is about (#866).

The new `options.dryRun` returns what the preview actually owes its caller: the
ANSWER the real run would get. `lockState` already separates `live` (a holder
is running) from `stale` and `missing`, and the real run reclaims either of the
latter and wins, so `acquireLock(path, { dryRun: true })` is exactly that
verdict — no mkdir, no lock file, no reclaim sentinel.

Nothing is recorded in `heldLockOwners`, which is what makes the preview safe
alongside the existing `releaseLock` calls: it returns at its first line when
it holds no owner token for the path, so a preview cannot delete a lock another
process owns.

No caller passes `dryRun` yet; this commit is the primitive only.

* fix(pull,push): acquire the preview's locks read-only, and stop hiding what it reports

Supersedes the guards added in cfd7c573. Those guards stopped the writes, and
E2E (fork-safe) caught what they cost — 6 failures in
push-sync-followups-823.test.ts, all of the same shape:

  expected '- Scanning local resources...\nNo new or modified resources to push'
  to contain '[rules] teamai-rule (modified)'

A preview that reports no changes for a tree with a deliberately edited team
rule is not a conservative preview; it is this PR's own defect with the sign
flipped.

pull.ts — `lockScope()` returned `true` outright for a dry run, asserting the
scope was uncontended. That is a fabricated fact: its callers read `true` as
"you hold the lock". It now acquires through the read-only primitive and
records the lock for release only when it really took one, so a scope with a
live holder is reported as contended and skipped, exactly as a real pull does.

push.ts — the self-mode branch returned early for a dry run. Two of the three
things it skipped are right to skip and one is not.

  - the sync-lock: read-only now, at both push.ts:784 (self) and push.ts:839
    (git mode).
  - `migrateSelfModeGitignore()`: still skipped. It rewrites a tracked file in
    the user's ACTIVE tree, which outlives the preview, and it is idempotent,
    so the next real push performs it.
  - the knowledge worktree: MUST run, and that is the correction. `pushCore`
    adds the active tree's `.teamai/{skills,rules}` as scan sources and diffs
    them against `localConfig.repo.localPath` (push.ts:1095-1112) — the clean
    worktree checkout. Outside the worktree those are the same path in self
    mode, so the diff is empty by construction: skipping the worktree does not
    report an edit early, it hides the edit. `withKnowledgeWorktree` already
    removes it in a `finally`, so it stays disposable.

Fixture — `push --dry-run` now performs its `git fetch` for real, which leaves
`app/.git/FETCH_HEAD` behind. Declared and counted next to the existing
`<getDataHome>/locks/` entry, so any OTHER new entry still fails the case.
2026-09-28 22:32:16 +08:00
Saul Moro e2fd0abf11 fix(remove): remove a root MCP server named 'ambiguous' instead of refusing it silently (#862) (#864)
mcpRemovalTarget returned string | 'ambiguous' | null, so a root server
named 'ambiguous' came back as its own name and the caller read it as the
refusal sentinel: nothing removed, exit 1, no reason printed. Return a
tagged result and switch on its kind.
2026-09-28 22:31:07 +08:00
Saul Moro 53c096e02f fix(git): spawn git by its resolved path so macOS skips a PATH search per call (#869)
* fix(git): spawn git by its resolved path so macOS skips a PATH search per call

On macOS, a bare-name spawn of git costs a few milliseconds for every PATH
entry ahead of git's directory: 30-67 ms per call with a typical PATH, while
git itself takes about 8 ms. createGit now passes simple-git the absolute
path of the git that lookup would pick, resolved once per PATH value.

Refs #868

* fix(git): leave unreadable git candidates to the spawn's own lookup

A stat error other than ENOENT/ENOTDIR (a symlink loop) now keeps the bare
name instead of moving on to a later git. Narrow the CHANGELOG to the
simple-git paths and correct two comments.

Refs #868

* fix(git): keep the bare name when the first git on PATH does not start

A bare-name spawn moves past a PATH entry whose spawn fails, such as a
script whose interpreter is gone, while a spawn by path just fails.
gitBinary now spawns the candidate once (git --version) and keeps the
bare name unless it starts. POSIX-only gitBinary cases skip on win32.

* fix(git): look PATH up again when the resolved git is gone or not executable

A bare-name spawn walks PATH on every call, so a git removed or chmod'ed
mid-process is skipped. gitBinary's cache kept the stale absolute path.
One access(2) per call now drops a cached path that is no longer
executable.
2026-09-28 22:29:59 +08:00
Saul Moro 5fb316c7b8 fix(code-knowledge): keep tree-sitter grammars off V8's optimizing Wasm tier on Node 24 (#860) (#861)
On Node 24, V8's Turboshaft Wasm compiler exhausts its Zone memory while
tiering up tree-sitter grammar code and aborts the process with "Fatal
process out of memory: Zone" (nodejs/node#63421). The Swift grammar added
in #842 hits it right after its first parse, so `teamai codebase --extract`
on a repo with .swift files died with exit 133 and no fallback.

Set --wasm-tier-up-filter to an index no grammar has before the WASM
runtime starts, on Node 24 and later only. --liftoff-only has the same
effect but Node 24 ignores it when set at runtime. Node 20 and 22 keep
tier-up, which parses about 1.6x faster there.

Add Node 24 to the CI matrix so the regression test runs where it can fail.

Closes #860
Refs #842
2026-09-28 10:46:07 +08:00
ydflowandydflow e9368701a7 fix(stats): split Windows cwds on the backslash in attributeRepo (#858)
* fix(stats): split Windows cwds on the backslash in attributeRepo

`attributeRepo` split a filesystem cwd on `/` only, so on Windows every
`cwd` stayed one segment: `C:\Users\dev\work\teamai-cli` was attributed to
a repo literally named `C:\Users\dev\work\teamai-cli` instead of
`teamai-cli`. The `NON_REPO_LEAVES` check (`home`, `data`, `tmp`, ...)
never fired either, so a session started in `C:\src\data` opened its own
row instead of merging into `no_repo` like `/src/data` does.

`repoLabel` masked most of it on Windows, because `path.basename` handles
both separators and the repo-directory branch of `nameOf` runs first. The
gap shows wherever the path is not a live repo directory: a worktree
removed after the fact (#810), a session recorded outside git, or a
dashboard event whose anchor no longer exists. A drive root (`C:\`) also
left the bare drive letter as the leaf, where `/` on POSIX yields
`no_repo`.

Split on both separators and treat a bare drive letter as no project, so
Windows cwds attribute the way POSIX ones already do. This also fixes
`repoLabel`'s "a directory named like workspace is still no_repo" test on
Windows, which failed on `origin/main` for the same reason.

* test(e2e): retry the #810 sandbox removal past a detached hook child

`E2E (fork-safe, no credentials)` fails this suite on Linux with

    Error: ENOTEMPTY: directory not empty, rmdir '/tmp/teamai-issue810-e2e-*/home/.teamai'

after all 12 tests pass, so the run reports `1 failed | 56 passed` suites with
`356 passed | 26 skipped` tests and exit code 1.

Every hook here goes through `hook-dispatch`, which spawns a detached child for
the background-only handlers (SessionEnd, Stop) — `spawnPlainDetached` in
src/hook-dispatch-cli.ts. The suite waits for the events that child writes, and
never for the child itself to exit, so `afterAll`'s removal runs while it may
still be creating a file under `.teamai`. One `rmdir` then loses the race and
the suite is reported failed with nothing actually wrong.

Add the retry options `git-kind-learnings.test.ts` already uses for the same
`git gc --auto` race. Node's rm retries EBUSY, EMFILE, ENFILE, ENOTEMPTY and
EPERM with a linear backoff, which covers both this and the Windows `EPERM`
this suite also hits locally.

Unrelated to the attributeRepo change in the parent commit: the suite never
calls it, and the diff is an identity transform on every POSIX-shaped cwd the
suite produces (verified over 35 inputs, including each `teamai-issue810-e2e-*`
path). The Windows `EPERM` failure is pre-existing — it reproduces identically
on a clean origin/main checkout — and persists here because it is a persistent
condition, not a transient one; this change does not make it worse.

---------

Co-authored-by: ydflow <314143294+ydflow@users.noreply.github.com>
2026-09-28 10:45:33 +08:00
ydflowandydflow 2169f2e7e7 fix(iwiki): stop fetchAllPages from rejecting every space walk before it starts (#849)
The 'initial queue is empty' guard ran after tryDrain(), which had
already dispatched the root request: tryDrain shifts the only queue
entry, so the queue is always empty by then and the promise rejected
with 'fetchAllPages: rootId 队列为空' on every call, before any
response could arrive. Zero-latency mocks hid it — the first response
beat the rejection and populated allPages, so the catch guard
(allPages.length === 0) swallowed the error.

Check the guard before tryDrain() and seed the queue only for a
non-empty rootId, so it rejects exactly when there is nothing to walk.
Also drop the dead '!stopped' from the drain loop (tryDrain already
returns when stopped, and stopped cannot change inside the synchronous
loop — oxlint no-unmodified-loop-condition, the hit #836 flags), and
write the file's user-facing messages in English per the repo rule.

Co-authored-by: ydflow <ydflow@users.noreply.github.com>
2026-09-28 10:44:24 +08:00