Description
--dry-run promises "no changes made". It still writes, in three ways:
B the flag never reaches the command recall maintenance / promote run for real
C the command writes before its dry-run guard pulls, clones, locks, state saves
D the command ignores the flag runs for real
Code refs are main at 417e704.
Status
Updated 2026-10-01 against main at b2d3598.
| Item |
Owner |
State |
B recall maintenance / recall promote |
#903 |
merged |
D projects set |
#905 |
merged |
D exclude add / remove |
#906 |
merged |
D recall enable / disable |
#907 |
merged |
D review --apply / --reject / --all-apply |
#930 |
merged |
D mcp remove |
#937 |
merged |
D webhook test |
#941 |
merged |
C10 pull self-heals .teamai/.gitignore |
#944 |
open |
D init --dry-run (single-repo path) |
#852 |
open, conflicting |
| Loaders that persist a migration on a dry or read-only run |
#901 (not this issue; see #893) |
merged |
| Everything below marked open |
up for grabs |
|
Related, same files: #895 (merged) rewrites loadTeamValues, so C4 can build on it now. #899 and #901 (both merged) touch config.ts; #901 sits 2-3 lines above the pulls in C5, so C5 is unblocked.
Do first: a dry run can discard commits
pullRepo (src/utils/git.ts:312-352) falls back to git reset --hard origin/<b> when pull --ff-only fails. C1 and C5 call it before their guard.
Fix rule
allowed in a preview git fetch (FETCH_HEAD, remote-tracking refs)
not allowed pull / reset / checkout · clone into a cache · lock · config, state or manifest write
cheap preview print what would change, return before the write
no cheap preview refuse: exit 1, "teamai <cmd> has no --dry-run preview, nothing was run"
C. Writes before the guard (open)
| # |
Command |
Writes on a dry run |
Fix |
| C1 |
remove |
pullRepo remove.ts:77, saveStateForScope :98; self mode force-removes and recreates knowledge-wt |
fetch only; keep the reconciled records in memory (publishedNameFor :136 reads them back); self mode reads the checkout's .teamai |
| C2 |
source add |
clones into ~/.teamai/sources/<name>/repo (source.ts:149), or pulls it |
stop before the clone: "Would clone ..." (the publicSkills warning needs the clone; say it was skipped) |
| C3 |
import --from-repo, --from-repo-list, --from-org |
deletes and re-clones the cache (clone.ts:162), or reset --hard it (:318-338); import lock; LLM scan; --from-org reads a stale whitelist file |
stop before clone, lock and LLM: ls-remote, compare with LAST_SYNC, "Would import ..."; --from-org keeps the list in memory |
| C4 |
models switch |
loadValuesFor models-cmd.ts:154 saves re-bound keys |
forward { dryRun } (:154, :370); rebase on #895 |
| C5 |
roles init/add/remove/update, projects add/update/remove |
pullLatest (manifest-edit.ts:9) before each guard; self mode creates a worktree |
fetch, read the manifest at origin/<b> (getFileContentAtRev); self mode reads main; land after #901 |
| C6 |
stats |
bare resolveConfigForDir per event dir (dashboard-scope.ts:50,103, session-owners.ts:82,357); seeds session-owners.jsonl; refreshes the reports checkout |
resolveConfigForDir(dir, options), scope helpers always { dryRun: true }; no seed on read |
| C7 |
recall maintenance (all modes), recall promote |
loadUserVotes rewrites every v1 votes file (votes.ts:199-202) |
read-only readUserVotes for these paths |
| C8 |
source browse |
clones or pulls (source.ts:343) |
with --dry-run: read the cache, or "Would clone ..." |
| C9 |
recall <query> --dry-run |
quality record (recall.ts:649-653, deliberate), index rebuild saved (:330-390) |
record nothing, build the index in memory |
| C10 |
pull (self mode) |
refreshTeamRepo runs migrateSelfModeGitignore, rewriting the tracked .teamai/.gitignore above every guard |
pass options, self-heal only on a real run (#944) |
D. Ignores the flag
| Command |
Where |
Fix |
Owner |
projects set |
projects-cmd.ts:82-128 |
preview |
#905 (merged) |
exclude add / remove |
exclude.ts:47-80 |
preview |
#906 (merged) |
recall enable / disable |
recall-toggle.ts:125-141 |
preview |
#907 (merged) |
init |
init.ts |
preview (single-repo) |
#852 (conflicting) |
hooks inject |
hooks-cmd.ts:97-125 |
preview: reconcileTeamHooksForConfig takes dryRun |
open |
mcp remove |
mcp-cmd.ts:116-124 |
preview: reconcileMcpForConfig(..., { removeAll, dryRun }) |
#937 (merged) |
review --apply / --reject / --all-apply |
review-cmd.ts:152,169,232 |
preview |
#930 (merged) |
update |
update.ts:672-686 |
behave as --check |
open |
recall feedback |
votes.ts:435 |
preview |
open |
models add / configure / remove |
index.ts actions skip program.opts() |
merge options, preview; rebase on #895 |
open |
bind-project |
local-agent config |
preview |
open |
webhook test |
sends HTTP |
preview: "Would send to N endpoint(s)" |
#941 (merged) |
hooks remove, codebase --extract, init --http |
no preview path |
refuse |
open |
Out of scope: hidden commands called only by hooks (hook-dispatch, track, source reconcile-plugins, ...); env (#880).
After everything lands
A guard in the preAction hook that refuses --dry-run on any command not on an allowlist, so a new command can't silently run for real:
key full command path (leaf names collide: add x7, remove x10, init)
where top of preAction, before the hook-subcommand early return (index.ts:91)
refuse thisCommand.error(msg, { exitCode: 1 })
Reproduction
teamai recall maintenance --prune --dry-run with a prunable learning: it is deleted (B).
teamai hooks inject --dry-run: tool settings change (D).
teamai roles add x --namespaces x --dry-run on a clone with diverged local commits: pullRepo resets it (C5).
Environment
- teamai:
main at 417e704
- Provider: any
- AI tool(s): any
Logs
How this was found
B was reproduced with the real CLI (see #903). The C and D entries come from reading the code at 417e704. They were not each run.
Description
--dry-runpromises "no changes made". It still writes, in three ways:Code refs are
mainat 417e704.Status
Updated 2026-10-01 against
mainat b2d3598.recall maintenance/recall promoteprojects setexclude add/removerecall enable/disablereview --apply/--reject/--all-applymcp removewebhook testpullself-heals.teamai/.gitignoreinit --dry-run(single-repo path)Related, same files: #895 (merged) rewrites
loadTeamValues, so C4 can build on it now. #899 and #901 (both merged) touchconfig.ts; #901 sits 2-3 lines above the pulls in C5, so C5 is unblocked.Do first: a dry run can discard commits
pullRepo(src/utils/git.ts:312-352) falls back togit reset --hard origin/<b>whenpull --ff-onlyfails. C1 and C5 call it before their guard.Fix rule
C. Writes before the guard (open)
removepullReporemove.ts:77,saveStateForScope:98; self mode force-removes and recreatesknowledge-wtpublishedNameFor:136reads them back); self mode reads the checkout's.teamaisource add~/.teamai/sources/<name>/repo(source.ts:149), or pulls itpublicSkillswarning needs the clone; say it was skipped)import --from-repo,--from-repo-list,--from-orgclone.ts:162), orreset --hardit (:318-338); import lock; LLM scan;--from-orgreads a stale whitelist filels-remote, compare with LAST_SYNC, "Would import ...";--from-orgkeeps the list in memorymodels switchloadValuesFormodels-cmd.ts:154saves re-bound keys{ dryRun }(:154,:370); rebase on #895roles init/add/remove/update,projects add/update/removepullLatest(manifest-edit.ts:9) before each guard; self mode creates a worktreeorigin/<b>(getFileContentAtRev); self mode reads main; land after #901statsresolveConfigForDirper event dir (dashboard-scope.ts:50,103,session-owners.ts:82,357); seedssession-owners.jsonl; refreshes the reports checkoutresolveConfigForDir(dir, options), scope helpers always{ dryRun: true }; no seed on readrecall maintenance(all modes),recall promoteloadUserVotesrewrites every v1 votes file (votes.ts:199-202)readUserVotesfor these pathssource browsesource.ts:343)--dry-run: read the cache, or "Would clone ..."recall <query> --dry-runrecall.ts:649-653, deliberate), index rebuild saved (:330-390)pull(self mode)refreshTeamReporunsmigrateSelfModeGitignore, rewriting the tracked.teamai/.gitignoreabove every guardoptions, self-heal only on a real run (#944)D. Ignores the flag
projects setprojects-cmd.ts:82-128exclude add/removeexclude.ts:47-80recall enable/disablerecall-toggle.ts:125-141initinit.tshooks injecthooks-cmd.ts:97-125reconcileTeamHooksForConfigtakesdryRunmcp removemcp-cmd.ts:116-124reconcileMcpForConfig(..., { removeAll, dryRun })review --apply/--reject/--all-applyreview-cmd.ts:152,169,232updateupdate.ts:672-686--checkrecall feedbackvotes.ts:435models add/configure/removeindex.tsactions skipprogram.opts()bind-projectwebhook testhooks remove,codebase --extract,init --httpOut of scope: hidden commands called only by hooks (
hook-dispatch,track,source reconcile-plugins, ...);env(#880).After everything lands
A guard in the
preActionhook that refuses--dry-runon any command not on an allowlist, so a new command can't silently run for real:Reproduction
teamai recall maintenance --prune --dry-runwith a prunable learning: it is deleted (B).teamai hooks inject --dry-run: tool settings change (D).teamai roles add x --namespaces x --dry-runon a clone with diverged local commits:pullReporesets it (C5).Environment
mainat 417e704Logs
How this was found
B was reproduced with the real CLI (see #903). The C and D entries come from reading the code at 417e704. They were not each run.