@@ -47,16 +47,42 @@ class UpgradeResult:
4747]
4848
4949
50+ def _get_env_and_ctx (
51+ config : ProjectConfig ,
52+ ) -> tuple [Environment , dict [str , object ]]:
53+ """Create Jinja env and template context from config."""
54+ from specsmith .tools import get_tools
55+
56+ env = Environment (
57+ loader = PackageLoader ("specsmith" , "templates" ),
58+ autoescape = select_autoescape ([]),
59+ keep_trailing_newline = True ,
60+ trim_blocks = True ,
61+ lstrip_blocks = True ,
62+ )
63+ ctx : dict [str , object ] = {
64+ "project" : config ,
65+ "today" : date .today ().isoformat (),
66+ "package_name" : config .package_name ,
67+ "tools" : get_tools (config ),
68+ }
69+ return env , ctx
70+
71+
5072def run_upgrade (
5173 root : Path ,
5274 * ,
5375 target_version : str | None = None ,
76+ full : bool = False ,
5477) -> UpgradeResult :
5578 """Upgrade governance files to a newer spec version.
5679
5780 Args:
5881 root: Project root directory.
5982 target_version: Target spec version. If None, uses the current specsmith version.
83+ full: If True, also regenerate exec shims, agent integrations, CI configs,
84+ and create missing community/RTD files. Safe: never overwrites
85+ AGENTS.md, LEDGER.md, REQUIREMENTS.md, TEST_SPEC.md, or user docs.
6086
6187 Returns:
6288 UpgradeResult with details of the operation.
@@ -79,41 +105,22 @@ def run_upgrade(
79105 new_version = target_version or __version__
80106 old_version = config .spec_version
81107
82- if old_version == new_version :
108+ # For --full, allow syncing even when version matches
109+ if old_version == new_version and not full :
83110 return UpgradeResult (message = f"Already at spec version { new_version } . Nothing to upgrade." )
84111
85- # Update config
86112 config .spec_version = new_version
87-
88- env = Environment (
89- loader = PackageLoader ("specsmith" , "templates" ),
90- autoescape = select_autoescape ([]),
91- keep_trailing_newline = True ,
92- trim_blocks = True ,
93- lstrip_blocks = True ,
94- )
95-
96- from specsmith .tools import get_tools
97-
98- ctx = {
99- "project" : config ,
100- "today" : date .today ().isoformat (),
101- "package_name" : config .package_name ,
102- "tools" : get_tools (config ),
103- }
113+ env , ctx = _get_env_and_ctx (config )
104114
105115 result = UpgradeResult ()
106116
107117 # Migrate legacy lowercase filenames to uppercase
108118 _migrate_legacy_filenames (root , result )
109119
120+ # Regenerate governance templates (always overwritten — they're spec-managed)
110121 for template_name , output_rel in _GOVERNANCE_TEMPLATES :
111122 output_path = root / output_rel
112-
113- if not output_path .exists ():
114- result .skipped_files .append (output_rel )
115- continue
116-
123+ output_path .parent .mkdir (parents = True , exist_ok = True )
117124 tmpl = env .get_template (template_name )
118125 content = tmpl .render (** ctx )
119126 output_path .write_text (content , encoding = "utf-8" )
@@ -133,6 +140,10 @@ def run_upgrade(
133140 save_budget (root , CreditBudget ())
134141 result .updated_files .append (".specsmith/credit-budget.json" )
135142
143+ # Full sync: regenerate shims, CI, agent files, create missing community files
144+ if full :
145+ result .updated_files .extend (_sync_full (root , config , env , ctx ))
146+
136147 result .message = (
137148 f"Upgraded from { old_version } to { new_version } . "
138149 f"{ len (result .updated_files )} files updated, { len (result .skipped_files )} skipped."
@@ -141,6 +152,103 @@ def run_upgrade(
141152 return result
142153
143154
155+ # Files that are NEVER overwritten by --full sync (user-owned content)
156+ _USER_OWNED : set [str ] = {
157+ "AGENTS.md" ,
158+ "LEDGER.md" ,
159+ "README.md" ,
160+ "docs/REQUIREMENTS.md" ,
161+ "docs/TEST_SPEC.md" ,
162+ "docs/ARCHITECTURE.md" ,
163+ "docs/WORKFLOW.md" ,
164+ }
165+
166+
167+ def _sync_full (
168+ root : Path ,
169+ config : ProjectConfig ,
170+ env : Environment ,
171+ ctx : dict [str , object ],
172+ ) -> list [str ]:
173+ """Full sync: regenerate infrastructure files, create missing community files.
174+
175+ Safe rules:
176+ - User-owned docs (AGENTS.md, LEDGER.md, etc.) are NEVER touched
177+ - Exec shims are ALWAYS regenerated (they carry security/abort logic)
178+ - CI configs are regenerated (tool-aware, reflects current specsmith version)
179+ - Agent integrations are regenerated
180+ - Community/RTD files are created only if missing
181+ """
182+ synced : list [str ] = []
183+
184+ from specsmith .scaffolder import _build_community_files
185+
186+ # 1. Exec shims — always regenerate (carries PID tracking / abort fixes)
187+ shim_templates = [
188+ ("scripts/exec.cmd.j2" , "scripts/exec.cmd" ),
189+ ("scripts/exec.sh.j2" , "scripts/exec.sh" ),
190+ ("scripts/setup.cmd.j2" , "scripts/setup.cmd" ),
191+ ("scripts/setup.sh.j2" , "scripts/setup.sh" ),
192+ ("scripts/run.cmd.j2" , "scripts/run.cmd" ),
193+ ("scripts/run.sh.j2" , "scripts/run.sh" ),
194+ ]
195+ for tmpl_name , output_rel in shim_templates :
196+ out = root / output_rel
197+ out .parent .mkdir (parents = True , exist_ok = True )
198+ tmpl = env .get_template (tmpl_name )
199+ out .write_text (tmpl .render (** ctx ), encoding = "utf-8" )
200+ synced .append (output_rel )
201+
202+ # 2. Agent integrations — regenerate
203+ for integration_name in config .integrations :
204+ if integration_name == "agents-md" :
205+ continue
206+ try :
207+ from specsmith .integrations import get_adapter
208+
209+ adapter = get_adapter (integration_name )
210+ files = adapter .generate (config , root )
211+ for f in files :
212+ synced .append (str (f .relative_to (root )))
213+ except ValueError :
214+ pass
215+
216+ # 3. VCS CI configs — regenerate
217+ if config .vcs_platform :
218+ try :
219+ from specsmith .vcs import get_platform
220+
221+ platform = get_platform (config .vcs_platform )
222+ files = platform .generate_all (config , root )
223+ for f in files :
224+ synced .append (str (f .relative_to (root )))
225+ except ValueError :
226+ pass
227+
228+ # 4. Community files — create only if missing
229+ for tmpl_name , output_rel in _build_community_files (config ):
230+ out = root / output_rel
231+ if not out .exists ():
232+ out .parent .mkdir (parents = True , exist_ok = True )
233+ tmpl = env .get_template (tmpl_name )
234+ out .write_text (tmpl .render (** ctx ), encoding = "utf-8" )
235+ synced .append (f"{ output_rel } (created)" )
236+
237+ # 5. Config files — create only if missing (.editorconfig, .gitattributes)
238+ config_templates = [
239+ ("editorconfig.j2" , ".editorconfig" ),
240+ ("gitattributes.j2" , ".gitattributes" ),
241+ ]
242+ for tmpl_name , output_rel in config_templates :
243+ out = root / output_rel
244+ if not out .exists ():
245+ tmpl = env .get_template (tmpl_name )
246+ out .write_text (tmpl .render (** ctx ), encoding = "utf-8" )
247+ synced .append (f"{ output_rel } (created)" )
248+
249+ return synced
250+
251+
144252def _migrate_legacy_filenames (root : Path , result : UpgradeResult ) -> None :
145253 """Rename legacy lowercase governance files to uppercase.
146254
0 commit comments