You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
--dry-run is documented as "Preview mode, no changes made", and #837 made that true for tags subscribe, tags unsubscribe and roles set. It is still not true for pull, push and status: the scope-detection block they run before any dry-run guard calls config loaders that were never given { dryRun }, so a preview still persists the legacy role migration — and, in a git repo, can still adopt a pre-#546 partition and run the single-repo self-heal bootstrap.
pull --dry-run, push --dry-run and status reach the same writes through loaders that do not pass { dryRun } (loadLocalConfigForScope, and autoDetectInit/detectProjectConfig without options). They are outside #836 and are not changed here. Callers that pass nothing behave as before.
All line numbers and code below were re-checked on main @ e0bf2e96 (blobs: src/config.ts04cc88c9, src/pull.ts2faf8492, src/push.ts0ad93fd7, src/__tests__/dry-run-load-path.test.ts247a79a9).
Root cause
#837 threaded LoadOptions into loadLocalConfig, requireInit, autoDetectInit, detectProjectConfig, resolvePartitionDir, readConfigFrom and migrateLegacyRoleConfig. loadLocalConfigForScope was missed, and it calls them bare.
src/config.ts:229 — no options parameter, three bare calls:
exportasyncfunctionloadLocalConfigForScope(scope: Scope,projectRoot?: string,): Promise<LocalConfig|null>{if(scope==='project'){if(!projectRoot)returnnull;constdetected=awaitdetectProjectConfig(projectRoot);// :237 no optionsif(!detected)returnnull;returnmigrateLegacyRoleConfig(detected,path.join(getDataHome(detected),'config.yaml'));// :239 no options}constconfigPath=getConfigPath(scope,projectRoot);constcontent=awaitreadFileSafe(expandHome(configPath));if(!content)returnnull;try{constraw=YAML.parse(content);constparsed=LocalConfigSchema.parse(raw);returnawaitmigrateLegacyRoleConfig(parsed,configPath);// :247 no options}catch(e){…}}
Callers that run under a dry run:
Caller
Line
Call
Note
src/pull.ts
1885
detectProjectConfig(undefined, sink)
options not passed
src/pull.ts
1914
loadLocalConfigForScope('user')
the loader takes no options at all
src/push.ts
731
autoDetectInit()
options not passed
src/status.ts
46 (and list, :252)
autoDetectInit()
status(options) already has options in hand
pull() starts at pull.ts:1817. Every options.dryRun guard in that file is later (:1071, :1099, :1119, :1177, …), so the scope-detection block at ~1878–1939 runs first, unguarded.
Bare calls mean options = {}, so inside the loaders:
migrateLegacyRoleConfig skips its if (options.dryRun) return migrated early-out and writesconfig.yaml.
resolvePartitionDir (utils/partition.ts:219) skips its dry-run branch (:223) and runs adoptLegacyPartition → fs.promises.rename(legacyDir, canonical) (:237) plus the repo.localPath rewrite that follows.
selfHealAndReadPartition (config.ts:470) skips if (options.dryRun) return previewSelfHeal(…) (:476) and runs the real bootstrapSelfRepo (:479) — partition config, state, anchor, tool dirs, hooks, git worktree add, member registration and its push, plus the provider auth calls previewSelfBootstrap deliberately avoids.
The plumbing itself already exists — autoDetectInit(cwd?, options = {}) (config.ts:642) forwards options to detectProjectConfig (:643). These commands simply do not supply their flag.
Reproduction — real CLI, single-variable control
CLI built from unmodified main (npm run build, node dist/index.js). One fixture — an isolated HOME holding a role-less ~/.teamai/config.yaml next to a team repo whose manifest/roles.yaml declares hai — and the only variable is the command. Tree compared by sha256 over every file, the same method as dry-run-load-path.test.ts.
Command
Files changed
Output
tags subscribe testing --dry-run (fixed by #837 — control)
0
ℹ [dry-run] Would migrate legacy teamai config to default role profile: hai ℹ [dry-run] Would subscribe to: testing
pull --dry-run
1 — ~/.teamai/config.yaml rewritten
ℹ Migrated legacy teamai config to default role profile: hai
~/.teamai/config.yaml after pull --dry-run gains primaryRole: hai, resourceProfileVersion: 1, scope: user and additionalRoles: []. The control command writes nothing, so the harness is not a false negative — it does see writes, and this one is real.
Evidence level. The user-scope write above is run and observed. The project-scope writes in a git repo (partition adoption, self-heal bootstrap) are located by code path from the same unguarded calls — quoted line by line above, not yet run. I will run them before opening a PR.
Proposed fix
Same shape as #837, applied to the loader it missed:
pull.ts:1885 and pull.ts:1914 pass { dryRun: options.dryRun }; push.ts:731 passes it to autoDetectInit.
status.ts:46 and status.ts:252: status and list are read-only, so rather than reading the global flag they would pass { dryRun: true } unconditionally — a read command should never migrate, adopt or bootstrap. Happy to use a narrower option name if you prefer one.
Extend src/__tests__/dry-run-load-path.test.ts. COMMANDS is at :142–:146; the three fixtures (:136), the provider-call recorder (:10–:24) and the updateReports spy (:27–:30) already exist. pull and push fit the existing row shape — pull emits [<scope>] [dry-run] Would … (pull.ts:1071, :1099, :1119, :1177), so the shared assertion holds. status needs a second assertion: it prints no [dry-run] Would line today, so its row should assert the absence of writes, not a message.
Scope
In: the loader's { dryRun } threading and the three commands; the test matrix.
Out:init's call sites (it has no --dry-run to honour), and any change to what a real, non-dry run writes.
For status: pass { dryRun: true } unconditionally, or would you rather have a distinct read-only option?
When a read-only command does not write, should it still print a [dry-run] Would bootstrap … preview line for the bootstrap it declined to run, or stay silent?
Summary
--dry-runis documented as "Preview mode, no changes made", and #837 made that true fortags subscribe,tags unsubscribeandroles set. It is still not true forpull,pushandstatus: the scope-detection block they run before any dry-run guard calls config loaders that were never given{ dryRun }, so a preview still persists the legacy role migration — and, in a git repo, can still adopt a pre-#546 partition and run the single-repo self-heal bootstrap.#837 named exactly this and left it out:
All line numbers and code below were re-checked on
main@e0bf2e96(blobs:src/config.ts04cc88c9,src/pull.ts2faf8492,src/push.ts0ad93fd7,src/__tests__/dry-run-load-path.test.ts247a79a9).Root cause
#837 threaded
LoadOptionsintoloadLocalConfig,requireInit,autoDetectInit,detectProjectConfig,resolvePartitionDir,readConfigFromandmigrateLegacyRoleConfig.loadLocalConfigForScopewas missed, and it calls them bare.src/config.ts:229— nooptionsparameter, three bare calls:Callers that run under a dry run:
src/pull.tsdetectProjectConfig(undefined, sink)src/pull.tsloadLocalConfigForScope('user')src/push.tsautoDetectInit()src/status.tslist, :252)autoDetectInit()status(options)already hasoptionsin handpull()starts atpull.ts:1817. Everyoptions.dryRunguard in that file is later (:1071,:1099,:1119,:1177, …), so the scope-detection block at ~1878–1939 runs first, unguarded.Bare calls mean
options = {}, so inside the loaders:migrateLegacyRoleConfigskips itsif (options.dryRun) return migratedearly-out and writesconfig.yaml.resolvePartitionDir(utils/partition.ts:219) skips its dry-run branch (:223) and runsadoptLegacyPartition→fs.promises.rename(legacyDir, canonical)(:237) plus therepo.localPathrewrite that follows.selfHealAndReadPartition(config.ts:470) skipsif (options.dryRun) return previewSelfHeal(…)(:476) and runs the realbootstrapSelfRepo(:479) — partition config, state, anchor, tool dirs, hooks,git worktree add, member registration and its push, plus the provider auth callspreviewSelfBootstrapdeliberately avoids.The plumbing itself already exists —
autoDetectInit(cwd?, options = {})(config.ts:642) forwardsoptionstodetectProjectConfig(:643). These commands simply do not supply their flag.Reproduction — real CLI, single-variable control
CLI built from unmodified
main(npm run build,node dist/index.js). One fixture — an isolatedHOMEholding a role-less~/.teamai/config.yamlnext to a team repo whosemanifest/roles.yamldeclareshai— and the only variable is the command. Tree compared by sha256 over every file, the same method asdry-run-load-path.test.ts.tags subscribe testing --dry-run(fixed by #837 — control)ℹ [dry-run] Would migrate legacy teamai config to default role profile: haiℹ [dry-run] Would subscribe to: testingpull --dry-run~/.teamai/config.yamlrewrittenℹ Migrated legacy teamai config to default role profile: hai~/.teamai/config.yamlafterpull --dry-rungainsprimaryRole: hai,resourceProfileVersion: 1,scope: userandadditionalRoles: []. The control command writes nothing, so the harness is not a false negative — it does see writes, and this one is real.Evidence level. The user-scope write above is run and observed. The project-scope writes in a git repo (partition adoption, self-heal bootstrap) are located by code path from the same unguarded calls — quoted line by line above, not yet run. I will run them before opening a PR.
Proposed fix
Same shape as #837, applied to the loader it missed:
loadLocalConfigForScope(scope, projectRoot?, options: LoadOptions = {})— passoptionsintodetectProjectConfigand bothmigrateLegacyRoleConfigcalls. Callers that pass nothing behave as before, the same compatibility promise fix(tags,roles): honor --dry-run and count namespaced skills in tags list (#836) #837 made.pull.ts:1885andpull.ts:1914pass{ dryRun: options.dryRun };push.ts:731passes it toautoDetectInit.status.ts:46andstatus.ts:252:statusandlistare read-only, so rather than reading the global flag they would pass{ dryRun: true }unconditionally — a read command should never migrate, adopt or bootstrap. Happy to use a narrower option name if you prefer one.src/__tests__/dry-run-load-path.test.ts.COMMANDSis at:142–:146; the three fixtures (:136), the provider-call recorder (:10–:24) and theupdateReportsspy (:27–:30) already exist.pullandpushfit the existing row shape —pullemits[<scope>] [dry-run] Would …(pull.ts:1071,:1099,:1119,:1177), so the shared assertion holds.statusneeds a second assertion: it prints no[dry-run] Wouldline today, so its row should assert the absence of writes, not a message.Scope
{ dryRun }threading and the three commands; the test matrix.init's call sites (it has no--dry-runto honour), and any change to what a real, non-dry run writes.requireInitForScope(config.ts:625),recall.ts:513,contribute.ts:197,save-session.ts:102. They may be affected the same way; I would keep this change to the three commands fix(tags,roles): honor --dry-run and count namespaced skills in tags list (#836) #837 named unless you want them swept in.Questions
status: pass{ dryRun: true }unconditionally, or would you rather have a distinct read-only option?[dry-run] Would bootstrap …preview line for the bootstrap it declined to run, or stay silent?Happy to take it if it is not already spoken for.