diff --git a/docs/SOLCLI.md b/docs/SOLCLI.md index 29fe1e4f9..294e15b24 100644 --- a/docs/SOLCLI.md +++ b/docs/SOLCLI.md @@ -132,7 +132,9 @@ def list_items( 2. **That's it for the CLI.** Auto-discovery picks it up on next run. -3. **Update the app command fragment** (if agents should use these commands): +3. **Update the app command fragment** (if agents should use these commands). + `sol skills build` discovers these fragments automatically from + `solstone/apps/*/talent/*/SKILL.md`: ```markdown # solstone/apps/myapp/talent/myapp/SKILL.md diff --git a/solstone/talent/journal/SKILL.md b/solstone/talent/journal/SKILL.md index cbd5f158e..058d149df 100644 --- a/solstone/talent/journal/SKILL.md +++ b/solstone/talent/journal/SKILL.md @@ -2,17 +2,22 @@ name: journal description: > Search the journal, list facets, and explain how the journal is laid out - on disk — captures, extracts, talent outputs, apps, facets, and the - search index. Also covers the `sol call journal` CLI. - TRIGGER: journal, journal layout, search journal, find meeting, list - facets, show agent output, captures, extracts, talents, apps, facet, - indexer, activity records, sol call journal, sol call journal search, - sol call journal facet. + on disk — captures, extracts, talent outputs, apps, facets, and the search + index. Covers host commands such as `journal setup`, `journal doctor`, + `journal service`, `journal health`, `journal talent`, and `journal + identity`, plus the `sol call journal` CLI. + TRIGGER: journal, journal setup, journal doctor, journal service, journal + health, journal talent, journal identity, journal layout, search journal, + find meeting, list facets, show agent output, captures, extracts, talents, + apps, facet, indexer, activity records, sol call journal, sol call journal + search, sol call journal facet. --- # Journal Skill -Explore journal layout and run `sol call journal` CLI work. Invoke via Bash: `sol call journal [args...]`. +Operate the local journal host and explore journal layout. Use this skill for +`journal ` runtime/setup work and for `sol call journal ` +content queries. ## Overview @@ -30,6 +35,40 @@ A journal is the on-disk record of captures, extracts, facet data, app storage, For the full pipeline, see [captures](references/captures.md). +## Host CLI + +Use host commands from the journal machine: + +```bash +journal setup +journal doctor +journal service status +journal service logs +journal health +journal talent logs +journal identity +``` + +Boundaries: + +- `journal setup` owns first-run setup and repair: config, models, wrappers, + service units, and the `sol` + `journal` router skill links. +- `journal doctor` examines only. It can warn when router skills are missing, + stale, or pointed at the wrong source, but it does not repair them. +- `journal start` starts the supervisor runtime only. It must not initialize + config, refresh wrappers, repair service units, rebuild references, or fix + skills. +- `journal service ...` owns service lifecycle: status, start, stop, restart, + install, uninstall, and logs. `journal up` / `journal down` are aliases for + service start/stop. +- `journal health` and `journal talent ...` are troubleshooting surfaces for + supervisor health, logs, pipeline state, and talent run history. +- `journal identity` owns local owner/sol identity operations; use its help + output before changing identity data. + +For app-contributed host command guidance, see +[Commands](references/commands.md). + ## Vocabulary | Term | Definition | Examples | diff --git a/solstone/think/skills_build.py b/solstone/think/skills_build.py index 5ef12c06b..f1261e645 100644 --- a/solstone/think/skills_build.py +++ b/solstone/think/skills_build.py @@ -19,14 +19,9 @@ from solstone.think.command_polarity import classify_verb ROOT = Path(__file__).resolve().parents[2] -FRAGMENT_SOURCES: dict[str, Path] = { - "activities": Path("solstone/apps/activities/talent/activities/SKILL.md"), - "entities": Path("solstone/apps/entities/talent/entities/SKILL.md"), - "health": Path("solstone/apps/health/talent/health/SKILL.md"), - "speakers": Path("solstone/apps/speakers/talent/speakers/SKILL.md"), - "support": Path("solstone/apps/support/talent/support/SKILL.md"), - "transcripts": Path("solstone/apps/transcripts/talent/transcripts/SKILL.md"), -} +# Test hook: leave as None in production so new app fragments are discovered at +# build/check time instead of being registered in a second source list. +FRAGMENT_SOURCES: tuple[Path, ...] | None = None HEALTH_JOURNAL_NAMESPACES = ("journal health", "journal talent") JOURNAL_NAMESPACE_CONTRIBUTIONS = {"health": HEALTH_JOURNAL_NAMESPACES} @@ -59,6 +54,25 @@ def _repo_relative(path: Path) -> str: return path.as_posix() +def discover_fragment_sources(root: Path | None = None) -> tuple[Path, ...]: + """Return app-owned skill fragments in deterministic repo-relative order.""" + base = root or ROOT + apps_dir = base / "solstone" / "apps" + return tuple( + sorted( + path.relative_to(base) + for path in apps_dir.glob("*/talent/*/SKILL.md") + if path.is_file() + ) + ) + + +def _fragment_sources() -> tuple[Path, ...]: + if FRAGMENT_SOURCES is not None: + return tuple(FRAGMENT_SOURCES) + return discover_fragment_sources(ROOT) + + def _parse_triggers(description: str) -> tuple[str, ...]: marker = "TRIGGER:" if marker not in description: @@ -106,7 +120,10 @@ def _load_fragment(rel_path: Path) -> Fragment: def _load_fragments() -> dict[str, Fragment]: fragments: dict[str, Fragment] = {} seen_names: dict[str, Path] = {} - for app_key, rel_path in FRAGMENT_SOURCES.items(): + source_paths = _fragment_sources() + if not source_paths: + raise ValueError(f"{ROOT / 'solstone' / 'apps'}: no app skill fragments found") + for rel_path in source_paths: fragment = _load_fragment(rel_path) previous = seen_names.get(fragment.app) if previous is not None: @@ -114,11 +131,6 @@ def _load_fragments() -> dict[str, Fragment]: f"{fragment.path}: duplicate app key {fragment.app!r}: " f"{previous} and {fragment.path}" ) - if fragment.app != app_key: - raise ValueError( - f"{fragment.path}: fragment key {app_key!r} does not match name " - f"{fragment.app!r}" - ) seen_names[fragment.app] = fragment.path fragments[fragment.app] = fragment return fragments diff --git a/tests/test_skills_build.py b/tests/test_skills_build.py index abc6ec49d..5c36bba9c 100644 --- a/tests/test_skills_build.py +++ b/tests/test_skills_build.py @@ -17,7 +17,7 @@ REAL_ROOT = Path(__file__).resolve().parents[1] def _copy_fragment_tree(tmp_path: Path) -> Path: root = tmp_path / "repo" - for rel_path in skills_build.FRAGMENT_SOURCES.values(): + for rel_path in skills_build.discover_fragment_sources(REAL_ROOT): src = REAL_ROOT / rel_path dst = root / rel_path dst.parent.mkdir(parents=True, exist_ok=True) @@ -91,7 +91,7 @@ def test_malformed_fragment_raises_with_path_without_partial_output( ): root = _copy_fragment_tree(tmp_path) _patch_root(monkeypatch, root) - target = root / skills_build.FRAGMENT_SOURCES["activities"] + target = root / "solstone/apps/activities/talent/activities/SKILL.md" target.write_text(content, encoding="utf-8") with pytest.raises(ValueError) as exc_info: @@ -111,9 +111,10 @@ def test_duplicate_fragment_name_raises_with_path_without_partial_output( duplicate = Path("solstone/apps/entities-copy/talent/entities/SKILL.md") duplicate_path = root / duplicate duplicate_path.parent.mkdir(parents=True, exist_ok=True) - shutil.copy2(root / skills_build.FRAGMENT_SOURCES["entities"], duplicate_path) - sources = dict(skills_build.FRAGMENT_SOURCES) - sources["entities_copy"] = duplicate + shutil.copy2( + root / "solstone/apps/entities/talent/entities/SKILL.md", duplicate_path + ) + sources = skills_build.discover_fragment_sources(root) + (duplicate,) monkeypatch.setattr(skills_build, "FRAGMENT_SOURCES", sources) with pytest.raises(ValueError) as exc_info: @@ -152,8 +153,39 @@ def test_health_contributes_to_both_router_references(monkeypatch, tmp_path): assert "Guidance: `solstone/apps/health/talent/health/SKILL.md`" in journal +def test_fragments_are_discovered_from_app_talent_dirs(monkeypatch, tmp_path): + root = _copy_fragment_tree(tmp_path) + extra = root / "solstone/apps/newapp/talent/newapp/SKILL.md" + extra.parent.mkdir(parents=True) + extra.write_text( + "---\n" + "name: newapp\n" + "description: >\n" + " New app commands. TRIGGER: new app, extra route.\n" + "---\n" + "\n" + "# New App\n", + encoding="utf-8", + ) + _patch_root(monkeypatch, root) + monkeypatch.setattr( + skills_build, + "_command_names", + lambda app_name, _source: ("list",) if app_name == "newapp" else ("status",), + ) + + outputs = skills_build.render() + sol = outputs[str(root / skills_build.SOL_COMMANDS_PATH)] + + assert "## newapp — `sol call newapp`" in sol + assert "Triggers: `new app`, `extra route`" in sol + assert "Guidance: `solstone/apps/newapp/talent/newapp/SKILL.md`" in sol + + def test_trigger_parsing_matches_fragment_description(): - post = frontmatter.load(REAL_ROOT / skills_build.FRAGMENT_SOURCES["activities"]) + post = frontmatter.load( + REAL_ROOT / "solstone/apps/activities/talent/activities/SKILL.md" + ) assert skills_build._parse_triggers(post.metadata["description"]) == ( "activity",