spawn-cli 0.1.8__tar.gz → 0.1.10__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {spawn_cli-0.1.8/src/spawn_cli.egg-info → spawn_cli-0.1.10}/PKG-INFO +3 -3
- {spawn_cli-0.1.8 → spawn_cli-0.1.10}/README.md +2 -2
- {spawn_cli-0.1.8 → spawn_cli-0.1.10}/pyproject.toml +1 -1
- {spawn_cli-0.1.8 → spawn_cli-0.1.10}/src/spawn_cli/cli.py +2 -2
- {spawn_cli-0.1.8 → spawn_cli-0.1.10}/src/spawn_cli/core/high_level.py +77 -24
- {spawn_cli-0.1.8 → spawn_cli-0.1.10}/src/spawn_cli/core/low_level.py +242 -20
- {spawn_cli-0.1.8 → spawn_cli-0.1.10}/src/spawn_cli/ide/__init__.py +8 -0
- {spawn_cli-0.1.8 → spawn_cli-0.1.10}/src/spawn_cli/ide/_helpers.py +166 -2
- {spawn_cli-0.1.8 → spawn_cli-0.1.10}/src/spawn_cli/ide/claude_code.py +6 -0
- {spawn_cli-0.1.8 → spawn_cli-0.1.10}/src/spawn_cli/ide/cursor.py +16 -2
- {spawn_cli-0.1.8 → spawn_cli-0.1.10}/src/spawn_cli/ide/gemini_cli.py +17 -3
- {spawn_cli-0.1.8 → spawn_cli-0.1.10}/src/spawn_cli/ide/registry.py +17 -0
- {spawn_cli-0.1.8 → spawn_cli-0.1.10}/src/spawn_cli/ide/windsurf.py +17 -3
- {spawn_cli-0.1.8 → spawn_cli-0.1.10}/src/spawn_cli/models/config.py +10 -0
- {spawn_cli-0.1.8 → spawn_cli-0.1.10}/src/spawn_cli/models/skill.py +1 -0
- {spawn_cli-0.1.8 → spawn_cli-0.1.10/src/spawn_cli.egg-info}/PKG-INFO +3 -3
- {spawn_cli-0.1.8 → spawn_cli-0.1.10}/LICENSE +0 -0
- {spawn_cli-0.1.8 → spawn_cli-0.1.10}/setup.cfg +0 -0
- {spawn_cli-0.1.8 → spawn_cli-0.1.10}/src/spawn_cli/__init__.py +0 -0
- {spawn_cli-0.1.8 → spawn_cli-0.1.10}/src/spawn_cli/core/__init__.py +0 -0
- {spawn_cli-0.1.8 → spawn_cli-0.1.10}/src/spawn_cli/core/download.py +0 -0
- {spawn_cli-0.1.8 → spawn_cli-0.1.10}/src/spawn_cli/core/errors.py +0 -0
- {spawn_cli-0.1.8 → spawn_cli-0.1.10}/src/spawn_cli/core/scripts.py +0 -0
- {spawn_cli-0.1.8 → spawn_cli-0.1.10}/src/spawn_cli/errors.py +0 -0
- {spawn_cli-0.1.8 → spawn_cli-0.1.10}/src/spawn_cli/ide/_stub.py +0 -0
- {spawn_cli-0.1.8 → spawn_cli-0.1.10}/src/spawn_cli/ide/_vacancy.py +0 -0
- {spawn_cli-0.1.8 → spawn_cli-0.1.10}/src/spawn_cli/ide/codex.py +0 -0
- {spawn_cli-0.1.8 → spawn_cli-0.1.10}/src/spawn_cli/ide/github_copilot.py +0 -0
- {spawn_cli-0.1.8 → spawn_cli-0.1.10}/src/spawn_cli/io/__init__.py +0 -0
- {spawn_cli-0.1.8 → spawn_cli-0.1.10}/src/spawn_cli/io/json_io.py +0 -0
- {spawn_cli-0.1.8 → spawn_cli-0.1.10}/src/spawn_cli/io/lock.py +0 -0
- {spawn_cli-0.1.8 → spawn_cli-0.1.10}/src/spawn_cli/io/paths.py +0 -0
- {spawn_cli-0.1.8 → spawn_cli-0.1.10}/src/spawn_cli/io/text_io.py +0 -0
- {spawn_cli-0.1.8 → spawn_cli-0.1.10}/src/spawn_cli/io/toml_io.py +0 -0
- {spawn_cli-0.1.8 → spawn_cli-0.1.10}/src/spawn_cli/io/yaml_io.py +0 -0
- {spawn_cli-0.1.8 → spawn_cli-0.1.10}/src/spawn_cli/models/__init__.py +0 -0
- {spawn_cli-0.1.8 → spawn_cli-0.1.10}/src/spawn_cli/models/mcp.py +0 -0
- {spawn_cli-0.1.8 → spawn_cli-0.1.10}/src/spawn_cli/models/metadata.py +0 -0
- {spawn_cli-0.1.8 → spawn_cli-0.1.10}/src/spawn_cli/models/navigation.py +0 -0
- {spawn_cli-0.1.8 → spawn_cli-0.1.10}/src/spawn_cli/resources/__init__.py +0 -0
- {spawn_cli-0.1.8 → spawn_cli-0.1.10}/src/spawn_cli/resources/default_core_config.yaml +0 -0
- {spawn_cli-0.1.8 → spawn_cli-0.1.10}/src/spawn_cli/warnings_display.py +0 -0
- {spawn_cli-0.1.8 → spawn_cli-0.1.10}/src/spawn_cli.egg-info/SOURCES.txt +0 -0
- {spawn_cli-0.1.8 → spawn_cli-0.1.10}/src/spawn_cli.egg-info/dependency_links.txt +0 -0
- {spawn_cli-0.1.8 → spawn_cli-0.1.10}/src/spawn_cli.egg-info/entry_points.txt +0 -0
- {spawn_cli-0.1.8 → spawn_cli-0.1.10}/src/spawn_cli.egg-info/requires.txt +0 -0
- {spawn_cli-0.1.8 → spawn_cli-0.1.10}/src/spawn_cli.egg-info/top_level.txt +0 -0
- {spawn_cli-0.1.8 → spawn_cli-0.1.10}/tests/test_cli.py +0 -0
- {spawn_cli-0.1.8 → spawn_cli-0.1.10}/tests/test_warnings_display.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: spawn-cli
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.10
|
|
4
4
|
Summary: A minimal installable Python CLI package.
|
|
5
5
|
License: MIT
|
|
6
6
|
Classifier: License :: OSI Approved :: MIT License
|
|
@@ -27,7 +27,7 @@ Spawn is a framework for building **AIDD** (AI-driven development) methodologies
|
|
|
27
27
|
|
|
28
28
|
Several methodologies in one repo stay aligned because Spawn maintains **one shared navigation index**: [`spawn/navigation.yaml`](spec/design/data-structure.md) aggregates mandatory and contextual reads from **every installed extension** plus local [`spawn/rules/`](spec/design/user-guide.md) (refreshed with `spawn rules refresh`). Agents are steered through that file instead of chasing disconnected README fragments.
|
|
29
29
|
|
|
30
|
-
On each install or refresh, the CLI **regenerates IDE-facing artifacts** so skills and entry points stay consistent across extensions: rendered skill bodies include **paths and descriptions** wired from navigation (required reads, contextual hints, rule files), MCP snippets normalize server definitions per IDE adapter, and ignore lists merge without manual copy-paste. The net effect is that methodology files, generated skill links, and repo rules **compose** rather than contradict each other.
|
|
30
|
+
On each install or refresh, the CLI **regenerates IDE-facing artifacts** so skills and entry points stay consistent across extensions: rendered skill bodies include **paths and descriptions** wired from navigation (required reads, contextual hints, rule files), MCP snippets normalize server definitions per IDE adapter, and ignore lists merge without manual copy-paste. The net effect is that methodology files, generated skill links, and repo rules **compose** rather than contradict each other. Optional `hints` add short reminders to rendered skills and the Spawn-managed entry block.
|
|
31
31
|
|
|
32
32
|
**Example:** [**spawn-ext-spectask**](https://github.com/noant/spawn-ext-spectask) (Spec-Tasks methodology: spec layout, skills, design index) and [**spawn-ext-creator**](https://github.com/noant/spawn-ext-creator) (authoring flows, including agent-guided extension bootstrap) can be installed in the **same** repository and still share that single index and merged IDE outputs.
|
|
33
33
|
|
|
@@ -189,7 +189,7 @@ Read **[spec/design/extension-author-guide.md](spec/design/extension-author-guid
|
|
|
189
189
|
|
|
190
190
|
**Minimal mental model:**
|
|
191
191
|
|
|
192
|
-
- `**extsrc/config.yaml`** — `schema`, `version`, stable `**name`** (install path `spawn/.extend/<name>/`), plus `files`, `skills`, ignores, etc.
|
|
192
|
+
- `**extsrc/config.yaml`** — `schema`, `version`, stable `**name`** (install path `spawn/.extend/<name>/`), plus `files`, `skills`, ignores, optional `hints`, etc.
|
|
193
193
|
- `**extsrc/files/**` — template tree mirrored into the target repo according to `files:` entries.
|
|
194
194
|
- `**spawn extension check . --strict**` — validate before publishing or tagging a release.
|
|
195
195
|
|
|
@@ -10,7 +10,7 @@ Spawn is a framework for building **AIDD** (AI-driven development) methodologies
|
|
|
10
10
|
|
|
11
11
|
Several methodologies in one repo stay aligned because Spawn maintains **one shared navigation index**: [`spawn/navigation.yaml`](spec/design/data-structure.md) aggregates mandatory and contextual reads from **every installed extension** plus local [`spawn/rules/`](spec/design/user-guide.md) (refreshed with `spawn rules refresh`). Agents are steered through that file instead of chasing disconnected README fragments.
|
|
12
12
|
|
|
13
|
-
On each install or refresh, the CLI **regenerates IDE-facing artifacts** so skills and entry points stay consistent across extensions: rendered skill bodies include **paths and descriptions** wired from navigation (required reads, contextual hints, rule files), MCP snippets normalize server definitions per IDE adapter, and ignore lists merge without manual copy-paste. The net effect is that methodology files, generated skill links, and repo rules **compose** rather than contradict each other.
|
|
13
|
+
On each install or refresh, the CLI **regenerates IDE-facing artifacts** so skills and entry points stay consistent across extensions: rendered skill bodies include **paths and descriptions** wired from navigation (required reads, contextual hints, rule files), MCP snippets normalize server definitions per IDE adapter, and ignore lists merge without manual copy-paste. The net effect is that methodology files, generated skill links, and repo rules **compose** rather than contradict each other. Optional `hints` add short reminders to rendered skills and the Spawn-managed entry block.
|
|
14
14
|
|
|
15
15
|
**Example:** [**spawn-ext-spectask**](https://github.com/noant/spawn-ext-spectask) (Spec-Tasks methodology: spec layout, skills, design index) and [**spawn-ext-creator**](https://github.com/noant/spawn-ext-creator) (authoring flows, including agent-guided extension bootstrap) can be installed in the **same** repository and still share that single index and merged IDE outputs.
|
|
16
16
|
|
|
@@ -172,7 +172,7 @@ Read **[spec/design/extension-author-guide.md](spec/design/extension-author-guid
|
|
|
172
172
|
|
|
173
173
|
**Minimal mental model:**
|
|
174
174
|
|
|
175
|
-
- `**extsrc/config.yaml`** — `schema`, `version`, stable `**name`** (install path `spawn/.extend/<name>/`), plus `files`, `skills`, ignores, etc.
|
|
175
|
+
- `**extsrc/config.yaml`** — `schema`, `version`, stable `**name`** (install path `spawn/.extend/<name>/`), plus `files`, `skills`, ignores, optional `hints`, etc.
|
|
176
176
|
- `**extsrc/files/**` — template tree mirrored into the target repo according to `files:` entries.
|
|
177
177
|
- `**spawn extension check . --strict**` — validate before publishing or tagging a release.
|
|
178
178
|
|
|
@@ -44,8 +44,8 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
44
44
|
"refresh",
|
|
45
45
|
help="Sync core config from defaults and rebuild IDE renders",
|
|
46
46
|
description=(
|
|
47
|
-
"
|
|
48
|
-
"agent ignore, navigation, gitignore metadata, and IDE entry points."
|
|
47
|
+
"Replace spawn/.core/config.yaml with bundled default core config, then refresh "
|
|
48
|
+
"skills, MCP, agent ignore, navigation, gitignore metadata, and IDE entry points."
|
|
49
49
|
),
|
|
50
50
|
)
|
|
51
51
|
|
|
@@ -63,12 +63,9 @@ def _extend_dir(target_root: Path, extension: str) -> Path:
|
|
|
63
63
|
|
|
64
64
|
|
|
65
65
|
def _agent_ignore_merge_excluding(target_root: Path, skip_ext: str) -> list[str]:
|
|
66
|
+
"""Merged extension ``agent-ignore`` only, omitting *skip_ext*."""
|
|
66
67
|
merged: list[str] = []
|
|
67
68
|
seen: set[str] = set()
|
|
68
|
-
for g in ll.get_core_agent_ignore(target_root):
|
|
69
|
-
if g not in seen:
|
|
70
|
-
seen.add(g)
|
|
71
|
-
merged.append(g)
|
|
72
69
|
for ext in ll.list_extensions(target_root):
|
|
73
70
|
if ext == skip_ext:
|
|
74
71
|
continue
|
|
@@ -79,6 +76,48 @@ def _agent_ignore_merge_excluding(target_root: Path, skip_ext: str) -> list[str]
|
|
|
79
76
|
return merged
|
|
80
77
|
|
|
81
78
|
|
|
79
|
+
def _sync_project_agent_ignore_permissions(target_root: Path, ide: str) -> None:
|
|
80
|
+
old = ll.get_agent_ignore_list(target_root, ide)
|
|
81
|
+
new = ll.get_all_agent_ignore(target_root)
|
|
82
|
+
adapter = ide_get(ide)
|
|
83
|
+
to_remove = sorted(set(old) - set(new))
|
|
84
|
+
to_add = sorted(set(new) - set(old))
|
|
85
|
+
if to_remove:
|
|
86
|
+
adapter.remove_agent_ignore(target_root, to_remove)
|
|
87
|
+
if to_add:
|
|
88
|
+
adapter.add_agent_ignore(target_root, to_add)
|
|
89
|
+
ll.save_agent_ignore_list(target_root, ide, new)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def refresh_core_agent_ignore(target_root: Path, ide: str) -> None:
|
|
93
|
+
_require_init(target_root)
|
|
94
|
+
adapter = ide_get(ide)
|
|
95
|
+
cap = adapter.detect(target_root).capabilities.agent_ignore
|
|
96
|
+
core = ll.get_core_agent_ignore(target_root)
|
|
97
|
+
if cap == "native":
|
|
98
|
+
adapter.rewrite_core_agent_ignore(target_root, core)
|
|
99
|
+
elif cap == "project":
|
|
100
|
+
_sync_project_agent_ignore_permissions(target_root, ide)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def refresh_extension_agent_ignore(target_root: Path, ide: str) -> None:
|
|
104
|
+
_require_init(target_root)
|
|
105
|
+
adapter = ide_get(ide)
|
|
106
|
+
cap = adapter.detect(target_root).capabilities.agent_ignore
|
|
107
|
+
ext = ll.get_merged_extension_agent_ignore(target_root)
|
|
108
|
+
if cap == "native":
|
|
109
|
+
adapter.rewrite_extension_agent_ignore(target_root, ext)
|
|
110
|
+
ll.save_agent_ignore_list(target_root, ide, ext)
|
|
111
|
+
elif cap == "project":
|
|
112
|
+
_sync_project_agent_ignore_permissions(target_root, ide)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def refresh_agent_ignore(target_root: Path, ide: str) -> None:
|
|
116
|
+
_require_init(target_root)
|
|
117
|
+
refresh_core_agent_ignore(target_root, ide)
|
|
118
|
+
refresh_extension_agent_ignore(target_root, ide)
|
|
119
|
+
|
|
120
|
+
|
|
82
121
|
def refresh_gitignore(target_root: Path) -> None:
|
|
83
122
|
_require_init(target_root)
|
|
84
123
|
new_items: list[str] = []
|
|
@@ -95,16 +134,6 @@ def refresh_gitignore(target_root: Path) -> None:
|
|
|
95
134
|
ll.remove_from_global_gitignore(target_root, sorted(ex_set - new_set))
|
|
96
135
|
|
|
97
136
|
|
|
98
|
-
def refresh_agent_ignore(target_root: Path, ide: str) -> None:
|
|
99
|
-
_require_init(target_root)
|
|
100
|
-
old = ll.get_agent_ignore_list(target_root, ide)
|
|
101
|
-
new = ll.get_all_agent_ignore(target_root)
|
|
102
|
-
adapter = ide_get(ide)
|
|
103
|
-
adapter.remove_agent_ignore(target_root, sorted(set(old) - set(new)))
|
|
104
|
-
adapter.add_agent_ignore(target_root, sorted(set(new) - set(old)))
|
|
105
|
-
ll.save_agent_ignore_list(target_root, ide, new)
|
|
106
|
-
|
|
107
|
-
|
|
108
137
|
def refresh_skills(target_root: Path, ide: str, extension: str) -> None:
|
|
109
138
|
_require_init(target_root)
|
|
110
139
|
adapter = ide_get(ide)
|
|
@@ -169,7 +198,17 @@ def remove_mcp(target_root: Path, ide: str, extension: str) -> None:
|
|
|
169
198
|
|
|
170
199
|
def refresh_entry_point(target_root: Path, ide: str) -> None:
|
|
171
200
|
_require_init(target_root)
|
|
172
|
-
|
|
201
|
+
rollup = ll.rollup_hints_for_agents(target_root)
|
|
202
|
+
ll.warn_if_agents_hints_exceed_measurement(rollup)
|
|
203
|
+
if rollup:
|
|
204
|
+
prompt = (
|
|
205
|
+
f"{SPAWN_ENTRY_POINT_PROMPT}\nHints:\n"
|
|
206
|
+
+ "\n".join(f"- {h}" for h in rollup)
|
|
207
|
+
+ "\n"
|
|
208
|
+
)
|
|
209
|
+
else:
|
|
210
|
+
prompt = SPAWN_ENTRY_POINT_PROMPT
|
|
211
|
+
ide_get(ide).rewrite_entry_point(target_root, prompt)
|
|
173
212
|
|
|
174
213
|
|
|
175
214
|
def refresh_extension_for_ide(target_root: Path, ide: str, extension: str) -> None:
|
|
@@ -192,17 +231,30 @@ def refresh_extension_for_ide(target_root: Path, ide: str, extension: str) -> No
|
|
|
192
231
|
refresh_mcp(target_root, ide, extension)
|
|
193
232
|
_refresh_skills_all_extensions_for_ide(target_root, ide)
|
|
194
233
|
refresh_agent_ignore(target_root, ide)
|
|
234
|
+
refresh_navigation(target_root)
|
|
195
235
|
|
|
196
236
|
|
|
197
237
|
def remove_extension_for_ide(target_root: Path, ide: str, extension: str) -> None:
|
|
198
238
|
remove_mcp(target_root, ide, extension)
|
|
199
239
|
remove_skills(target_root, ide, extension)
|
|
200
|
-
|
|
201
|
-
new = _agent_ignore_merge_excluding(target_root, extension)
|
|
240
|
+
new_ext = _agent_ignore_merge_excluding(target_root, extension)
|
|
202
241
|
adapter = ide_get(ide)
|
|
203
|
-
adapter.
|
|
204
|
-
|
|
205
|
-
|
|
242
|
+
cap = adapter.detect(target_root).capabilities.agent_ignore
|
|
243
|
+
if cap == "native":
|
|
244
|
+
adapter.rewrite_extension_agent_ignore(target_root, new_ext)
|
|
245
|
+
ll.save_agent_ignore_list(target_root, ide, new_ext)
|
|
246
|
+
elif cap == "project":
|
|
247
|
+
desired = ll.merge_core_and_extension_agent_ignore(
|
|
248
|
+
ll.get_core_agent_ignore(target_root), new_ext
|
|
249
|
+
)
|
|
250
|
+
old = ll.get_agent_ignore_list(target_root, ide)
|
|
251
|
+
to_remove = sorted(set(old) - set(desired))
|
|
252
|
+
to_add = sorted(set(desired) - set(old))
|
|
253
|
+
if to_remove:
|
|
254
|
+
adapter.remove_agent_ignore(target_root, to_remove)
|
|
255
|
+
if to_add:
|
|
256
|
+
adapter.add_agent_ignore(target_root, to_add)
|
|
257
|
+
ll.save_agent_ignore_list(target_root, ide, desired)
|
|
206
258
|
|
|
207
259
|
|
|
208
260
|
def refresh_navigation(target_root: Path) -> None:
|
|
@@ -223,9 +275,9 @@ def refresh_rules_navigation(target_root: Path) -> None:
|
|
|
223
275
|
|
|
224
276
|
|
|
225
277
|
def refresh_repository(target_root: Path) -> None:
|
|
226
|
-
"""
|
|
278
|
+
"""Overwrite core config from bundled defaults, then rebuild all IDE-facing metadata."""
|
|
227
279
|
_require_init(target_root)
|
|
228
|
-
ll.
|
|
280
|
+
ll.sync_core_config_from_defaults(target_root)
|
|
229
281
|
needs_skill = _any_extension_has_skill_files(target_root)
|
|
230
282
|
needs_mcp = _any_extension_has_mcp_servers(target_root)
|
|
231
283
|
for ide in ll.list_ides(target_root):
|
|
@@ -535,8 +587,7 @@ def remove_ide(target_root: Path, ide: str) -> None:
|
|
|
535
587
|
for ext in ll.list_extensions(target_root):
|
|
536
588
|
remove_mcp(target_root, ide, ext)
|
|
537
589
|
remove_skills(target_root, ide, ext)
|
|
538
|
-
|
|
539
|
-
ide_get(ide).remove_agent_ignore(target_root, old)
|
|
590
|
+
ide_get(ide).clear_spawn_agent_ignore(target_root)
|
|
540
591
|
ide_get(ide).finalize_repo_after_ide_removed(target_root)
|
|
541
592
|
ll.remove_ide_from_list(target_root, ide)
|
|
542
593
|
ll.remove_ide_metadata_dir(target_root, ide)
|
|
@@ -560,6 +611,8 @@ __all__ = [
|
|
|
560
611
|
"extension_init",
|
|
561
612
|
"install_extension",
|
|
562
613
|
"refresh_agent_ignore",
|
|
614
|
+
"refresh_core_agent_ignore",
|
|
615
|
+
"refresh_extension_agent_ignore",
|
|
563
616
|
"refresh_entry_point",
|
|
564
617
|
"refresh_extension",
|
|
565
618
|
"refresh_extension_for_ide",
|
|
@@ -130,6 +130,182 @@ def _load_ext_config(target_root: Path, extension: str) -> ExtensionConfig:
|
|
|
130
130
|
return ExtensionConfig.model_validate(raw)
|
|
131
131
|
|
|
132
132
|
|
|
133
|
+
_EXTENSION_NAV_HINT_MAX_CODEPOINTS = 512
|
|
134
|
+
_SKILL_HINT_SECTION_COMBINED_MAX_CHARS = 4096
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _optional_ext_config(target_root: Path, extension: str) -> ExtensionConfig | None:
|
|
138
|
+
path = _config_path(target_root, extension)
|
|
139
|
+
if not path.is_file():
|
|
140
|
+
return None
|
|
141
|
+
raw = load_yaml(path)
|
|
142
|
+
if not raw:
|
|
143
|
+
return None
|
|
144
|
+
return ExtensionConfig.model_validate(raw)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _normalized_extension_global_hints_for_navigation(cfg: ExtensionConfig) -> list[str]:
|
|
148
|
+
"""Strip, drop empties, dedupe (first wins), warn + truncate to codepoint limit for persisted navigation."""
|
|
149
|
+
|
|
150
|
+
raw_list: list[str] = []
|
|
151
|
+
if cfg.hints is not None:
|
|
152
|
+
raw_list.extend(cfg.hints.global_)
|
|
153
|
+
seen: set[str] = set()
|
|
154
|
+
out: list[str] = []
|
|
155
|
+
for h in raw_list:
|
|
156
|
+
s = h.strip()
|
|
157
|
+
if not s:
|
|
158
|
+
continue
|
|
159
|
+
if s in seen:
|
|
160
|
+
continue
|
|
161
|
+
seen.add(s)
|
|
162
|
+
if len(s) > _EXTENSION_NAV_HINT_MAX_CODEPOINTS:
|
|
163
|
+
warnings.warn(
|
|
164
|
+
"Extension navigation hint exceeds 512 Unicode code points and was truncated.",
|
|
165
|
+
SpawnWarning,
|
|
166
|
+
)
|
|
167
|
+
s = s[:_EXTENSION_NAV_HINT_MAX_CODEPOINTS]
|
|
168
|
+
out.append(s)
|
|
169
|
+
return out
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _truncate_hint_codepoints(text: str, max_codepoints: int) -> tuple[str, bool]:
|
|
173
|
+
if len(text) <= max_codepoints:
|
|
174
|
+
return text, False
|
|
175
|
+
return text[:max_codepoints], True
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _merge_hint_streams_ordered(streams: list[list[str]]) -> list[str]:
|
|
179
|
+
"""Dedupe stripped strings with first occurrence winning; earlier streams dominate later ones."""
|
|
180
|
+
|
|
181
|
+
seen: set[str] = set()
|
|
182
|
+
out: list[str] = []
|
|
183
|
+
for stream in streams:
|
|
184
|
+
for h in stream:
|
|
185
|
+
s = h.strip()
|
|
186
|
+
if not s or s in seen:
|
|
187
|
+
continue
|
|
188
|
+
seen.add(s)
|
|
189
|
+
out.append(s)
|
|
190
|
+
return out
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _navigation_read_required_rule_hints_ordered(target_root: Path) -> list[str]:
|
|
194
|
+
"""Plain-text hints from maintainer-authored read-required ``rules`` rows only (YAML order).
|
|
195
|
+
|
|
196
|
+
Hint fields on ``read-contextual`` rules are ignored here.
|
|
197
|
+
"""
|
|
198
|
+
path = _spawn(target_root) / "navigation.yaml"
|
|
199
|
+
raw = load_yaml(path)
|
|
200
|
+
if not isinstance(raw, dict):
|
|
201
|
+
return []
|
|
202
|
+
rr = raw.get("read-required")
|
|
203
|
+
if not isinstance(rr, list):
|
|
204
|
+
return []
|
|
205
|
+
out: list[str] = []
|
|
206
|
+
for grp in rr:
|
|
207
|
+
if not isinstance(grp, dict):
|
|
208
|
+
continue
|
|
209
|
+
rules_raw = grp.get("rules")
|
|
210
|
+
if not isinstance(rules_raw, list):
|
|
211
|
+
continue
|
|
212
|
+
for entry in rules_raw:
|
|
213
|
+
if not isinstance(entry, dict):
|
|
214
|
+
continue
|
|
215
|
+
hint_val = entry.get("hint")
|
|
216
|
+
if isinstance(hint_val, str):
|
|
217
|
+
out.append(hint_val)
|
|
218
|
+
return out
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _finalize_hints_for_skill_metadata(merged_hints: list[str]) -> list[str]:
|
|
222
|
+
"""512 codepoints per hint + 4096 char ``Hints:`` section (terminal ``- ...`` when clipped)."""
|
|
223
|
+
|
|
224
|
+
trimmed: list[str] = []
|
|
225
|
+
for h in merged_hints:
|
|
226
|
+
t, clipped = _truncate_hint_codepoints(h, _EXTENSION_NAV_HINT_MAX_CODEPOINTS)
|
|
227
|
+
if clipped:
|
|
228
|
+
warnings.warn(
|
|
229
|
+
"Skill hint exceeds 512 Unicode code points and was truncated for skill metadata.",
|
|
230
|
+
SpawnWarning,
|
|
231
|
+
)
|
|
232
|
+
trimmed.append(t)
|
|
233
|
+
|
|
234
|
+
header = "Hints:\n"
|
|
235
|
+
warn_combined = (
|
|
236
|
+
"Skill hints truncated to fit combined 4096 character budget for the Hints section; "
|
|
237
|
+
"shorten hints and/or split skills."
|
|
238
|
+
)
|
|
239
|
+
|
|
240
|
+
def build_block(strings: list[str], with_ellipsis: bool) -> str:
|
|
241
|
+
lines = [f"- {s}" for s in strings]
|
|
242
|
+
if with_ellipsis:
|
|
243
|
+
lines.append("- ...")
|
|
244
|
+
if not lines:
|
|
245
|
+
return ""
|
|
246
|
+
return header + "\n".join(lines)
|
|
247
|
+
|
|
248
|
+
fitted: list[str] = []
|
|
249
|
+
need_ellipsis = False
|
|
250
|
+
|
|
251
|
+
for s in trimmed:
|
|
252
|
+
plain = build_block(fitted + [s], False)
|
|
253
|
+
if len(plain) <= _SKILL_HINT_SECTION_COMBINED_MAX_CHARS:
|
|
254
|
+
fitted.append(s)
|
|
255
|
+
continue
|
|
256
|
+
with_el = build_block(fitted + [s], True)
|
|
257
|
+
if len(with_el) <= _SKILL_HINT_SECTION_COMBINED_MAX_CHARS:
|
|
258
|
+
fitted.append(s)
|
|
259
|
+
need_ellipsis = True
|
|
260
|
+
warnings.warn(warn_combined, SpawnWarning)
|
|
261
|
+
break
|
|
262
|
+
need_ellipsis = True
|
|
263
|
+
warnings.warn(warn_combined, SpawnWarning)
|
|
264
|
+
break
|
|
265
|
+
|
|
266
|
+
if need_ellipsis:
|
|
267
|
+
fitted.append("...")
|
|
268
|
+
return fitted
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def _extension_global_hint_streams_ordered(target_root: Path) -> list[list[str]]:
|
|
272
|
+
"""One hint list per extension, in ``list_extensions`` order (sorted ext ids)."""
|
|
273
|
+
|
|
274
|
+
streams: list[list[str]] = []
|
|
275
|
+
for ext in list_extensions(target_root):
|
|
276
|
+
cfg = _load_ext_config(target_root, ext)
|
|
277
|
+
streams.append(list(cfg.hints.global_) if cfg.hints else [])
|
|
278
|
+
return streams
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def rollup_hints_for_agents(target_root: Path) -> list[str]:
|
|
282
|
+
"""Deduped ordered hints for ``AGENTS.md``: every extension ``hints.global``, then maintainer."""
|
|
283
|
+
|
|
284
|
+
streams = _extension_global_hint_streams_ordered(target_root)
|
|
285
|
+
streams.append(_navigation_read_required_rule_hints_ordered(target_root))
|
|
286
|
+
return _merge_hint_streams_ordered(streams)
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def warn_if_agents_hints_exceed_measurement(hints: list[str]) -> None:
|
|
290
|
+
"""Warn when thresholds are exceeded without mutating hints (full text still written)."""
|
|
291
|
+
|
|
292
|
+
if any(len(h) > _EXTENSION_NAV_HINT_MAX_CODEPOINTS for h in hints):
|
|
293
|
+
warnings.warn(
|
|
294
|
+
"AGENTS.md hints contain at least one hint over 512 Unicode code points "
|
|
295
|
+
"(full text retained); shorten hints and/or reduce installed extensions.",
|
|
296
|
+
SpawnWarning,
|
|
297
|
+
)
|
|
298
|
+
block = ""
|
|
299
|
+
if hints:
|
|
300
|
+
block = "Hints:\n" + "\n".join(f"- {h}" for h in hints)
|
|
301
|
+
if hints and len(block) > _SKILL_HINT_SECTION_COMBINED_MAX_CHARS:
|
|
302
|
+
warnings.warn(
|
|
303
|
+
"AGENTS.md hints exceed combined 4096 character measurement "
|
|
304
|
+
"(full text retained); shorten hints and/or reduce installed extensions.",
|
|
305
|
+
SpawnWarning,
|
|
306
|
+
)
|
|
307
|
+
|
|
308
|
+
|
|
133
309
|
def _file_refs_for_global(extension: ExtensionConfig, flag_required: bool) -> list[SkillFileRef]:
|
|
134
310
|
out: list[SkillFileRef] = []
|
|
135
311
|
for path_str, ent in extension.files.items():
|
|
@@ -353,6 +529,16 @@ def _required_read_description_for_path(
|
|
|
353
529
|
|
|
354
530
|
def generate_skills_metadata(target_root: Path, extension: str) -> list[SkillMetadata]:
|
|
355
531
|
cfg_ext = _load_ext_config(target_root, extension)
|
|
532
|
+
maintainer_hints = _navigation_read_required_rule_hints_ordered(target_root)
|
|
533
|
+
global_streams = _extension_global_hint_streams_ordered(target_root)
|
|
534
|
+
local_raw = list(cfg_ext.hints.local) if cfg_ext.hints else []
|
|
535
|
+
merged_for_skill = _merge_hint_streams_ordered(
|
|
536
|
+
[*global_streams, local_raw, maintainer_hints]
|
|
537
|
+
)
|
|
538
|
+
skill_hints = (
|
|
539
|
+
_finalize_hints_for_skill_metadata(merged_for_skill) if merged_for_skill else []
|
|
540
|
+
)
|
|
541
|
+
|
|
356
542
|
merged_global_required = _flatten_global_refs_ordered(
|
|
357
543
|
target_root, get_required_read_global_all(target_root)
|
|
358
544
|
)
|
|
@@ -422,6 +608,7 @@ def generate_skills_metadata(target_root: Path, extension: str) -> list[SkillMet
|
|
|
422
608
|
name=raw.name,
|
|
423
609
|
description=raw.description,
|
|
424
610
|
content=raw.content,
|
|
611
|
+
hints=list(skill_hints),
|
|
425
612
|
required_read=required_out,
|
|
426
613
|
auto_read=auto_out,
|
|
427
614
|
)
|
|
@@ -519,12 +706,11 @@ def get_core_agent_ignore(target_root: Path) -> list[str]:
|
|
|
519
706
|
return list(core.agent_ignore)
|
|
520
707
|
|
|
521
708
|
|
|
522
|
-
def
|
|
523
|
-
"""
|
|
709
|
+
def sync_core_config_from_defaults(target_root: Path) -> None:
|
|
710
|
+
"""Overwrite ``spawn/.core/config.yaml`` with bundled default ``CoreConfig``.
|
|
524
711
|
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
the default set.
|
|
712
|
+
Validates that an existing repo file parses as ``CoreConfig`` before
|
|
713
|
+
replacing it so corrupted files are not silently clobbered.
|
|
528
714
|
"""
|
|
529
715
|
path = _spawn(target_root) / ".core" / "config.yaml"
|
|
530
716
|
if not path.is_file():
|
|
@@ -548,16 +734,10 @@ def merge_core_config_from_defaults(target_root: Path) -> None:
|
|
|
548
734
|
"spawn/.core/config.yaml is empty or invalid; restore it or run spawn init again."
|
|
549
735
|
)
|
|
550
736
|
try:
|
|
551
|
-
|
|
737
|
+
CoreConfig.model_validate(raw_existing)
|
|
552
738
|
except Exception as e:
|
|
553
739
|
raise SpawnError(f"invalid spawn/.core/config.yaml: {e}") from e
|
|
554
|
-
|
|
555
|
-
merged_ignore = list(default_core.agent_ignore)
|
|
556
|
-
for g in current.agent_ignore:
|
|
557
|
-
if g not in default_set:
|
|
558
|
-
merged_ignore.append(g)
|
|
559
|
-
out = CoreConfig(version=default_core.version, agent_ignore=merged_ignore)
|
|
560
|
-
save_yaml(path, out.model_dump(by_alias=True))
|
|
740
|
+
save_yaml(path, default_core.model_dump(by_alias=True))
|
|
561
741
|
|
|
562
742
|
|
|
563
743
|
def get_ext_agent_ignore(target_root: Path, extension: str) -> list[str]:
|
|
@@ -566,12 +746,16 @@ def get_ext_agent_ignore(target_root: Path, extension: str) -> list[str]:
|
|
|
566
746
|
|
|
567
747
|
|
|
568
748
|
def get_all_agent_ignore(target_root: Path) -> list[str]:
|
|
749
|
+
return merge_core_and_extension_agent_ignore(
|
|
750
|
+
get_core_agent_ignore(target_root),
|
|
751
|
+
get_merged_extension_agent_ignore(target_root),
|
|
752
|
+
)
|
|
753
|
+
|
|
754
|
+
|
|
755
|
+
def get_merged_extension_agent_ignore(target_root: Path) -> list[str]:
|
|
756
|
+
"""Stable deduped merge of ``agent-ignore`` from all installed extensions."""
|
|
569
757
|
merged: list[str] = []
|
|
570
758
|
seen: set[str] = set()
|
|
571
|
-
for g in get_core_agent_ignore(target_root):
|
|
572
|
-
if g not in seen:
|
|
573
|
-
seen.add(g)
|
|
574
|
-
merged.append(g)
|
|
575
759
|
for ext in list_extensions(target_root):
|
|
576
760
|
for g in get_ext_agent_ignore(target_root, ext):
|
|
577
761
|
if g not in seen:
|
|
@@ -580,6 +764,20 @@ def get_all_agent_ignore(target_root: Path) -> list[str]:
|
|
|
580
764
|
return merged
|
|
581
765
|
|
|
582
766
|
|
|
767
|
+
def merge_core_and_extension_agent_ignore(core: list[str], ext: list[str]) -> list[str]:
|
|
768
|
+
merged: list[str] = []
|
|
769
|
+
seen: set[str] = set()
|
|
770
|
+
for g in core:
|
|
771
|
+
if g not in seen:
|
|
772
|
+
seen.add(g)
|
|
773
|
+
merged.append(g)
|
|
774
|
+
for g in ext:
|
|
775
|
+
if g not in seen:
|
|
776
|
+
seen.add(g)
|
|
777
|
+
merged.append(g)
|
|
778
|
+
return merged
|
|
779
|
+
|
|
780
|
+
|
|
583
781
|
def get_ext_git_ignore(target_root: Path, extension: str) -> list[str]:
|
|
584
782
|
cfg = _load_ext_config(target_root, extension)
|
|
585
783
|
return list(cfg.git_ignore)
|
|
@@ -671,6 +869,13 @@ def _agent_ignore_list_path(target_root: Path, ide: str) -> Path:
|
|
|
671
869
|
|
|
672
870
|
|
|
673
871
|
def get_agent_ignore_list(target_root: Path, ide: str) -> list[str]:
|
|
872
|
+
"""Last persisted ignore snapshot for *ide*.
|
|
873
|
+
|
|
874
|
+
Native file-based ignores: extension ``agent-ignore`` merge only.
|
|
875
|
+
|
|
876
|
+
Project-style ignores (e.g. Claude Code): full core+extension merge used for
|
|
877
|
+
JSON permissions diffing.
|
|
878
|
+
"""
|
|
674
879
|
lines = read_lines(_agent_ignore_list_path(target_root, ide))
|
|
675
880
|
return [ln for ln in lines if ln.strip() and not ln.lstrip().startswith("#")]
|
|
676
881
|
|
|
@@ -839,7 +1044,13 @@ def save_extension_navigation(
|
|
|
839
1044
|
_strip_ext_sections_inplace(rr, extension)
|
|
840
1045
|
_strip_ext_sections_inplace(rc, extension)
|
|
841
1046
|
if read_required_files:
|
|
842
|
-
|
|
1047
|
+
block: dict[str, Any] = {"ext": extension, "files": _nav_refs_to_files(read_required_files)}
|
|
1048
|
+
cfg_opt = _optional_ext_config(target_root, extension)
|
|
1049
|
+
if cfg_opt is not None:
|
|
1050
|
+
hints_nav = _normalized_extension_global_hints_for_navigation(cfg_opt)
|
|
1051
|
+
if hints_nav:
|
|
1052
|
+
block["hints"] = hints_nav
|
|
1053
|
+
rr.append(block)
|
|
843
1054
|
if read_contextual_files:
|
|
844
1055
|
req_norm = {_norm_read_path(r.file) for r in read_required_files}
|
|
845
1056
|
contextual_only = [
|
|
@@ -913,7 +1124,14 @@ def save_rules_navigation(target_root: Path) -> None:
|
|
|
913
1124
|
rk = posix_norm(str(entry['path']))
|
|
914
1125
|
exists = rk in paths_on_disk and (Path(target_root) / Path(rk)).is_file()
|
|
915
1126
|
if exists:
|
|
916
|
-
|
|
1127
|
+
row: dict[str, Any] = {
|
|
1128
|
+
'path': rk,
|
|
1129
|
+
'description': str(entry.get('description') or 'Local rule file.'),
|
|
1130
|
+
}
|
|
1131
|
+
hint_val = entry.get('hint')
|
|
1132
|
+
if isinstance(hint_val, str):
|
|
1133
|
+
row['hint'] = hint_val
|
|
1134
|
+
out.append(row)
|
|
917
1135
|
else:
|
|
918
1136
|
warnings.warn(
|
|
919
1137
|
f"Removed missing rule path from navigation: {rk}",
|
|
@@ -977,6 +1195,7 @@ __all__ = [
|
|
|
977
1195
|
"get_folders",
|
|
978
1196
|
"get_git_ignore_list",
|
|
979
1197
|
"get_global_gitignore",
|
|
1198
|
+
"get_merged_extension_agent_ignore",
|
|
980
1199
|
"get_navigation_metadata",
|
|
981
1200
|
"get_rendered_mcp",
|
|
982
1201
|
"get_rendered_skills",
|
|
@@ -990,7 +1209,8 @@ __all__ = [
|
|
|
990
1209
|
"list_ides",
|
|
991
1210
|
"list_mcp",
|
|
992
1211
|
"list_skills",
|
|
993
|
-
"
|
|
1212
|
+
"sync_core_config_from_defaults",
|
|
1213
|
+
"merge_core_and_extension_agent_ignore",
|
|
994
1214
|
"METADATA_TEMP_MAX_AGE_SECONDS",
|
|
995
1215
|
"normalize_skill_name",
|
|
996
1216
|
"prune_metadata_temp",
|
|
@@ -998,6 +1218,7 @@ __all__ = [
|
|
|
998
1218
|
"remove_from_global_gitignore",
|
|
999
1219
|
"remove_ide_metadata_dir",
|
|
1000
1220
|
"remove_ide_from_list",
|
|
1221
|
+
"rollup_hints_for_agents",
|
|
1001
1222
|
"save_agent_ignore_list",
|
|
1002
1223
|
"save_extension_navigation",
|
|
1003
1224
|
"save_git_ignore_list",
|
|
@@ -1006,5 +1227,6 @@ __all__ = [
|
|
|
1006
1227
|
"save_skills_rendered",
|
|
1007
1228
|
"supported_ide_keys",
|
|
1008
1229
|
"validate_rendered_identity",
|
|
1230
|
+
"warn_if_agents_hints_exceed_measurement",
|
|
1009
1231
|
]
|
|
1010
1232
|
|
|
@@ -17,6 +17,10 @@ from .github_copilot import GitHubCopilotAdapter
|
|
|
17
17
|
from .windsurf import WindsurfAdapter
|
|
18
18
|
from .registry import (
|
|
19
19
|
ALIASES,
|
|
20
|
+
CORE_IGNORE_BLOCK_END,
|
|
21
|
+
CORE_IGNORE_BLOCK_START,
|
|
22
|
+
EXT_IGNORE_BLOCK_END,
|
|
23
|
+
EXT_IGNORE_BLOCK_START,
|
|
20
24
|
IGNORE_BLOCK_END,
|
|
21
25
|
IGNORE_BLOCK_START,
|
|
22
26
|
SPAWN_BLOCK_END,
|
|
@@ -43,6 +47,10 @@ __all__ = [
|
|
|
43
47
|
"GeminiCliAdapter",
|
|
44
48
|
"GitHubCopilotAdapter",
|
|
45
49
|
"DetectResult",
|
|
50
|
+
"CORE_IGNORE_BLOCK_END",
|
|
51
|
+
"CORE_IGNORE_BLOCK_START",
|
|
52
|
+
"EXT_IGNORE_BLOCK_END",
|
|
53
|
+
"EXT_IGNORE_BLOCK_START",
|
|
46
54
|
"IGNORE_BLOCK_END",
|
|
47
55
|
"IGNORE_BLOCK_START",
|
|
48
56
|
"IdeAdapter",
|
|
@@ -14,6 +14,12 @@ SPAWN_BLOCK_END = "<!-- spawn:end -->"
|
|
|
14
14
|
IGNORE_BLOCK_START = "# spawn:start"
|
|
15
15
|
IGNORE_BLOCK_END = "# spawn:end"
|
|
16
16
|
|
|
17
|
+
# Dual-region agent ignore (core from spawn/.core vs extensions).
|
|
18
|
+
CORE_IGNORE_BLOCK_START = "# spawn:core:start"
|
|
19
|
+
CORE_IGNORE_BLOCK_END = "# spawn:core:end"
|
|
20
|
+
EXT_IGNORE_BLOCK_START = "# spawn:ext:start"
|
|
21
|
+
EXT_IGNORE_BLOCK_END = "# spawn:ext:end"
|
|
22
|
+
|
|
17
23
|
_NAV_YAML_FILE = "spawn/navigation.yaml"
|
|
18
24
|
_NAV_YAML_DESCRIPTION = "Merged Spawn navigation (read-required, read-contextual)."
|
|
19
25
|
|
|
@@ -36,7 +42,8 @@ def _mandatory_reads_for_render(skill: SkillMetadata) -> list[SkillFileRef]:
|
|
|
36
42
|
|
|
37
43
|
def render_skill_md(skill: SkillMetadata) -> str:
|
|
38
44
|
"""Render normalized skill to Markdown with frontmatter, skill body,
|
|
39
|
-
mandatory reads (navigation path last),
|
|
45
|
+
hints (stable order from metadata), mandatory reads (navigation path last),
|
|
46
|
+
and contextual reads."""
|
|
40
47
|
lines = [
|
|
41
48
|
"---",
|
|
42
49
|
f"name: {skill.name}",
|
|
@@ -45,8 +52,13 @@ def render_skill_md(skill: SkillMetadata) -> str:
|
|
|
45
52
|
"",
|
|
46
53
|
skill.content,
|
|
47
54
|
"",
|
|
48
|
-
"Mandatory reads:",
|
|
49
55
|
]
|
|
56
|
+
if skill.hints:
|
|
57
|
+
lines += ["Hints:"]
|
|
58
|
+
for h in skill.hints:
|
|
59
|
+
lines += [f"- {h}"]
|
|
60
|
+
lines += [""]
|
|
61
|
+
lines += ["Mandatory reads:"]
|
|
50
62
|
for ref in _mandatory_reads_for_render(skill):
|
|
51
63
|
lines += [f"- `{ref.file}` - {ref.description}"]
|
|
52
64
|
lines += [""]
|
|
@@ -112,6 +124,149 @@ def rewrite_ignore_block(file_path: Path, globs: list[str]) -> None:
|
|
|
112
124
|
file_path.write_text("".join(new_lines), encoding="utf-8")
|
|
113
125
|
|
|
114
126
|
|
|
127
|
+
def _normalize_ignore_inner_lines(inner: list[str]) -> list[str]:
|
|
128
|
+
out: list[str] = []
|
|
129
|
+
seen: set[str] = set()
|
|
130
|
+
for ln in inner:
|
|
131
|
+
st = ln.strip()
|
|
132
|
+
if not st or st.startswith("#"):
|
|
133
|
+
continue
|
|
134
|
+
if st not in seen:
|
|
135
|
+
seen.add(st)
|
|
136
|
+
out.append(st)
|
|
137
|
+
return out
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _format_optional_block(start: str, end: str, globs: list[str]) -> list[str]:
|
|
141
|
+
body = [g for g in globs if g.strip()]
|
|
142
|
+
if not body:
|
|
143
|
+
return []
|
|
144
|
+
lines = [start + "\n"] + [g.strip() + "\n" for g in body] + [end + "\n"]
|
|
145
|
+
return lines
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _flatten_chunks(chunks: list[list[str]]) -> list[str]:
|
|
149
|
+
out: list[str] = []
|
|
150
|
+
for ch in chunks:
|
|
151
|
+
out.extend(ch)
|
|
152
|
+
return out
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _rebuild_from_user_chunks(
|
|
156
|
+
chunks: list[list[str]], core_globs: list[str], ext_globs: list[str]
|
|
157
|
+
) -> list[str]:
|
|
158
|
+
"""Interleave user segments with core and ext blocks (see spawn agent-ignore spec)."""
|
|
159
|
+
core_b = _format_optional_block(CORE_IGNORE_BLOCK_START, CORE_IGNORE_BLOCK_END, core_globs)
|
|
160
|
+
ext_b = _format_optional_block(EXT_IGNORE_BLOCK_START, EXT_IGNORE_BLOCK_END, ext_globs)
|
|
161
|
+
if not core_b and not ext_b:
|
|
162
|
+
return _flatten_chunks(chunks)
|
|
163
|
+
if len(chunks) == 0:
|
|
164
|
+
return core_b + ext_b
|
|
165
|
+
if len(chunks) == 1:
|
|
166
|
+
return chunks[0] + core_b + ext_b
|
|
167
|
+
if len(chunks) == 2:
|
|
168
|
+
return chunks[0] + core_b + ext_b + chunks[1]
|
|
169
|
+
out: list[str] = []
|
|
170
|
+
out += chunks[0] + core_b + chunks[1] + ext_b
|
|
171
|
+
for c in chunks[2:]:
|
|
172
|
+
out += c
|
|
173
|
+
return out
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _block_kind(line_stripped: str) -> str | None:
|
|
177
|
+
if line_stripped == IGNORE_BLOCK_START:
|
|
178
|
+
return "legacy"
|
|
179
|
+
if line_stripped == CORE_IGNORE_BLOCK_START:
|
|
180
|
+
return "core"
|
|
181
|
+
if line_stripped == EXT_IGNORE_BLOCK_START:
|
|
182
|
+
return "ext"
|
|
183
|
+
return None
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _end_marker_for(kind: str) -> str:
|
|
187
|
+
if kind == "legacy":
|
|
188
|
+
return IGNORE_BLOCK_END
|
|
189
|
+
if kind == "core":
|
|
190
|
+
return CORE_IGNORE_BLOCK_END
|
|
191
|
+
if kind == "ext":
|
|
192
|
+
return EXT_IGNORE_BLOCK_END
|
|
193
|
+
raise ValueError(kind)
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _parse_split_agent_ignore_lines(
|
|
197
|
+
raw_lines: list[str],
|
|
198
|
+
) -> tuple[list[list[str]], list[str], list[str]]:
|
|
199
|
+
"""Split file into user chunks (between blocks), core globs, ext globs.
|
|
200
|
+
|
|
201
|
+
Legacy ``# spawn:start`` … ``# spawn:end`` inner lines are discarded (migration).
|
|
202
|
+
Unclosed blocks: inner runs to EOF.
|
|
203
|
+
"""
|
|
204
|
+
chunks: list[list[str]] = []
|
|
205
|
+
core: list[str] = []
|
|
206
|
+
ext: list[str] = []
|
|
207
|
+
cur: list[str] = []
|
|
208
|
+
i = 0
|
|
209
|
+
n = len(raw_lines)
|
|
210
|
+
while i < n:
|
|
211
|
+
kind = _block_kind(raw_lines[i].strip())
|
|
212
|
+
if kind is None:
|
|
213
|
+
cur.append(raw_lines[i])
|
|
214
|
+
i += 1
|
|
215
|
+
continue
|
|
216
|
+
if cur:
|
|
217
|
+
chunks.append(cur)
|
|
218
|
+
cur = []
|
|
219
|
+
end_marker = _end_marker_for(kind)
|
|
220
|
+
j = i + 1
|
|
221
|
+
inner: list[str] = []
|
|
222
|
+
while j < n and raw_lines[j].strip() != end_marker:
|
|
223
|
+
inner.append(raw_lines[j])
|
|
224
|
+
j += 1
|
|
225
|
+
if kind == "core":
|
|
226
|
+
core = _normalize_ignore_inner_lines(inner)
|
|
227
|
+
elif kind == "ext":
|
|
228
|
+
ext = _normalize_ignore_inner_lines(inner)
|
|
229
|
+
# legacy: discard inner
|
|
230
|
+
i = j + 1 if j < n and raw_lines[j].strip() == end_marker else j
|
|
231
|
+
if cur:
|
|
232
|
+
chunks.append(cur)
|
|
233
|
+
return chunks, core, ext
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def parse_split_agent_ignore_file(file_path: Path) -> tuple[list[list[str]], list[str], list[str]]:
|
|
237
|
+
if not file_path.exists():
|
|
238
|
+
return [], [], []
|
|
239
|
+
raw = file_path.read_text(encoding="utf-8").splitlines(True)
|
|
240
|
+
return _parse_split_agent_ignore_lines(raw)
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def write_split_agent_ignore_file(
|
|
244
|
+
file_path: Path, chunks: list[list[str]], core_globs: list[str], ext_globs: list[str]
|
|
245
|
+
) -> None:
|
|
246
|
+
body = _rebuild_from_user_chunks(chunks, core_globs, ext_globs)
|
|
247
|
+
file_path.parent.mkdir(parents=True, exist_ok=True)
|
|
248
|
+
if not body:
|
|
249
|
+
if file_path.exists():
|
|
250
|
+
file_path.write_text("", encoding="utf-8")
|
|
251
|
+
return
|
|
252
|
+
file_path.write_text("".join(body), encoding="utf-8")
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def rewrite_core_agent_ignore_region(file_path: Path, core_globs: list[str]) -> None:
|
|
256
|
+
chunks, _, ext = parse_split_agent_ignore_file(file_path)
|
|
257
|
+
write_split_agent_ignore_file(file_path, chunks, core_globs, ext)
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def rewrite_extension_agent_ignore_region(file_path: Path, ext_globs: list[str]) -> None:
|
|
261
|
+
chunks, core, _ = parse_split_agent_ignore_file(file_path)
|
|
262
|
+
write_split_agent_ignore_file(file_path, chunks, core, ext_globs)
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def clear_split_agent_ignore_file(file_path: Path) -> None:
|
|
266
|
+
chunks, _, _ = parse_split_agent_ignore_file(file_path)
|
|
267
|
+
write_split_agent_ignore_file(file_path, chunks, [], [])
|
|
268
|
+
|
|
269
|
+
|
|
115
270
|
def remove_ignore_block(file_path: Path, globs: list[str]) -> None:
|
|
116
271
|
"""Remove Spawn-managed ignore lines or the whole managed block.
|
|
117
272
|
|
|
@@ -156,13 +311,22 @@ def remove_ignore_block(file_path: Path, globs: list[str]) -> None:
|
|
|
156
311
|
|
|
157
312
|
|
|
158
313
|
__all__ = [
|
|
314
|
+
"CORE_IGNORE_BLOCK_END",
|
|
315
|
+
"CORE_IGNORE_BLOCK_START",
|
|
316
|
+
"EXT_IGNORE_BLOCK_END",
|
|
317
|
+
"EXT_IGNORE_BLOCK_START",
|
|
159
318
|
"IGNORE_BLOCK_END",
|
|
160
319
|
"IGNORE_BLOCK_START",
|
|
161
320
|
"SPAWN_BLOCK_END",
|
|
162
321
|
"SPAWN_BLOCK_START",
|
|
322
|
+
"clear_split_agent_ignore_file",
|
|
163
323
|
"normalize_skill_name",
|
|
324
|
+
"parse_split_agent_ignore_file",
|
|
164
325
|
"remove_ignore_block",
|
|
165
326
|
"render_skill_md",
|
|
327
|
+
"rewrite_core_agent_ignore_region",
|
|
328
|
+
"rewrite_extension_agent_ignore_region",
|
|
166
329
|
"rewrite_ignore_block",
|
|
167
330
|
"rewrite_managed_block",
|
|
331
|
+
"write_split_agent_ignore_file",
|
|
168
332
|
]
|
|
@@ -6,6 +6,7 @@ import json
|
|
|
6
6
|
import warnings
|
|
7
7
|
from pathlib import Path
|
|
8
8
|
|
|
9
|
+
from spawn_cli.core import low_level as ll
|
|
9
10
|
from spawn_cli.ide import _vacancy as _vac
|
|
10
11
|
from spawn_cli.ide.registry import (
|
|
11
12
|
DetectResult,
|
|
@@ -126,6 +127,11 @@ class ClaudeCodeAdapter(IdeAdapter):
|
|
|
126
127
|
perms["deny"] = [g for g in deny if g not in drop]
|
|
127
128
|
settings_path.write_text(json.dumps(data, indent=2), encoding="utf-8")
|
|
128
129
|
|
|
130
|
+
def clear_spawn_agent_ignore(self, target_root: Path) -> None:
|
|
131
|
+
recorded = ll.get_agent_ignore_list(target_root, self.key)
|
|
132
|
+
if recorded:
|
|
133
|
+
self.remove_agent_ignore(target_root, recorded)
|
|
134
|
+
|
|
129
135
|
def rewrite_entry_point(self, target_root: Path, prompt: str) -> str:
|
|
130
136
|
ep = target_root / "CLAUDE.md"
|
|
131
137
|
if (target_root / ".claude" / "CLAUDE.md").exists():
|
|
@@ -7,6 +7,13 @@ import warnings
|
|
|
7
7
|
from pathlib import Path
|
|
8
8
|
|
|
9
9
|
from spawn_cli.ide import _vacancy as _vac
|
|
10
|
+
from spawn_cli.ide._helpers import (
|
|
11
|
+
clear_split_agent_ignore_file,
|
|
12
|
+
remove_ignore_block,
|
|
13
|
+
rewrite_core_agent_ignore_region,
|
|
14
|
+
rewrite_extension_agent_ignore_region,
|
|
15
|
+
rewrite_ignore_block,
|
|
16
|
+
)
|
|
10
17
|
from spawn_cli.ide.registry import (
|
|
11
18
|
DetectResult,
|
|
12
19
|
IdeAdapter,
|
|
@@ -15,8 +22,6 @@ from spawn_cli.ide.registry import (
|
|
|
15
22
|
normalize_skill_name,
|
|
16
23
|
render_skill_md,
|
|
17
24
|
rewrite_managed_block,
|
|
18
|
-
rewrite_ignore_block,
|
|
19
|
-
remove_ignore_block,
|
|
20
25
|
)
|
|
21
26
|
from spawn_cli.models.mcp import McpServer, NormalizedMcp
|
|
22
27
|
from spawn_cli.models.skill import SkillMetadata
|
|
@@ -108,6 +113,15 @@ class CursorAdapter(IdeAdapter):
|
|
|
108
113
|
def remove_agent_ignore(self, target_root: Path, globs: list[str]) -> None:
|
|
109
114
|
remove_ignore_block(target_root / ".cursorignore", globs)
|
|
110
115
|
|
|
116
|
+
def rewrite_core_agent_ignore(self, target_root: Path, globs: list[str]) -> None:
|
|
117
|
+
rewrite_core_agent_ignore_region(target_root / ".cursorignore", globs)
|
|
118
|
+
|
|
119
|
+
def rewrite_extension_agent_ignore(self, target_root: Path, globs: list[str]) -> None:
|
|
120
|
+
rewrite_extension_agent_ignore_region(target_root / ".cursorignore", globs)
|
|
121
|
+
|
|
122
|
+
def clear_spawn_agent_ignore(self, target_root: Path) -> None:
|
|
123
|
+
clear_split_agent_ignore_file(target_root / ".cursorignore")
|
|
124
|
+
|
|
111
125
|
def rewrite_entry_point(self, target_root: Path, prompt: str) -> str:
|
|
112
126
|
ep = target_root / "AGENTS.md"
|
|
113
127
|
rewrite_managed_block(ep, prompt)
|
|
@@ -7,6 +7,14 @@ import warnings
|
|
|
7
7
|
from pathlib import Path
|
|
8
8
|
|
|
9
9
|
from spawn_cli.ide import _vacancy as _vac
|
|
10
|
+
from spawn_cli.ide._helpers import (
|
|
11
|
+
clear_split_agent_ignore_file,
|
|
12
|
+
remove_ignore_block,
|
|
13
|
+
rewrite_core_agent_ignore_region,
|
|
14
|
+
rewrite_extension_agent_ignore_region,
|
|
15
|
+
rewrite_ignore_block,
|
|
16
|
+
rewrite_managed_block,
|
|
17
|
+
)
|
|
10
18
|
from spawn_cli.ide.registry import (
|
|
11
19
|
DetectResult,
|
|
12
20
|
IdeAdapter,
|
|
@@ -14,9 +22,6 @@ from spawn_cli.ide.registry import (
|
|
|
14
22
|
register,
|
|
15
23
|
normalize_skill_name,
|
|
16
24
|
render_skill_md,
|
|
17
|
-
rewrite_managed_block,
|
|
18
|
-
rewrite_ignore_block,
|
|
19
|
-
remove_ignore_block,
|
|
20
25
|
)
|
|
21
26
|
from spawn_cli.models.mcp import McpServer, NormalizedMcp
|
|
22
27
|
from spawn_cli.models.skill import SkillMetadata
|
|
@@ -111,6 +116,15 @@ class GeminiCliAdapter(IdeAdapter):
|
|
|
111
116
|
def remove_agent_ignore(self, target_root: Path, globs: list[str]) -> None:
|
|
112
117
|
remove_ignore_block(target_root / ".geminiignore", globs)
|
|
113
118
|
|
|
119
|
+
def rewrite_core_agent_ignore(self, target_root: Path, globs: list[str]) -> None:
|
|
120
|
+
rewrite_core_agent_ignore_region(target_root / ".geminiignore", globs)
|
|
121
|
+
|
|
122
|
+
def rewrite_extension_agent_ignore(self, target_root: Path, globs: list[str]) -> None:
|
|
123
|
+
rewrite_extension_agent_ignore_region(target_root / ".geminiignore", globs)
|
|
124
|
+
|
|
125
|
+
def clear_spawn_agent_ignore(self, target_root: Path) -> None:
|
|
126
|
+
clear_split_agent_ignore_file(target_root / ".geminiignore")
|
|
127
|
+
|
|
114
128
|
def rewrite_entry_point(self, target_root: Path, prompt: str) -> str:
|
|
115
129
|
ep = target_root / "GEMINI.md"
|
|
116
130
|
rewrite_managed_block(ep, prompt)
|
|
@@ -9,6 +9,10 @@ from pathlib import Path
|
|
|
9
9
|
from spawn_cli.core.errors import SpawnError
|
|
10
10
|
from spawn_cli.core.low_level import CANONICAL_IDE_KEYS
|
|
11
11
|
from spawn_cli.ide._helpers import (
|
|
12
|
+
CORE_IGNORE_BLOCK_END,
|
|
13
|
+
CORE_IGNORE_BLOCK_START,
|
|
14
|
+
EXT_IGNORE_BLOCK_END,
|
|
15
|
+
EXT_IGNORE_BLOCK_START,
|
|
12
16
|
IGNORE_BLOCK_END,
|
|
13
17
|
IGNORE_BLOCK_START,
|
|
14
18
|
SPAWN_BLOCK_END,
|
|
@@ -74,6 +78,15 @@ class IdeAdapter(ABC):
|
|
|
74
78
|
@abstractmethod
|
|
75
79
|
def remove_agent_ignore(self, target_root: Path, globs: list[str]) -> None: ...
|
|
76
80
|
|
|
81
|
+
def rewrite_core_agent_ignore(self, target_root: Path, globs: list[str]) -> None:
|
|
82
|
+
del target_root, globs
|
|
83
|
+
|
|
84
|
+
def rewrite_extension_agent_ignore(self, target_root: Path, globs: list[str]) -> None:
|
|
85
|
+
del target_root, globs
|
|
86
|
+
|
|
87
|
+
def clear_spawn_agent_ignore(self, target_root: Path) -> None:
|
|
88
|
+
del target_root
|
|
89
|
+
|
|
77
90
|
@abstractmethod
|
|
78
91
|
def rewrite_entry_point(self, target_root: Path, prompt: str) -> str:
|
|
79
92
|
"""Returns rendered path or warning string."""
|
|
@@ -122,7 +135,11 @@ def detect_supported_ides(target_root: Path) -> dict[str, DetectResult]:
|
|
|
122
135
|
|
|
123
136
|
__all__ = [
|
|
124
137
|
"ALIASES",
|
|
138
|
+
"CORE_IGNORE_BLOCK_END",
|
|
139
|
+
"CORE_IGNORE_BLOCK_START",
|
|
125
140
|
"DetectResult",
|
|
141
|
+
"EXT_IGNORE_BLOCK_END",
|
|
142
|
+
"EXT_IGNORE_BLOCK_START",
|
|
126
143
|
"IdeAdapter",
|
|
127
144
|
"IdeCapabilities",
|
|
128
145
|
"IGNORE_BLOCK_END",
|
|
@@ -6,6 +6,14 @@ import warnings
|
|
|
6
6
|
from pathlib import Path
|
|
7
7
|
|
|
8
8
|
from spawn_cli.ide import _vacancy as _vac
|
|
9
|
+
from spawn_cli.ide._helpers import (
|
|
10
|
+
clear_split_agent_ignore_file,
|
|
11
|
+
remove_ignore_block,
|
|
12
|
+
rewrite_core_agent_ignore_region,
|
|
13
|
+
rewrite_extension_agent_ignore_region,
|
|
14
|
+
rewrite_ignore_block,
|
|
15
|
+
rewrite_managed_block,
|
|
16
|
+
)
|
|
9
17
|
from spawn_cli.ide.registry import (
|
|
10
18
|
DetectResult,
|
|
11
19
|
IdeAdapter,
|
|
@@ -13,9 +21,6 @@ from spawn_cli.ide.registry import (
|
|
|
13
21
|
register,
|
|
14
22
|
normalize_skill_name,
|
|
15
23
|
render_skill_md,
|
|
16
|
-
rewrite_managed_block,
|
|
17
|
-
rewrite_ignore_block,
|
|
18
|
-
remove_ignore_block,
|
|
19
24
|
)
|
|
20
25
|
from spawn_cli.models.mcp import NormalizedMcp
|
|
21
26
|
from spawn_cli.models.skill import SkillMetadata
|
|
@@ -77,6 +82,15 @@ class WindsurfAdapter(IdeAdapter):
|
|
|
77
82
|
def remove_agent_ignore(self, target_root: Path, globs: list[str]) -> None:
|
|
78
83
|
remove_ignore_block(target_root / ".codeiumignore", globs)
|
|
79
84
|
|
|
85
|
+
def rewrite_core_agent_ignore(self, target_root: Path, globs: list[str]) -> None:
|
|
86
|
+
rewrite_core_agent_ignore_region(target_root / ".codeiumignore", globs)
|
|
87
|
+
|
|
88
|
+
def rewrite_extension_agent_ignore(self, target_root: Path, globs: list[str]) -> None:
|
|
89
|
+
rewrite_extension_agent_ignore_region(target_root / ".codeiumignore", globs)
|
|
90
|
+
|
|
91
|
+
def clear_spawn_agent_ignore(self, target_root: Path) -> None:
|
|
92
|
+
clear_split_agent_ignore_file(target_root / ".codeiumignore")
|
|
93
|
+
|
|
80
94
|
def rewrite_entry_point(self, target_root: Path, prompt: str) -> str:
|
|
81
95
|
ep = target_root / "AGENTS.md"
|
|
82
96
|
rewrite_managed_block(ep, prompt)
|
|
@@ -40,6 +40,15 @@ class SkillEntry(BaseModel):
|
|
|
40
40
|
required_read: list[str] = Field(default_factory=list, alias="required-read")
|
|
41
41
|
|
|
42
42
|
|
|
43
|
+
class ExtensionHints(BaseModel):
|
|
44
|
+
"""Optional extension-authored hints (plain strings only)."""
|
|
45
|
+
|
|
46
|
+
model_config = ConfigDict(populate_by_name=True)
|
|
47
|
+
|
|
48
|
+
global_: list[str] = Field(default_factory=list, alias="global")
|
|
49
|
+
local: list[str] = Field(default_factory=list)
|
|
50
|
+
|
|
51
|
+
|
|
43
52
|
class SetupConfig(BaseModel):
|
|
44
53
|
model_config = ConfigDict(populate_by_name=True)
|
|
45
54
|
|
|
@@ -62,6 +71,7 @@ class ExtensionConfig(BaseModel):
|
|
|
62
71
|
git_ignore: list[str] = Field(default_factory=list, alias="git-ignore")
|
|
63
72
|
skills: dict[str, SkillEntry] = Field(default_factory=dict)
|
|
64
73
|
setup: Optional[SetupConfig] = None
|
|
74
|
+
hints: Optional[ExtensionHints] = None
|
|
65
75
|
|
|
66
76
|
|
|
67
77
|
class CoreConfig(BaseModel):
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: spawn-cli
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.10
|
|
4
4
|
Summary: A minimal installable Python CLI package.
|
|
5
5
|
License: MIT
|
|
6
6
|
Classifier: License :: OSI Approved :: MIT License
|
|
@@ -27,7 +27,7 @@ Spawn is a framework for building **AIDD** (AI-driven development) methodologies
|
|
|
27
27
|
|
|
28
28
|
Several methodologies in one repo stay aligned because Spawn maintains **one shared navigation index**: [`spawn/navigation.yaml`](spec/design/data-structure.md) aggregates mandatory and contextual reads from **every installed extension** plus local [`spawn/rules/`](spec/design/user-guide.md) (refreshed with `spawn rules refresh`). Agents are steered through that file instead of chasing disconnected README fragments.
|
|
29
29
|
|
|
30
|
-
On each install or refresh, the CLI **regenerates IDE-facing artifacts** so skills and entry points stay consistent across extensions: rendered skill bodies include **paths and descriptions** wired from navigation (required reads, contextual hints, rule files), MCP snippets normalize server definitions per IDE adapter, and ignore lists merge without manual copy-paste. The net effect is that methodology files, generated skill links, and repo rules **compose** rather than contradict each other.
|
|
30
|
+
On each install or refresh, the CLI **regenerates IDE-facing artifacts** so skills and entry points stay consistent across extensions: rendered skill bodies include **paths and descriptions** wired from navigation (required reads, contextual hints, rule files), MCP snippets normalize server definitions per IDE adapter, and ignore lists merge without manual copy-paste. The net effect is that methodology files, generated skill links, and repo rules **compose** rather than contradict each other. Optional `hints` add short reminders to rendered skills and the Spawn-managed entry block.
|
|
31
31
|
|
|
32
32
|
**Example:** [**spawn-ext-spectask**](https://github.com/noant/spawn-ext-spectask) (Spec-Tasks methodology: spec layout, skills, design index) and [**spawn-ext-creator**](https://github.com/noant/spawn-ext-creator) (authoring flows, including agent-guided extension bootstrap) can be installed in the **same** repository and still share that single index and merged IDE outputs.
|
|
33
33
|
|
|
@@ -189,7 +189,7 @@ Read **[spec/design/extension-author-guide.md](spec/design/extension-author-guid
|
|
|
189
189
|
|
|
190
190
|
**Minimal mental model:**
|
|
191
191
|
|
|
192
|
-
- `**extsrc/config.yaml`** — `schema`, `version`, stable `**name`** (install path `spawn/.extend/<name>/`), plus `files`, `skills`, ignores, etc.
|
|
192
|
+
- `**extsrc/config.yaml`** — `schema`, `version`, stable `**name`** (install path `spawn/.extend/<name>/`), plus `files`, `skills`, ignores, optional `hints`, etc.
|
|
193
193
|
- `**extsrc/files/**` — template tree mirrored into the target repo according to `files:` entries.
|
|
194
194
|
- `**spawn extension check . --strict**` — validate before publishing or tagging a release.
|
|
195
195
|
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|