spawn-cli 0.1.7__tar.gz → 0.1.9__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.7/src/spawn_cli.egg-info → spawn_cli-0.1.9}/PKG-INFO +3 -3
- {spawn_cli-0.1.7 → spawn_cli-0.1.9}/README.md +2 -2
- {spawn_cli-0.1.7 → spawn_cli-0.1.9}/pyproject.toml +1 -1
- {spawn_cli-0.1.7 → spawn_cli-0.1.9}/src/spawn_cli/cli.py +2 -2
- {spawn_cli-0.1.7 → spawn_cli-0.1.9}/src/spawn_cli/core/high_level.py +14 -3
- {spawn_cli-0.1.7 → spawn_cli-0.1.9}/src/spawn_cli/core/low_level.py +276 -20
- {spawn_cli-0.1.7 → spawn_cli-0.1.9}/src/spawn_cli/ide/_helpers.py +8 -2
- {spawn_cli-0.1.7 → spawn_cli-0.1.9}/src/spawn_cli/models/config.py +10 -0
- {spawn_cli-0.1.7 → spawn_cli-0.1.9}/src/spawn_cli/models/skill.py +1 -0
- {spawn_cli-0.1.7 → spawn_cli-0.1.9/src/spawn_cli.egg-info}/PKG-INFO +3 -3
- {spawn_cli-0.1.7 → spawn_cli-0.1.9}/LICENSE +0 -0
- {spawn_cli-0.1.7 → spawn_cli-0.1.9}/setup.cfg +0 -0
- {spawn_cli-0.1.7 → spawn_cli-0.1.9}/src/spawn_cli/__init__.py +0 -0
- {spawn_cli-0.1.7 → spawn_cli-0.1.9}/src/spawn_cli/core/__init__.py +0 -0
- {spawn_cli-0.1.7 → spawn_cli-0.1.9}/src/spawn_cli/core/download.py +0 -0
- {spawn_cli-0.1.7 → spawn_cli-0.1.9}/src/spawn_cli/core/errors.py +0 -0
- {spawn_cli-0.1.7 → spawn_cli-0.1.9}/src/spawn_cli/core/scripts.py +0 -0
- {spawn_cli-0.1.7 → spawn_cli-0.1.9}/src/spawn_cli/errors.py +0 -0
- {spawn_cli-0.1.7 → spawn_cli-0.1.9}/src/spawn_cli/ide/__init__.py +0 -0
- {spawn_cli-0.1.7 → spawn_cli-0.1.9}/src/spawn_cli/ide/_stub.py +0 -0
- {spawn_cli-0.1.7 → spawn_cli-0.1.9}/src/spawn_cli/ide/_vacancy.py +0 -0
- {spawn_cli-0.1.7 → spawn_cli-0.1.9}/src/spawn_cli/ide/claude_code.py +0 -0
- {spawn_cli-0.1.7 → spawn_cli-0.1.9}/src/spawn_cli/ide/codex.py +0 -0
- {spawn_cli-0.1.7 → spawn_cli-0.1.9}/src/spawn_cli/ide/cursor.py +0 -0
- {spawn_cli-0.1.7 → spawn_cli-0.1.9}/src/spawn_cli/ide/gemini_cli.py +0 -0
- {spawn_cli-0.1.7 → spawn_cli-0.1.9}/src/spawn_cli/ide/github_copilot.py +0 -0
- {spawn_cli-0.1.7 → spawn_cli-0.1.9}/src/spawn_cli/ide/registry.py +0 -0
- {spawn_cli-0.1.7 → spawn_cli-0.1.9}/src/spawn_cli/ide/windsurf.py +0 -0
- {spawn_cli-0.1.7 → spawn_cli-0.1.9}/src/spawn_cli/io/__init__.py +0 -0
- {spawn_cli-0.1.7 → spawn_cli-0.1.9}/src/spawn_cli/io/json_io.py +0 -0
- {spawn_cli-0.1.7 → spawn_cli-0.1.9}/src/spawn_cli/io/lock.py +0 -0
- {spawn_cli-0.1.7 → spawn_cli-0.1.9}/src/spawn_cli/io/paths.py +0 -0
- {spawn_cli-0.1.7 → spawn_cli-0.1.9}/src/spawn_cli/io/text_io.py +0 -0
- {spawn_cli-0.1.7 → spawn_cli-0.1.9}/src/spawn_cli/io/toml_io.py +0 -0
- {spawn_cli-0.1.7 → spawn_cli-0.1.9}/src/spawn_cli/io/yaml_io.py +0 -0
- {spawn_cli-0.1.7 → spawn_cli-0.1.9}/src/spawn_cli/models/__init__.py +0 -0
- {spawn_cli-0.1.7 → spawn_cli-0.1.9}/src/spawn_cli/models/mcp.py +0 -0
- {spawn_cli-0.1.7 → spawn_cli-0.1.9}/src/spawn_cli/models/metadata.py +0 -0
- {spawn_cli-0.1.7 → spawn_cli-0.1.9}/src/spawn_cli/models/navigation.py +0 -0
- {spawn_cli-0.1.7 → spawn_cli-0.1.9}/src/spawn_cli/resources/__init__.py +0 -0
- {spawn_cli-0.1.7 → spawn_cli-0.1.9}/src/spawn_cli/resources/default_core_config.yaml +0 -0
- {spawn_cli-0.1.7 → spawn_cli-0.1.9}/src/spawn_cli/warnings_display.py +0 -0
- {spawn_cli-0.1.7 → spawn_cli-0.1.9}/src/spawn_cli.egg-info/SOURCES.txt +0 -0
- {spawn_cli-0.1.7 → spawn_cli-0.1.9}/src/spawn_cli.egg-info/dependency_links.txt +0 -0
- {spawn_cli-0.1.7 → spawn_cli-0.1.9}/src/spawn_cli.egg-info/entry_points.txt +0 -0
- {spawn_cli-0.1.7 → spawn_cli-0.1.9}/src/spawn_cli.egg-info/requires.txt +0 -0
- {spawn_cli-0.1.7 → spawn_cli-0.1.9}/src/spawn_cli.egg-info/top_level.txt +0 -0
- {spawn_cli-0.1.7 → spawn_cli-0.1.9}/tests/test_cli.py +0 -0
- {spawn_cli-0.1.7 → spawn_cli-0.1.9}/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.9
|
|
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
|
|
|
@@ -169,7 +169,17 @@ def remove_mcp(target_root: Path, ide: str, extension: str) -> None:
|
|
|
169
169
|
|
|
170
170
|
def refresh_entry_point(target_root: Path, ide: str) -> None:
|
|
171
171
|
_require_init(target_root)
|
|
172
|
-
|
|
172
|
+
rollup = ll.rollup_hints_for_agents(target_root)
|
|
173
|
+
ll.warn_if_agents_hints_exceed_measurement(rollup)
|
|
174
|
+
if rollup:
|
|
175
|
+
prompt = (
|
|
176
|
+
f"{SPAWN_ENTRY_POINT_PROMPT}\nHints:\n"
|
|
177
|
+
+ "\n".join(f"- {h}" for h in rollup)
|
|
178
|
+
+ "\n"
|
|
179
|
+
)
|
|
180
|
+
else:
|
|
181
|
+
prompt = SPAWN_ENTRY_POINT_PROMPT
|
|
182
|
+
ide_get(ide).rewrite_entry_point(target_root, prompt)
|
|
173
183
|
|
|
174
184
|
|
|
175
185
|
def refresh_extension_for_ide(target_root: Path, ide: str, extension: str) -> None:
|
|
@@ -192,6 +202,7 @@ def refresh_extension_for_ide(target_root: Path, ide: str, extension: str) -> No
|
|
|
192
202
|
refresh_mcp(target_root, ide, extension)
|
|
193
203
|
_refresh_skills_all_extensions_for_ide(target_root, ide)
|
|
194
204
|
refresh_agent_ignore(target_root, ide)
|
|
205
|
+
refresh_navigation(target_root)
|
|
195
206
|
|
|
196
207
|
|
|
197
208
|
def remove_extension_for_ide(target_root: Path, ide: str, extension: str) -> None:
|
|
@@ -223,9 +234,9 @@ def refresh_rules_navigation(target_root: Path) -> None:
|
|
|
223
234
|
|
|
224
235
|
|
|
225
236
|
def refresh_repository(target_root: Path) -> None:
|
|
226
|
-
"""
|
|
237
|
+
"""Overwrite core config from bundled defaults, then rebuild all IDE-facing metadata."""
|
|
227
238
|
_require_init(target_root)
|
|
228
|
-
ll.
|
|
239
|
+
ll.sync_core_config_from_defaults(target_root)
|
|
229
240
|
needs_skill = _any_extension_has_skill_files(target_root)
|
|
230
241
|
needs_mcp = _any_extension_has_mcp_servers(target_root)
|
|
231
242
|
for ide in ll.list_ides(target_root):
|
|
@@ -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():
|
|
@@ -294,26 +470,82 @@ def _norm_read_path(path_str: str) -> str:
|
|
|
294
470
|
return Path(path_str).as_posix().replace("\\", "/")
|
|
295
471
|
|
|
296
472
|
|
|
473
|
+
def _navigation_rules_refs_from_section(section: Any) -> list[SkillFileRef]:
|
|
474
|
+
out: list[SkillFileRef] = []
|
|
475
|
+
if not isinstance(section, list):
|
|
476
|
+
return out
|
|
477
|
+
for grp in section:
|
|
478
|
+
if not isinstance(grp, dict):
|
|
479
|
+
continue
|
|
480
|
+
rules_raw = grp.get("rules")
|
|
481
|
+
if not isinstance(rules_raw, list):
|
|
482
|
+
continue
|
|
483
|
+
for entry in rules_raw:
|
|
484
|
+
if not isinstance(entry, dict):
|
|
485
|
+
continue
|
|
486
|
+
path_raw = entry.get("path")
|
|
487
|
+
if not isinstance(path_raw, str) or not path_raw.strip():
|
|
488
|
+
continue
|
|
489
|
+
desc_raw = entry.get("description")
|
|
490
|
+
desc = "" if desc_raw is None else str(desc_raw)
|
|
491
|
+
out.append(SkillFileRef(file=path_raw.strip(), description=desc))
|
|
492
|
+
return out
|
|
493
|
+
|
|
494
|
+
|
|
495
|
+
def _navigation_yaml_rules_refs(target_root: Path) -> tuple[list[SkillFileRef], list[SkillFileRef]]:
|
|
496
|
+
"""Return (read-required rules, read-contextual rules) from merged navigation.yaml."""
|
|
497
|
+
path = _spawn(target_root) / "navigation.yaml"
|
|
498
|
+
raw = load_yaml(path)
|
|
499
|
+
if not isinstance(raw, dict):
|
|
500
|
+
return [], []
|
|
501
|
+
rr = raw.get("read-required")
|
|
502
|
+
cq = raw.get("read-contextual")
|
|
503
|
+
return (
|
|
504
|
+
_navigation_rules_refs_from_section(rr if isinstance(rr, list) else []),
|
|
505
|
+
_navigation_rules_refs_from_section(cq if isinstance(cq, list) else []),
|
|
506
|
+
)
|
|
507
|
+
|
|
508
|
+
|
|
297
509
|
def _required_read_description_for_path(
|
|
298
|
-
p: str,
|
|
510
|
+
p: str,
|
|
511
|
+
merged_global: list[SkillFileRef],
|
|
512
|
+
local_req: list[SkillFileRef],
|
|
513
|
+
cfg_ext: ExtensionConfig,
|
|
514
|
+
nav_rules_required: list[SkillFileRef] | None = None,
|
|
299
515
|
) -> str:
|
|
516
|
+
nk = _norm_read_path(p)
|
|
517
|
+
if nav_rules_required:
|
|
518
|
+
for r in nav_rules_required:
|
|
519
|
+
if _norm_read_path(r.file) == nk and r.description:
|
|
520
|
+
return r.description
|
|
300
521
|
for r in merged_global:
|
|
301
|
-
if r.file ==
|
|
522
|
+
if _norm_read_path(r.file) == nk and r.description:
|
|
302
523
|
return r.description
|
|
303
524
|
for r in local_req:
|
|
304
|
-
if r.file ==
|
|
525
|
+
if _norm_read_path(r.file) == nk and r.description:
|
|
305
526
|
return r.description
|
|
306
527
|
return _describe_path(cfg_ext, p)
|
|
307
528
|
|
|
308
529
|
|
|
309
530
|
def generate_skills_metadata(target_root: Path, extension: str) -> list[SkillMetadata]:
|
|
310
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
|
+
|
|
311
542
|
merged_global_required = _flatten_global_refs_ordered(
|
|
312
543
|
target_root, get_required_read_global_all(target_root)
|
|
313
544
|
)
|
|
314
545
|
merged_global_auto = _flatten_global_refs_ordered(target_root, get_auto_read_global_all(target_root))
|
|
315
546
|
local_required = get_required_read_ext_local(target_root, extension)
|
|
316
547
|
local_auto = get_auto_read_local(target_root, extension)
|
|
548
|
+
nav_rules_required, nav_rules_contextual = _navigation_yaml_rules_refs(target_root)
|
|
317
549
|
|
|
318
550
|
metas: list[SkillMetadata] = []
|
|
319
551
|
for skill_path in list_skills(target_root, extension):
|
|
@@ -331,10 +563,19 @@ def generate_skills_metadata(target_root: Path, extension: str) -> list[SkillMet
|
|
|
331
563
|
continue
|
|
332
564
|
seen_norm.add(nk)
|
|
333
565
|
required_paths.append(fp)
|
|
566
|
+
for ref in nav_rules_required:
|
|
567
|
+
nk = _norm_read_path(ref.file)
|
|
568
|
+
if nk in seen_norm:
|
|
569
|
+
continue
|
|
570
|
+
seen_norm.add(nk)
|
|
571
|
+
required_paths.append(ref.file)
|
|
572
|
+
|
|
334
573
|
required_out = [
|
|
335
574
|
SkillFileRef(
|
|
336
575
|
file=p,
|
|
337
|
-
description=_required_read_description_for_path(
|
|
576
|
+
description=_required_read_description_for_path(
|
|
577
|
+
p, merged_global_required, local_required, cfg_ext, nav_rules_required
|
|
578
|
+
),
|
|
338
579
|
)
|
|
339
580
|
for p in required_paths
|
|
340
581
|
]
|
|
@@ -355,12 +596,19 @@ def generate_skills_metadata(target_root: Path, extension: str) -> list[SkillMet
|
|
|
355
596
|
continue
|
|
356
597
|
seen_auto.add(key)
|
|
357
598
|
auto_out.append(ar)
|
|
599
|
+
for ar in nav_rules_contextual:
|
|
600
|
+
key = _norm_read_path(ar.file)
|
|
601
|
+
if key in required_key_set or key in seen_auto:
|
|
602
|
+
continue
|
|
603
|
+
seen_auto.add(key)
|
|
604
|
+
auto_out.append(ar)
|
|
358
605
|
|
|
359
606
|
metas.append(
|
|
360
607
|
SkillMetadata(
|
|
361
608
|
name=raw.name,
|
|
362
609
|
description=raw.description,
|
|
363
610
|
content=raw.content,
|
|
611
|
+
hints=list(skill_hints),
|
|
364
612
|
required_read=required_out,
|
|
365
613
|
auto_read=auto_out,
|
|
366
614
|
)
|
|
@@ -458,12 +706,11 @@ def get_core_agent_ignore(target_root: Path) -> list[str]:
|
|
|
458
706
|
return list(core.agent_ignore)
|
|
459
707
|
|
|
460
708
|
|
|
461
|
-
def
|
|
462
|
-
"""
|
|
709
|
+
def sync_core_config_from_defaults(target_root: Path) -> None:
|
|
710
|
+
"""Overwrite ``spawn/.core/config.yaml`` with bundled default ``CoreConfig``.
|
|
463
711
|
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
the default set.
|
|
712
|
+
Validates that an existing repo file parses as ``CoreConfig`` before
|
|
713
|
+
replacing it so corrupted files are not silently clobbered.
|
|
467
714
|
"""
|
|
468
715
|
path = _spawn(target_root) / ".core" / "config.yaml"
|
|
469
716
|
if not path.is_file():
|
|
@@ -487,16 +734,10 @@ def merge_core_config_from_defaults(target_root: Path) -> None:
|
|
|
487
734
|
"spawn/.core/config.yaml is empty or invalid; restore it or run spawn init again."
|
|
488
735
|
)
|
|
489
736
|
try:
|
|
490
|
-
|
|
737
|
+
CoreConfig.model_validate(raw_existing)
|
|
491
738
|
except Exception as e:
|
|
492
739
|
raise SpawnError(f"invalid spawn/.core/config.yaml: {e}") from e
|
|
493
|
-
|
|
494
|
-
merged_ignore = list(default_core.agent_ignore)
|
|
495
|
-
for g in current.agent_ignore:
|
|
496
|
-
if g not in default_set:
|
|
497
|
-
merged_ignore.append(g)
|
|
498
|
-
out = CoreConfig(version=default_core.version, agent_ignore=merged_ignore)
|
|
499
|
-
save_yaml(path, out.model_dump(by_alias=True))
|
|
740
|
+
save_yaml(path, default_core.model_dump(by_alias=True))
|
|
500
741
|
|
|
501
742
|
|
|
502
743
|
def get_ext_agent_ignore(target_root: Path, extension: str) -> list[str]:
|
|
@@ -778,7 +1019,13 @@ def save_extension_navigation(
|
|
|
778
1019
|
_strip_ext_sections_inplace(rr, extension)
|
|
779
1020
|
_strip_ext_sections_inplace(rc, extension)
|
|
780
1021
|
if read_required_files:
|
|
781
|
-
|
|
1022
|
+
block: dict[str, Any] = {"ext": extension, "files": _nav_refs_to_files(read_required_files)}
|
|
1023
|
+
cfg_opt = _optional_ext_config(target_root, extension)
|
|
1024
|
+
if cfg_opt is not None:
|
|
1025
|
+
hints_nav = _normalized_extension_global_hints_for_navigation(cfg_opt)
|
|
1026
|
+
if hints_nav:
|
|
1027
|
+
block["hints"] = hints_nav
|
|
1028
|
+
rr.append(block)
|
|
782
1029
|
if read_contextual_files:
|
|
783
1030
|
req_norm = {_norm_read_path(r.file) for r in read_required_files}
|
|
784
1031
|
contextual_only = [
|
|
@@ -852,7 +1099,14 @@ def save_rules_navigation(target_root: Path) -> None:
|
|
|
852
1099
|
rk = posix_norm(str(entry['path']))
|
|
853
1100
|
exists = rk in paths_on_disk and (Path(target_root) / Path(rk)).is_file()
|
|
854
1101
|
if exists:
|
|
855
|
-
|
|
1102
|
+
row: dict[str, Any] = {
|
|
1103
|
+
'path': rk,
|
|
1104
|
+
'description': str(entry.get('description') or 'Local rule file.'),
|
|
1105
|
+
}
|
|
1106
|
+
hint_val = entry.get('hint')
|
|
1107
|
+
if isinstance(hint_val, str):
|
|
1108
|
+
row['hint'] = hint_val
|
|
1109
|
+
out.append(row)
|
|
856
1110
|
else:
|
|
857
1111
|
warnings.warn(
|
|
858
1112
|
f"Removed missing rule path from navigation: {rk}",
|
|
@@ -929,7 +1183,7 @@ __all__ = [
|
|
|
929
1183
|
"list_ides",
|
|
930
1184
|
"list_mcp",
|
|
931
1185
|
"list_skills",
|
|
932
|
-
"
|
|
1186
|
+
"sync_core_config_from_defaults",
|
|
933
1187
|
"METADATA_TEMP_MAX_AGE_SECONDS",
|
|
934
1188
|
"normalize_skill_name",
|
|
935
1189
|
"prune_metadata_temp",
|
|
@@ -937,6 +1191,7 @@ __all__ = [
|
|
|
937
1191
|
"remove_from_global_gitignore",
|
|
938
1192
|
"remove_ide_metadata_dir",
|
|
939
1193
|
"remove_ide_from_list",
|
|
1194
|
+
"rollup_hints_for_agents",
|
|
940
1195
|
"save_agent_ignore_list",
|
|
941
1196
|
"save_extension_navigation",
|
|
942
1197
|
"save_git_ignore_list",
|
|
@@ -945,5 +1200,6 @@ __all__ = [
|
|
|
945
1200
|
"save_skills_rendered",
|
|
946
1201
|
"supported_ide_keys",
|
|
947
1202
|
"validate_rendered_identity",
|
|
1203
|
+
"warn_if_agents_hints_exceed_measurement",
|
|
948
1204
|
]
|
|
949
1205
|
|
|
@@ -36,7 +36,8 @@ def _mandatory_reads_for_render(skill: SkillMetadata) -> list[SkillFileRef]:
|
|
|
36
36
|
|
|
37
37
|
def render_skill_md(skill: SkillMetadata) -> str:
|
|
38
38
|
"""Render normalized skill to Markdown with frontmatter, skill body,
|
|
39
|
-
mandatory reads (navigation path last),
|
|
39
|
+
hints (stable order from metadata), mandatory reads (navigation path last),
|
|
40
|
+
and contextual reads."""
|
|
40
41
|
lines = [
|
|
41
42
|
"---",
|
|
42
43
|
f"name: {skill.name}",
|
|
@@ -45,8 +46,13 @@ def render_skill_md(skill: SkillMetadata) -> str:
|
|
|
45
46
|
"",
|
|
46
47
|
skill.content,
|
|
47
48
|
"",
|
|
48
|
-
"Mandatory reads:",
|
|
49
49
|
]
|
|
50
|
+
if skill.hints:
|
|
51
|
+
lines += ["Hints:"]
|
|
52
|
+
for h in skill.hints:
|
|
53
|
+
lines += [f"- {h}"]
|
|
54
|
+
lines += [""]
|
|
55
|
+
lines += ["Mandatory reads:"]
|
|
50
56
|
for ref in _mandatory_reads_for_render(skill):
|
|
51
57
|
lines += [f"- `{ref.file}` - {ref.description}"]
|
|
52
58
|
lines += [""]
|
|
@@ -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.9
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|