personal memory agent
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

apps/skills,think: owner-wide skills CLI + helpers (lode A)

Lands the storage layout, shared helpers, and `sol call skills` CLI
verbs (list, show, observe, seed, promote, refresh, mark-dormant,
retire, edit-request, rename) as the foundation for the skills
observer/editor refactor. Zero user-visible change — the old
per-activity talent and per-facet data stay dormant on disk; the new
owner-wide journal/skills/{patterns.jsonl,edit_requests.jsonl,*.md}
starts empty. Writes are fcntl-locked, atomic, and idempotent. Lode B
will add the observer/editor talents and cut Pulse over to read the
new storage.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

+1451
+4
apps/skills/__init__.py
··· 1 + # SPDX-License-Identifier: AGPL-3.0-only 2 + # Copyright (c) 2026 sol pbc 3 + 4 + """Skills app package."""
+476
apps/skills/call.py
··· 1 + # SPDX-License-Identifier: AGPL-3.0-only 2 + # Copyright (c) 2026 sol pbc 3 + 4 + """CLI commands for owner-wide skill patterns and edit requests. 5 + 6 + Auto-discovered by ``think.call`` and mounted as ``sol call skills ...``. 7 + """ 8 + 9 + from __future__ import annotations 10 + 11 + import json 12 + import logging 13 + from typing import Any, Callable 14 + 15 + import typer 16 + 17 + from think.skills import ( 18 + find_pattern, 19 + load_patterns, 20 + load_profile, 21 + locked_modify_edit_requests, 22 + locked_modify_patterns, 23 + make_request_id, 24 + observation_key, 25 + profile_path, 26 + rename_profile, 27 + touch_updated, 28 + utc_now_iso, 29 + ) 30 + from think.utils import require_solstone 31 + 32 + logger = logging.getLogger(__name__) 33 + 34 + app = typer.Typer(help="Owner-wide skill patterns and edit requests.") 35 + 36 + 37 + class _PatternCommandError(Exception): 38 + """Internal control-flow error carrying a CLI message and exit code.""" 39 + 40 + def __init__(self, message: str, exit_code: int) -> None: 41 + super().__init__(message) 42 + self.message = message 43 + self.exit_code = exit_code 44 + 45 + 46 + @app.callback() 47 + def _require_up() -> None: 48 + require_solstone() 49 + 50 + 51 + def _echo_json(payload: Any) -> None: 52 + typer.echo(json.dumps(payload, indent=2, ensure_ascii=False)) 53 + 54 + 55 + def _exit_with_message(message: str, *, code: int) -> None: 56 + typer.echo(message, err=True) 57 + raise typer.Exit(code=code) 58 + 59 + 60 + def _parse_activity_ids(raw_value: str) -> list[str]: 61 + activity_ids = [item.strip() for item in raw_value.split(",") if item.strip()] 62 + if not activity_ids: 63 + typer.echo("Error: --activity-ids requires at least one id.", err=True) 64 + raise typer.Exit(1) from None 65 + return activity_ids 66 + 67 + 68 + def _parse_status_filter(raw_value: str | None) -> set[str] | None: 69 + if raw_value is None: 70 + return None 71 + statuses = {item.strip() for item in raw_value.split(",") if item.strip()} 72 + return statuses or None 73 + 74 + 75 + def _pattern_observation_key( 76 + pattern: dict[str, Any], observation: dict[str, Any] 77 + ) -> str: 78 + return observation_key( 79 + str(pattern.get("slug") or ""), 80 + str(observation.get("day") or ""), 81 + [str(item) for item in observation.get("activity_ids", [])], 82 + ) 83 + 84 + 85 + def _recompute_derived_fields(pattern: dict[str, Any]) -> None: 86 + observations = pattern.get("observations", []) 87 + facets = sorted( 88 + { 89 + str(observation.get("facet") or "") 90 + for observation in observations 91 + if observation.get("facet") 92 + } 93 + ) 94 + days = sorted( 95 + str(observation.get("day") or "") 96 + for observation in observations 97 + if observation.get("day") 98 + ) 99 + pattern["facets_touched"] = facets 100 + if days: 101 + pattern["first_seen"] = days[0] 102 + pattern["last_seen"] = days[-1] 103 + 104 + 105 + def _emit_pattern_result( 106 + pattern: dict[str, Any], *, json_output: bool, text_message: str 107 + ) -> None: 108 + if json_output: 109 + _echo_json(pattern) 110 + return 111 + typer.echo(text_message) 112 + 113 + 114 + def _locked_update_pattern( 115 + slug: str, mutate_fn: Callable[[dict[str, Any]], None] 116 + ) -> dict[str, Any]: 117 + updated_pattern: dict[str, Any] | None = None 118 + 119 + def mutate(rows: list[dict[str, Any]]) -> list[dict[str, Any]]: 120 + nonlocal updated_pattern 121 + pattern = find_pattern(slug, rows) 122 + if pattern is None: 123 + raise _PatternCommandError("no such skill", 1) 124 + mutate_fn(pattern) 125 + updated_pattern = pattern 126 + return rows 127 + 128 + try: 129 + locked_modify_patterns(mutate) 130 + except _PatternCommandError as exc: 131 + _exit_with_message(exc.message, code=exc.exit_code) 132 + 133 + if updated_pattern is None: # pragma: no cover - defensive assertion 134 + raise RuntimeError(f"pattern mutation produced no row for slug {slug}") 135 + return updated_pattern 136 + 137 + 138 + @app.command("list") 139 + def list_skills( 140 + status: str | None = typer.Option( 141 + None, 142 + "--status", 143 + help="Filter by one status or a comma-separated list of statuses.", 144 + ), 145 + json_output: bool = typer.Option(False, "--json", help="Output as JSON."), 146 + ) -> None: 147 + """List owner-wide skill patterns.""" 148 + rows = load_patterns() 149 + status_filter = _parse_status_filter(status) 150 + if status_filter is not None: 151 + rows = [row for row in rows if str(row.get("status") or "") in status_filter] 152 + 153 + if json_output: 154 + _echo_json(rows) 155 + return 156 + 157 + for row in rows: 158 + slug = str(row.get("slug") or "")[:40] 159 + status_value = str(row.get("status") or "")[:10] 160 + observations = row.get("observations", []) 161 + last_seen = str(row.get("last_seen") or "") 162 + facets = ",".join(str(item) for item in row.get("facets_touched", [])) 163 + typer.echo( 164 + f"{slug:<40} {status_value:<10} " 165 + f"obs={len(observations):<3} last={last_seen} facets={facets}" 166 + ) 167 + 168 + 169 + @app.command("show") 170 + def show_skill( 171 + slug: str = typer.Argument(help="Skill slug."), 172 + json_output: bool = typer.Option(False, "--json", help="Output as JSON."), 173 + ) -> None: 174 + """Show one owner-wide skill pattern and its profile.""" 175 + pattern = find_pattern(slug) 176 + if pattern is None: 177 + _exit_with_message("no such skill", code=1) 178 + 179 + profile = load_profile(slug) 180 + if json_output: 181 + _echo_json({"pattern": pattern, "profile": profile}) 182 + return 183 + 184 + typer.echo(f"name: {pattern.get('name', '')}") 185 + typer.echo(f"slug: {pattern.get('slug', '')}") 186 + typer.echo(f"status: {pattern.get('status', '')}") 187 + typer.echo(f"first_seen: {pattern.get('first_seen', '')}") 188 + typer.echo(f"last_seen: {pattern.get('last_seen', '')}") 189 + typer.echo(f"obs_count: {len(pattern.get('observations', []))}") 190 + typer.echo(f"facets_touched: {','.join(pattern.get('facets_touched', []))}") 191 + observations = sorted( 192 + pattern.get("observations", []), 193 + key=lambda observation: ( 194 + str(observation.get("day", "")), 195 + str(observation.get("recorded_at", "")), 196 + ), 197 + ) 198 + for observation in observations: 199 + activity_ids = ",".join( 200 + str(item) for item in observation.get("activity_ids", []) 201 + ) 202 + notes = str(observation.get("notes") or "") 203 + typer.echo( 204 + f"- {observation.get('day', '')} [{observation.get('facet', '')}] " 205 + f"activity_ids={activity_ids} notes={notes}" 206 + ) 207 + if profile is not None: 208 + typer.echo("---") 209 + typer.echo(profile.rstrip("\n")) 210 + 211 + 212 + @app.command("observe") 213 + def observe_skill( 214 + slug: str = typer.Argument(help="Skill slug."), 215 + day: str = typer.Option(..., "--day", help="Observation day in YYYY-MM-DD format."), 216 + facet: str = typer.Option(..., "--facet", help="Facet name."), 217 + activity_ids: str = typer.Option( 218 + ..., 219 + "--activity-ids", 220 + help="Comma-separated activity ids.", 221 + ), 222 + notes: str = typer.Option("", "--notes", help="Optional observation notes."), 223 + json_output: bool = typer.Option(False, "--json", help="Output as JSON."), 224 + ) -> None: 225 + """Record one new observation for an existing skill.""" 226 + normalized_activity_ids = _parse_activity_ids(activity_ids) 227 + target_key = observation_key(slug, day, normalized_activity_ids) 228 + 229 + def mutate(pattern: dict[str, Any]) -> None: 230 + existing = pattern.get("observations", []) 231 + if any( 232 + _pattern_observation_key(pattern, observation) == target_key 233 + for observation in existing 234 + ): 235 + raise _PatternCommandError("already recorded", 0) 236 + existing.append( 237 + { 238 + "day": day, 239 + "facet": facet, 240 + "activity_ids": normalized_activity_ids, 241 + "notes": notes, 242 + "recorded_at": utc_now_iso(), 243 + } 244 + ) 245 + _recompute_derived_fields(pattern) 246 + if pattern.get("status") == "dormant": 247 + pattern["status"] = "mature" 248 + touch_updated(pattern) 249 + 250 + pattern = _locked_update_pattern(slug, mutate) 251 + _emit_pattern_result( 252 + pattern, 253 + json_output=json_output, 254 + text_message=f"recorded observation: {slug}", 255 + ) 256 + 257 + 258 + @app.command("seed") 259 + def seed_skill( 260 + slug: str = typer.Argument(help="Skill slug."), 261 + name: str = typer.Option(..., "--name", help="Human-readable skill name."), 262 + day: str = typer.Option(..., "--day", help="Observation day in YYYY-MM-DD format."), 263 + facet: str = typer.Option(..., "--facet", help="Facet name."), 264 + activity_ids: str = typer.Option( 265 + ..., 266 + "--activity-ids", 267 + help="Comma-separated activity ids.", 268 + ), 269 + notes: str = typer.Option("", "--notes", help="Optional observation notes."), 270 + json_output: bool = typer.Option(False, "--json", help="Output as JSON."), 271 + ) -> None: 272 + """Seed one new emerging skill pattern.""" 273 + normalized_activity_ids = _parse_activity_ids(activity_ids) 274 + created_pattern: dict[str, Any] | None = None 275 + created_at = utc_now_iso() 276 + 277 + def mutate(rows: list[dict[str, Any]]) -> list[dict[str, Any]]: 278 + nonlocal created_pattern 279 + if find_pattern(slug, rows) is not None: 280 + raise _PatternCommandError("slug already exists", 1) 281 + created_pattern = { 282 + "slug": slug, 283 + "name": name, 284 + "status": "emerging", 285 + "observations": [ 286 + { 287 + "day": day, 288 + "facet": facet, 289 + "activity_ids": normalized_activity_ids, 290 + "notes": notes, 291 + "recorded_at": created_at, 292 + } 293 + ], 294 + "facets_touched": [facet], 295 + "first_seen": day, 296 + "last_seen": day, 297 + "needs_profile": False, 298 + "needs_refresh": False, 299 + "profile_generated_at": None, 300 + "created_at": created_at, 301 + "updated_at": created_at, 302 + } 303 + rows = list(rows) 304 + rows.append(created_pattern) 305 + return rows 306 + 307 + try: 308 + locked_modify_patterns(mutate) 309 + except _PatternCommandError as exc: 310 + _exit_with_message(exc.message, code=exc.exit_code) 311 + 312 + if created_pattern is None: # pragma: no cover - defensive assertion 313 + raise RuntimeError(f"seed did not create pattern {slug}") 314 + _emit_pattern_result( 315 + created_pattern, 316 + json_output=json_output, 317 + text_message=f"created skill: {slug}", 318 + ) 319 + 320 + 321 + @app.command("promote") 322 + def promote_skill( 323 + slug: str = typer.Argument(help="Skill slug."), 324 + json_output: bool = typer.Option(False, "--json", help="Output as JSON."), 325 + ) -> None: 326 + """Flag one skill for profile generation.""" 327 + 328 + def mutate(pattern: dict[str, Any]) -> None: 329 + if pattern.get("status") == "mature": 330 + raise _PatternCommandError("already mature", 0) 331 + if bool(pattern.get("needs_profile")): 332 + raise _PatternCommandError("already flagged", 0) 333 + pattern["needs_profile"] = True 334 + touch_updated(pattern) 335 + 336 + pattern = _locked_update_pattern(slug, mutate) 337 + _emit_pattern_result( 338 + pattern, 339 + json_output=json_output, 340 + text_message=f"flagged for profile: {slug}", 341 + ) 342 + 343 + 344 + @app.command("refresh") 345 + def refresh_skill( 346 + slug: str = typer.Argument(help="Skill slug."), 347 + json_output: bool = typer.Option(False, "--json", help="Output as JSON."), 348 + ) -> None: 349 + """Flag one mature skill for profile refresh.""" 350 + 351 + def mutate(pattern: dict[str, Any]) -> None: 352 + if pattern.get("status") != "mature": 353 + raise _PatternCommandError("not mature", 1) 354 + if bool(pattern.get("needs_refresh")): 355 + raise _PatternCommandError("already flagged", 0) 356 + pattern["needs_refresh"] = True 357 + touch_updated(pattern) 358 + 359 + pattern = _locked_update_pattern(slug, mutate) 360 + _emit_pattern_result( 361 + pattern, 362 + json_output=json_output, 363 + text_message=f"flagged for refresh: {slug}", 364 + ) 365 + 366 + 367 + @app.command("mark-dormant") 368 + def mark_dormant_skill( 369 + slug: str = typer.Argument(help="Skill slug."), 370 + json_output: bool = typer.Option(False, "--json", help="Output as JSON."), 371 + ) -> None: 372 + """Mark one skill dormant.""" 373 + 374 + def mutate(pattern: dict[str, Any]) -> None: 375 + if pattern.get("status") == "dormant": 376 + raise _PatternCommandError("already flagged", 0) 377 + pattern["status"] = "dormant" 378 + touch_updated(pattern) 379 + 380 + pattern = _locked_update_pattern(slug, mutate) 381 + _emit_pattern_result( 382 + pattern, 383 + json_output=json_output, 384 + text_message=f"marked dormant: {slug}", 385 + ) 386 + 387 + 388 + @app.command("retire") 389 + def retire_skill( 390 + slug: str = typer.Argument(help="Skill slug."), 391 + json_output: bool = typer.Option(False, "--json", help="Output as JSON."), 392 + ) -> None: 393 + """Mark one skill retired.""" 394 + 395 + def mutate(pattern: dict[str, Any]) -> None: 396 + if pattern.get("status") == "retired": 397 + raise _PatternCommandError("already flagged", 0) 398 + pattern["status"] = "retired" 399 + touch_updated(pattern) 400 + 401 + pattern = _locked_update_pattern(slug, mutate) 402 + _emit_pattern_result( 403 + pattern, 404 + json_output=json_output, 405 + text_message=f"retired skill: {slug}", 406 + ) 407 + 408 + 409 + @app.command("edit-request") 410 + def edit_request_skill( 411 + slug: str = typer.Argument(help="Skill slug."), 412 + instructions: str = typer.Option(..., "--instructions", help="Edit instructions."), 413 + requested_by: str = typer.Option("chat", "--requested-by", help="Request source."), 414 + json_output: bool = typer.Option(False, "--json", help="Output as JSON."), 415 + ) -> None: 416 + """Append one owner-authored edit request for a skill.""" 417 + if find_pattern(slug) is None: 418 + _exit_with_message("no such skill", code=1) 419 + 420 + request_id = make_request_id() 421 + request = { 422 + "id": request_id, 423 + "slug": slug, 424 + "instructions": instructions, 425 + "requested_at": utc_now_iso(), 426 + "requested_by": requested_by, 427 + "processed_at": None, 428 + } 429 + 430 + def mutate(rows: list[dict[str, Any]]) -> list[dict[str, Any]]: 431 + next_rows = list(rows) 432 + next_rows.append(request) 433 + return next_rows 434 + 435 + locked_modify_edit_requests(mutate) 436 + 437 + if json_output: 438 + _echo_json({"request_id": request_id, "slug": slug}) 439 + return 440 + typer.echo(f"request_id: {request_id}") 441 + 442 + 443 + @app.command("rename") 444 + def rename_skill( 445 + old_slug: str = typer.Argument(help="Existing skill slug."), 446 + new_slug: str = typer.Argument(help="New skill slug."), 447 + json_output: bool = typer.Option(False, "--json", help="Output as JSON."), 448 + ) -> None: 449 + """Rename one skill slug and move its profile if present.""" 450 + patterns = load_patterns() 451 + if find_pattern(old_slug, patterns) is None: 452 + _exit_with_message("no such skill", code=1) 453 + if find_pattern(new_slug, patterns) is not None or profile_path(new_slug).exists(): 454 + _exit_with_message("new slug already exists", code=1) 455 + 456 + rename_profile(old_slug, new_slug) 457 + 458 + try: 459 + pattern = _locked_update_pattern( 460 + old_slug, 461 + lambda row: (row.__setitem__("slug", new_slug), touch_updated(row)), 462 + ) 463 + except Exception: 464 + logger.error( 465 + "skills: rename_pattern failed after profile move %s -> %s", 466 + old_slug, 467 + new_slug, 468 + exc_info=True, 469 + ) 470 + raise 471 + 472 + _emit_pattern_result( 473 + pattern, 474 + json_output=json_output, 475 + text_message=f"renamed skill: {new_slug}", 476 + )
+1
docs/SOLCLI.md
··· 309 309 | `activities` | `apps/activities/call.py` | list, get, create, update, mute, unmute | 310 310 | `entities` | `apps/entities/call.py` | list, show, search, observe, merge | 311 311 | `speakers` | `apps/speakers/call.py` | list, show, detect-owner, confirm-owner, clusters, suggest | 312 + | `skills` | `apps/skills/call.py` | list, show, observe, seed, promote, refresh, mark-dormant, retire, edit-request, rename | 312 313 | `transcripts` | `apps/transcripts/call.py` | list, read, segments | 313 314 | `support` | `apps/support/call.py` | register, search, article, create, list, show, reply, attach, feedback, announcements, diagnose | 314 315 | `sol` | `apps/sol/call.py` | name, set-name, reset, thickness, set-owner, sol-init |
+536
tests/test_apps_skills_call.py
··· 1 + # SPDX-License-Identifier: AGPL-3.0-only 2 + # Copyright (c) 2026 sol pbc 3 + 4 + from __future__ import annotations 5 + 6 + import json 7 + from pathlib import Path 8 + 9 + import pytest 10 + from typer.testing import CliRunner 11 + 12 + from apps.skills.call import app as skills_app 13 + from think.call import call_app 14 + from think.skills import ( 15 + load_edit_requests, 16 + load_patterns, 17 + locked_modify_patterns, 18 + profile_path, 19 + save_patterns, 20 + save_profile, 21 + ) 22 + 23 + runner = CliRunner() 24 + 25 + 26 + @pytest.fixture 27 + def skill_cli_env(monkeypatch, tmp_path): 28 + monkeypatch.setenv("_SOLSTONE_JOURNAL_OVERRIDE", str(tmp_path)) 29 + return Path(tmp_path) 30 + 31 + 32 + def _make_pattern( 33 + *, 34 + slug: str = "alpha-skill", 35 + name: str = "Alpha Skill", 36 + status: str = "emerging", 37 + day: str = "2026-04-19", 38 + facet: str = "work", 39 + activity_ids: list[str] | None = None, 40 + notes: str = "", 41 + needs_profile: bool = False, 42 + needs_refresh: bool = False, 43 + profile_generated_at: str | None = None, 44 + created_at: str = "2026-04-19T14:22:00Z", 45 + updated_at: str = "2026-04-19T14:22:00Z", 46 + ) -> dict: 47 + ids = ["act_abc"] if activity_ids is None else activity_ids 48 + observations = [ 49 + { 50 + "day": day, 51 + "facet": facet, 52 + "activity_ids": ids, 53 + "notes": notes, 54 + "recorded_at": created_at, 55 + } 56 + ] 57 + return { 58 + "slug": slug, 59 + "name": name, 60 + "status": status, 61 + "observations": observations, 62 + "facets_touched": [facet], 63 + "first_seen": day, 64 + "last_seen": day, 65 + "needs_profile": needs_profile, 66 + "needs_refresh": needs_refresh, 67 + "profile_generated_at": profile_generated_at, 68 + "created_at": created_at, 69 + "updated_at": updated_at, 70 + } 71 + 72 + 73 + def _seed_patterns(*rows: dict) -> None: 74 + save_patterns(list(rows)) 75 + 76 + 77 + def _invoke(*args: str): 78 + return runner.invoke(call_app, ["skills", *args]) 79 + 80 + 81 + def test_skills_app_has_ten_registered_commands(skill_cli_env): 82 + command_names = {command.name for command in skills_app.registered_commands} 83 + 84 + assert len(skills_app.registered_commands) == 10 85 + assert command_names == { 86 + "list", 87 + "show", 88 + "observe", 89 + "seed", 90 + "promote", 91 + "refresh", 92 + "mark-dormant", 93 + "retire", 94 + "edit-request", 95 + "rename", 96 + } 97 + 98 + 99 + def test_list_empty_text_output(skill_cli_env): 100 + result = _invoke("list") 101 + 102 + assert result.exit_code == 0 103 + assert result.output == "" 104 + 105 + 106 + def test_list_empty_json_output(skill_cli_env): 107 + result = _invoke("list", "--json") 108 + 109 + assert result.exit_code == 0 110 + assert json.loads(result.output) == [] 111 + 112 + 113 + def test_list_filters_by_status(skill_cli_env): 114 + _seed_patterns( 115 + _make_pattern(slug="alpha-skill"), 116 + _make_pattern(slug="beta-skill", status="dormant"), 117 + ) 118 + 119 + result = _invoke("list", "--status", "dormant") 120 + 121 + assert result.exit_code == 0 122 + assert "beta-skill" in result.output 123 + assert "alpha-skill" not in result.output 124 + 125 + 126 + def test_list_filters_by_status_comma_separated(skill_cli_env): 127 + _seed_patterns( 128 + _make_pattern(slug="alpha-skill", status="mature"), 129 + _make_pattern(slug="beta-skill", status="dormant"), 130 + _make_pattern(slug="gamma-skill", status="retired"), 131 + ) 132 + 133 + result = _invoke("list", "--status", "mature,dormant") 134 + 135 + assert result.exit_code == 0 136 + assert "alpha-skill" in result.output 137 + assert "beta-skill" in result.output 138 + assert "gamma-skill" not in result.output 139 + 140 + 141 + def test_show_missing_slug(skill_cli_env): 142 + result = _invoke("show", "missing-skill") 143 + 144 + assert result.exit_code == 1 145 + assert "no such skill" in result.stderr 146 + 147 + 148 + def test_show_renders_pattern(skill_cli_env): 149 + _seed_patterns( 150 + _make_pattern( 151 + slug="alpha-skill", 152 + name="Alpha Skill", 153 + activity_ids=["act_abc", "act_def"], 154 + notes="Observed in review", 155 + ) 156 + ) 157 + save_profile("alpha-skill", "# Alpha Skill\n") 158 + 159 + result = _invoke("show", "alpha-skill") 160 + 161 + assert result.exit_code == 0 162 + assert "name: Alpha Skill" in result.output 163 + assert "slug: alpha-skill" in result.output 164 + assert ( 165 + "- 2026-04-19 [work] activity_ids=act_abc,act_def notes=Observed in review" 166 + in result.output 167 + ) 168 + assert "# Alpha Skill" in result.output 169 + 170 + 171 + def test_show_json_shape(skill_cli_env): 172 + _seed_patterns(_make_pattern()) 173 + save_profile("alpha-skill", "# Alpha Skill\n") 174 + 175 + result = _invoke("show", "alpha-skill", "--json") 176 + 177 + payload = json.loads(result.output) 178 + assert result.exit_code == 0 179 + assert set(payload) == {"pattern", "profile"} 180 + assert payload["pattern"]["slug"] == "alpha-skill" 181 + assert payload["profile"] == "# Alpha Skill\n" 182 + 183 + 184 + def test_show_sorts_observations_for_text_output(skill_cli_env): 185 + _seed_patterns(_make_pattern(day="2026-04-20", created_at="2026-04-20T10:00:00Z")) 186 + 187 + def mutate(rows): 188 + rows = list(rows) 189 + rows[0]["observations"].append( 190 + { 191 + "day": "2026-04-19", 192 + "facet": "solpbc", 193 + "activity_ids": ["act_older"], 194 + "notes": "Earlier observation", 195 + "recorded_at": "2026-04-19T09:00:00Z", 196 + } 197 + ) 198 + rows[0]["first_seen"] = "2026-04-19" 199 + rows[0]["last_seen"] = "2026-04-20" 200 + return rows 201 + 202 + locked_modify_patterns(mutate) 203 + 204 + result = _invoke("show", "alpha-skill") 205 + 206 + assert result.exit_code == 0 207 + first_index = result.output.index( 208 + "- 2026-04-19 [solpbc] activity_ids=act_older notes=Earlier observation" 209 + ) 210 + second_index = result.output.index( 211 + "- 2026-04-20 [work] activity_ids=act_abc notes=" 212 + ) 213 + assert first_index < second_index 214 + 215 + 216 + def test_observe_missing_slug_errors(skill_cli_env): 217 + result = _invoke( 218 + "observe", 219 + "missing-skill", 220 + "--day", 221 + "2026-04-20", 222 + "--facet", 223 + "work", 224 + "--activity-ids", 225 + "act_new", 226 + ) 227 + 228 + assert result.exit_code == 1 229 + assert "no such skill" in result.stderr 230 + 231 + 232 + def test_observe_appends_and_updates_derived_fields(skill_cli_env): 233 + _seed_patterns(_make_pattern()) 234 + 235 + result = _invoke( 236 + "observe", 237 + "alpha-skill", 238 + "--day", 239 + "2026-04-20", 240 + "--facet", 241 + "personal", 242 + "--activity-ids", 243 + "act_new", 244 + "--notes", 245 + "Later observation", 246 + "--json", 247 + ) 248 + 249 + payload = json.loads(result.output) 250 + assert result.exit_code == 0 251 + assert payload["facets_touched"] == ["personal", "work"] 252 + assert payload["first_seen"] == "2026-04-19" 253 + assert payload["last_seen"] == "2026-04-20" 254 + assert len(payload["observations"]) == 2 255 + assert payload["updated_at"].endswith("Z") 256 + 257 + 258 + def test_observe_resurrects_dormant(skill_cli_env): 259 + _seed_patterns(_make_pattern(status="dormant")) 260 + 261 + result = _invoke( 262 + "observe", 263 + "alpha-skill", 264 + "--day", 265 + "2026-04-20", 266 + "--facet", 267 + "work", 268 + "--activity-ids", 269 + "act_new", 270 + "--json", 271 + ) 272 + 273 + payload = json.loads(result.output) 274 + assert result.exit_code == 0 275 + assert payload["status"] == "mature" 276 + 277 + 278 + def test_observe_idempotent_exits_0_with_already_recorded(skill_cli_env): 279 + _seed_patterns(_make_pattern(activity_ids=["act_a", "act_b"])) 280 + 281 + result = _invoke( 282 + "observe", 283 + "alpha-skill", 284 + "--day", 285 + "2026-04-19", 286 + "--facet", 287 + "work", 288 + "--activity-ids", 289 + "act_b,act_a", 290 + ) 291 + 292 + assert result.exit_code == 0 293 + assert "already recorded" in result.stderr 294 + rows = load_patterns() 295 + assert len(rows[0]["observations"]) == 1 296 + 297 + 298 + def test_seed_creates_pattern_with_initial_observation(skill_cli_env): 299 + result = _invoke( 300 + "seed", 301 + "alpha-skill", 302 + "--name", 303 + "Alpha Skill", 304 + "--day", 305 + "2026-04-19", 306 + "--facet", 307 + "work", 308 + "--activity-ids", 309 + "act_abc,act_def", 310 + "--notes", 311 + "Initial seed", 312 + "--json", 313 + ) 314 + 315 + payload = json.loads(result.output) 316 + assert result.exit_code == 0 317 + assert payload["slug"] == "alpha-skill" 318 + assert payload["status"] == "emerging" 319 + assert payload["needs_profile"] is False 320 + assert payload["needs_refresh"] is False 321 + assert payload["profile_generated_at"] is None 322 + assert payload["facets_touched"] == ["work"] 323 + assert payload["first_seen"] == "2026-04-19" 324 + assert payload["last_seen"] == "2026-04-19" 325 + assert payload["observations"][0]["activity_ids"] == ["act_abc", "act_def"] 326 + assert payload["observations"][0]["notes"] == "Initial seed" 327 + 328 + 329 + def test_seed_collision_errors_with_slug_already_exists(skill_cli_env): 330 + _seed_patterns(_make_pattern()) 331 + 332 + result = _invoke( 333 + "seed", 334 + "alpha-skill", 335 + "--name", 336 + "Alpha Skill", 337 + "--day", 338 + "2026-04-19", 339 + "--facet", 340 + "work", 341 + "--activity-ids", 342 + "act_abc", 343 + ) 344 + 345 + assert result.exit_code == 1 346 + assert "slug already exists" in result.stderr 347 + 348 + 349 + def test_promote_missing_slug_errors(skill_cli_env): 350 + result = _invoke("promote", "missing-skill") 351 + 352 + assert result.exit_code == 1 353 + assert "no such skill" in result.stderr 354 + 355 + 356 + def test_promote_sets_needs_profile(skill_cli_env): 357 + _seed_patterns(_make_pattern()) 358 + 359 + result = _invoke("promote", "alpha-skill", "--json") 360 + 361 + payload = json.loads(result.output) 362 + assert result.exit_code == 0 363 + assert payload["needs_profile"] is True 364 + 365 + 366 + def test_promote_already_flagged_exits_0(skill_cli_env): 367 + _seed_patterns(_make_pattern(needs_profile=True)) 368 + 369 + result = _invoke("promote", "alpha-skill") 370 + 371 + assert result.exit_code == 0 372 + assert "already flagged" in result.stderr 373 + 374 + 375 + def test_promote_already_mature_exits_0(skill_cli_env): 376 + _seed_patterns(_make_pattern(status="mature")) 377 + 378 + result = _invoke("promote", "alpha-skill") 379 + 380 + assert result.exit_code == 0 381 + assert "already mature" in result.stderr 382 + 383 + 384 + def test_refresh_not_mature_exits_1(skill_cli_env): 385 + _seed_patterns(_make_pattern(status="emerging")) 386 + 387 + result = _invoke("refresh", "alpha-skill") 388 + 389 + assert result.exit_code == 1 390 + assert "not mature" in result.stderr 391 + 392 + 393 + def test_refresh_sets_needs_refresh_on_mature(skill_cli_env): 394 + _seed_patterns(_make_pattern(status="mature")) 395 + 396 + result = _invoke("refresh", "alpha-skill", "--json") 397 + 398 + payload = json.loads(result.output) 399 + assert result.exit_code == 0 400 + assert payload["needs_refresh"] is True 401 + 402 + 403 + def test_refresh_already_flagged_exits_0(skill_cli_env): 404 + _seed_patterns(_make_pattern(status="mature", needs_refresh=True)) 405 + 406 + result = _invoke("refresh", "alpha-skill") 407 + 408 + assert result.exit_code == 0 409 + assert "already flagged" in result.stderr 410 + 411 + 412 + def test_mark_dormant_sets_status(skill_cli_env): 413 + _seed_patterns(_make_pattern()) 414 + 415 + result = _invoke("mark-dormant", "alpha-skill", "--json") 416 + 417 + payload = json.loads(result.output) 418 + assert result.exit_code == 0 419 + assert payload["status"] == "dormant" 420 + 421 + 422 + def test_mark_dormant_already_flagged_exits_0(skill_cli_env): 423 + _seed_patterns(_make_pattern(status="dormant")) 424 + 425 + result = _invoke("mark-dormant", "alpha-skill") 426 + 427 + assert result.exit_code == 0 428 + assert "already flagged" in result.stderr 429 + 430 + 431 + def test_retire_sets_status(skill_cli_env): 432 + _seed_patterns(_make_pattern()) 433 + 434 + result = _invoke("retire", "alpha-skill", "--json") 435 + 436 + payload = json.loads(result.output) 437 + assert result.exit_code == 0 438 + assert payload["status"] == "retired" 439 + 440 + 441 + def test_retire_already_flagged_exits_0(skill_cli_env): 442 + _seed_patterns(_make_pattern(status="retired")) 443 + 444 + result = _invoke("retire", "alpha-skill") 445 + 446 + assert result.exit_code == 0 447 + assert "already flagged" in result.stderr 448 + 449 + 450 + def test_edit_request_appends_with_unique_id(skill_cli_env): 451 + _seed_patterns(_make_pattern()) 452 + 453 + first = _invoke("edit-request", "alpha-skill", "--instructions", "revise opening") 454 + second = _invoke("edit-request", "alpha-skill", "--instructions", "expand examples") 455 + 456 + assert first.exit_code == 0 457 + assert second.exit_code == 0 458 + rows = load_edit_requests() 459 + assert len(rows) == 2 460 + assert rows[0]["id"] != rows[1]["id"] 461 + 462 + 463 + def test_edit_request_on_retired_skill_allowed(skill_cli_env): 464 + _seed_patterns(_make_pattern(status="retired")) 465 + 466 + result = _invoke( 467 + "edit-request", 468 + "alpha-skill", 469 + "--instructions", 470 + "still worth polishing", 471 + "--json", 472 + ) 473 + 474 + payload = json.loads(result.output) 475 + assert result.exit_code == 0 476 + assert payload["slug"] == "alpha-skill" 477 + 478 + 479 + def test_edit_request_missing_slug_errors(skill_cli_env): 480 + result = _invoke( 481 + "edit-request", 482 + "missing-skill", 483 + "--instructions", 484 + "revise this", 485 + ) 486 + 487 + assert result.exit_code == 1 488 + assert "no such skill" in result.stderr 489 + 490 + 491 + def test_rename_moves_profile_and_updates_slug(skill_cli_env): 492 + _seed_patterns(_make_pattern()) 493 + save_profile("alpha-skill", "# Alpha Skill\n") 494 + 495 + result = _invoke("rename", "alpha-skill", "renamed-skill", "--json") 496 + 497 + payload = json.loads(result.output) 498 + assert result.exit_code == 0 499 + assert payload["slug"] == "renamed-skill" 500 + assert not profile_path("alpha-skill").exists() 501 + assert ( 502 + profile_path("renamed-skill").read_text(encoding="utf-8") == "# Alpha Skill\n" 503 + ) 504 + assert load_patterns()[0]["slug"] == "renamed-skill" 505 + 506 + 507 + def test_rename_target_exists_errors(skill_cli_env): 508 + _seed_patterns(_make_pattern(slug="alpha-skill"), _make_pattern(slug="beta-skill")) 509 + 510 + result = _invoke("rename", "alpha-skill", "beta-skill") 511 + 512 + assert result.exit_code == 1 513 + assert "new slug already exists" in result.stderr 514 + 515 + 516 + def test_rename_orphan_profile_target_exists_errors(skill_cli_env): 517 + _seed_patterns(_make_pattern(slug="alpha-skill")) 518 + save_profile("orphan-target", "# Orphan Target\n") 519 + 520 + result = _invoke("rename", "alpha-skill", "orphan-target") 521 + 522 + assert result.exit_code == 1 523 + assert "new slug already exists" in result.stderr 524 + assert ( 525 + profile_path("orphan-target").read_text(encoding="utf-8") == "# Orphan Target\n" 526 + ) 527 + assert load_patterns()[0]["slug"] == "alpha-skill" 528 + 529 + 530 + def test_rename_missing_source_errors(skill_cli_env): 531 + _seed_patterns(_make_pattern(slug="alpha-skill")) 532 + 533 + result = _invoke("rename", "missing-skill", "beta-skill") 534 + 535 + assert result.exit_code == 1 536 + assert "no such skill" in result.stderr
+222
tests/test_think_skills.py
··· 1 + # SPDX-License-Identifier: AGPL-3.0-only 2 + # Copyright (c) 2026 sol pbc 3 + 4 + from __future__ import annotations 5 + 6 + import logging 7 + import threading 8 + from pathlib import Path 9 + 10 + import pytest 11 + 12 + from think.skills import ( 13 + edit_requests_lock_path, 14 + edit_requests_path, 15 + find_pattern, 16 + load_edit_requests, 17 + load_patterns, 18 + load_profile, 19 + locked_modify_edit_requests, 20 + locked_modify_patterns, 21 + make_request_id, 22 + observation_key, 23 + patterns_lock_path, 24 + patterns_path, 25 + profile_path, 26 + rename_profile, 27 + save_edit_requests, 28 + save_patterns, 29 + save_profile, 30 + skills_dir, 31 + touch_updated, 32 + utc_now_iso, 33 + ) 34 + 35 + 36 + @pytest.fixture 37 + def skill_journal(monkeypatch, tmp_path): 38 + monkeypatch.setenv("_SOLSTONE_JOURNAL_OVERRIDE", str(tmp_path)) 39 + return Path(tmp_path) 40 + 41 + 42 + def test_skills_dir_creates_dir(skill_journal): 43 + path = skills_dir() 44 + 45 + assert path == skill_journal / "skills" 46 + assert path.exists() 47 + assert path.is_dir() 48 + 49 + 50 + def test_path_helpers_return_expected_names(skill_journal): 51 + assert patterns_path() == skill_journal / "skills" / "patterns.jsonl" 52 + assert edit_requests_path() == skill_journal / "skills" / "edit_requests.jsonl" 53 + assert profile_path("alpha-skill") == skill_journal / "skills" / "alpha-skill.md" 54 + assert patterns_lock_path() == skill_journal / "skills" / ".patterns.lock" 55 + assert edit_requests_lock_path() == skill_journal / "skills" / ".edit_requests.lock" 56 + 57 + 58 + def test_load_patterns_missing_file_returns_empty(skill_journal): 59 + assert load_patterns() == [] 60 + 61 + 62 + def test_save_and_load_patterns_roundtrip(skill_journal): 63 + rows = [ 64 + {"slug": "alpha-skill", "status": "emerging"}, 65 + {"slug": "beta-skill", "status": "mature"}, 66 + ] 67 + 68 + save_patterns(rows) 69 + 70 + assert load_patterns() == rows 71 + 72 + 73 + def test_save_patterns_empty_list_writes_empty_file(skill_journal): 74 + save_patterns([]) 75 + 76 + assert patterns_path().read_text(encoding="utf-8") == "" 77 + 78 + 79 + def test_load_patterns_skips_malformed_line(skill_journal, caplog): 80 + patterns_path().write_text('{"slug": "alpha-skill"}\nnot-json\n', encoding="utf-8") 81 + 82 + rows = load_patterns() 83 + 84 + assert rows == [{"slug": "alpha-skill"}] 85 + assert "malformed JSONL line 2" in caplog.text 86 + 87 + 88 + def test_load_patterns_warns_on_non_dict_line(skill_journal, caplog): 89 + patterns_path().write_text('{"slug": "alpha-skill"}\n[1, 2]\n', encoding="utf-8") 90 + 91 + with caplog.at_level(logging.WARNING): 92 + rows = load_patterns() 93 + 94 + assert rows == [{"slug": "alpha-skill"}] 95 + assert "non-object JSONL line 2" in caplog.text 96 + assert "list" in caplog.text 97 + 98 + 99 + def test_load_edit_requests_missing_file_returns_empty(skill_journal): 100 + assert load_edit_requests() == [] 101 + 102 + 103 + def test_save_and_load_edit_requests_roundtrip(skill_journal): 104 + rows = [ 105 + {"id": "req_1", "slug": "alpha-skill"}, 106 + {"id": "req_2", "slug": "beta-skill"}, 107 + ] 108 + 109 + save_edit_requests(rows) 110 + 111 + assert load_edit_requests() == rows 112 + 113 + 114 + def test_load_profile_missing_returns_none(skill_journal): 115 + assert load_profile("missing-skill") is None 116 + 117 + 118 + def test_save_profile_writes_and_load_reads_back(skill_journal): 119 + save_profile("alpha-skill", "# Alpha Skill\n") 120 + 121 + assert load_profile("alpha-skill") == "# Alpha Skill\n" 122 + 123 + 124 + def test_rename_profile_renames_and_returns_true(skill_journal): 125 + save_profile("old-skill", "# Old\n") 126 + 127 + renamed = rename_profile("old-skill", "new-skill") 128 + 129 + assert renamed is True 130 + assert not profile_path("old-skill").exists() 131 + assert load_profile("new-skill") == "# Old\n" 132 + 133 + 134 + def test_rename_profile_missing_returns_false(skill_journal): 135 + assert rename_profile("missing-skill", "new-skill") is False 136 + 137 + 138 + def test_rename_profile_target_exists_raises(skill_journal): 139 + save_profile("old-skill", "# Old\n") 140 + save_profile("new-skill", "# New\n") 141 + 142 + with pytest.raises(FileExistsError): 143 + rename_profile("old-skill", "new-skill") 144 + 145 + 146 + def test_find_pattern_returns_row_or_none(skill_journal): 147 + rows = [{"slug": "alpha-skill"}, {"slug": "beta-skill"}] 148 + 149 + assert find_pattern("beta-skill", rows) == {"slug": "beta-skill"} 150 + assert find_pattern("missing-skill", rows) is None 151 + 152 + 153 + def test_observation_key_is_deterministic_and_sort_invariant(skill_journal): 154 + assert observation_key("alpha", "2026-04-19", ["x", "y"]) == observation_key( 155 + "alpha", "2026-04-19", ["y", "x"] 156 + ) 157 + 158 + 159 + def test_make_request_id_unique_across_100_calls(skill_journal): 160 + request_ids = {make_request_id() for _ in range(100)} 161 + 162 + assert len(request_ids) == 100 163 + 164 + 165 + def test_utc_now_iso_ends_with_z(skill_journal): 166 + assert utc_now_iso().endswith("Z") 167 + 168 + 169 + def test_touch_updated_sets_updated_at(skill_journal): 170 + row = {} 171 + 172 + touch_updated(row) 173 + 174 + assert row["updated_at"].endswith("Z") 175 + 176 + 177 + def test_locked_modify_patterns_applies_fn_and_persists(skill_journal): 178 + def mutate(rows): 179 + return list(rows) + [{"slug": "alpha-skill"}] 180 + 181 + updated = locked_modify_patterns(mutate) 182 + 183 + assert updated == [{"slug": "alpha-skill"}] 184 + assert load_patterns() == [{"slug": "alpha-skill"}] 185 + 186 + 187 + def test_locked_modify_edit_requests_applies_fn_and_persists(skill_journal): 188 + def mutate(rows): 189 + return list(rows) + [{"id": "req_1", "slug": "alpha-skill"}] 190 + 191 + updated = locked_modify_edit_requests(mutate) 192 + 193 + assert updated == [{"id": "req_1", "slug": "alpha-skill"}] 194 + assert load_edit_requests() == [{"id": "req_1", "slug": "alpha-skill"}] 195 + 196 + 197 + def test_locked_modify_patterns_serializes_threads(skill_journal): 198 + barrier = threading.Barrier(4) 199 + exceptions: list[BaseException] = [] 200 + 201 + def worker(i: int) -> None: 202 + try: 203 + barrier.wait() 204 + 205 + def mutate(rows): 206 + next_rows = list(rows) 207 + next_rows.append({"slug": f"s{i}"}) 208 + return next_rows 209 + 210 + locked_modify_patterns(mutate) 211 + except BaseException as exc: # pragma: no cover - assertion surface 212 + exceptions.append(exc) 213 + 214 + threads = [threading.Thread(target=worker, args=(i,)) for i in range(4)] 215 + for thread in threads: 216 + thread.start() 217 + for thread in threads: 218 + thread.join() 219 + 220 + assert exceptions == [] 221 + rows = load_patterns() 222 + assert sorted(row["slug"] for row in rows) == ["s0", "s1", "s2", "s3"]
+212
think/skills.py
··· 1 + # SPDX-License-Identifier: AGPL-3.0-only 2 + # Copyright (c) 2026 sol pbc 3 + """Owner-wide skills storage and helpers. 4 + 5 + Sole write-owner of: 6 + journal/skills/patterns.jsonl 7 + journal/skills/edit_requests.jsonl 8 + journal/skills/{slug}.md 9 + """ 10 + 11 + from __future__ import annotations 12 + 13 + import fcntl 14 + import json 15 + import logging 16 + import secrets 17 + from datetime import datetime, timezone 18 + from pathlib import Path 19 + from typing import Any, Callable 20 + 21 + from think.entities.core import atomic_write 22 + from think.utils import get_journal 23 + 24 + logger = logging.getLogger(__name__) 25 + 26 + 27 + def skills_dir() -> Path: 28 + """Return the owner-wide skills directory, creating it if needed.""" 29 + path = Path(get_journal()) / "skills" 30 + path.mkdir(parents=True, exist_ok=True) 31 + return path 32 + 33 + 34 + def patterns_path() -> Path: 35 + """Return the owner-wide skills patterns JSONL path.""" 36 + return skills_dir() / "patterns.jsonl" 37 + 38 + 39 + def edit_requests_path() -> Path: 40 + """Return the owner-wide skill edit requests JSONL path.""" 41 + return skills_dir() / "edit_requests.jsonl" 42 + 43 + 44 + def profile_path(slug: str) -> Path: 45 + """Return the markdown profile path for one skill slug.""" 46 + return skills_dir() / f"{slug}.md" 47 + 48 + 49 + def patterns_lock_path() -> Path: 50 + """Return the sibling lock path for patterns.jsonl.""" 51 + return skills_dir() / ".patterns.lock" 52 + 53 + 54 + def edit_requests_lock_path() -> Path: 55 + """Return the sibling lock path for edit_requests.jsonl.""" 56 + return skills_dir() / ".edit_requests.lock" 57 + 58 + 59 + def _load_jsonl_rows(path: Path) -> list[dict[str, Any]]: 60 + """Load JSONL rows from *path*, skipping blanks and malformed lines.""" 61 + if not path.exists(): 62 + return [] 63 + 64 + rows: list[dict[str, Any]] = [] 65 + with open(path, encoding="utf-8") as handle: 66 + for lineno, line in enumerate(handle, start=1): 67 + raw = line.strip() 68 + if not raw: 69 + continue 70 + try: 71 + data = json.loads(raw) 72 + except json.JSONDecodeError: 73 + logger.warning("skills: malformed JSONL line %s in %s", lineno, path) 74 + continue 75 + if not isinstance(data, dict): 76 + logger.warning( 77 + "skills: non-object JSONL line %s in %s (got %s)", 78 + lineno, 79 + path, 80 + type(data).__name__, 81 + ) 82 + continue 83 + rows.append(data) 84 + return rows 85 + 86 + 87 + def load_patterns() -> list[dict[str, Any]]: 88 + """Load owner-wide skill patterns from JSONL.""" 89 + return _load_jsonl_rows(patterns_path()) 90 + 91 + 92 + def load_edit_requests() -> list[dict[str, Any]]: 93 + """Load owner-wide skill edit requests from JSONL.""" 94 + return _load_jsonl_rows(edit_requests_path()) 95 + 96 + 97 + def load_profile(slug: str) -> str | None: 98 + """Load one markdown skill profile, returning None when absent.""" 99 + path = profile_path(slug) 100 + try: 101 + return path.read_text(encoding="utf-8") 102 + except FileNotFoundError: 103 + return None 104 + 105 + 106 + def find_pattern( 107 + slug: str, patterns: list[dict[str, Any]] | None = None 108 + ) -> dict[str, Any] | None: 109 + """Return one pattern by slug, or None when not found.""" 110 + rows = load_patterns() if patterns is None else patterns 111 + for row in rows: 112 + if row.get("slug") == slug: 113 + return row 114 + return None 115 + 116 + 117 + def _save_jsonl_rows(path: Path, rows: list[dict[str, Any]]) -> None: 118 + """Write *rows* to *path* as JSONL using an atomic replace.""" 119 + content = "" 120 + if rows: 121 + content = "\n".join(json.dumps(row, ensure_ascii=False) for row in rows) + "\n" 122 + atomic_write(path, content) 123 + 124 + 125 + def save_patterns(rows: list[dict[str, Any]]) -> None: 126 + """Persist owner-wide skill patterns atomically.""" 127 + _save_jsonl_rows(patterns_path(), rows) 128 + 129 + 130 + def save_edit_requests(rows: list[dict[str, Any]]) -> None: 131 + """Persist owner-wide skill edit requests atomically.""" 132 + _save_jsonl_rows(edit_requests_path(), rows) 133 + 134 + 135 + def save_profile(slug: str, markdown: str) -> None: 136 + """Persist one markdown skill profile atomically.""" 137 + atomic_write(profile_path(slug), markdown) 138 + 139 + 140 + def rename_profile(old_slug: str, new_slug: str) -> bool: 141 + """Rename one skill profile file, returning False when the source is absent.""" 142 + source = profile_path(old_slug) 143 + target = profile_path(new_slug) 144 + if not source.exists(): 145 + return False 146 + if target.exists(): 147 + raise FileExistsError(f"profile already exists for slug {new_slug}") 148 + source.rename(target) 149 + return True 150 + 151 + 152 + def locked_modify_patterns( 153 + fn: Callable[[list[dict[str, Any]]], list[dict[str, Any]]], 154 + ) -> list[dict[str, Any]]: 155 + """Apply a locked read-modify-write cycle to patterns.jsonl.""" 156 + skills_dir() 157 + lock_path = patterns_lock_path() 158 + # Lock file contents are irrelevant; opening with "w" matches the existing pattern. 159 + with open(lock_path, "w", encoding="utf-8") as lock_file: 160 + fcntl.flock(lock_file, fcntl.LOCK_EX) 161 + try: 162 + rows = load_patterns() 163 + new_rows = fn(rows) 164 + save_patterns(new_rows) 165 + return new_rows 166 + finally: 167 + fcntl.flock(lock_file, fcntl.LOCK_UN) 168 + 169 + 170 + def locked_modify_edit_requests( 171 + fn: Callable[[list[dict[str, Any]]], list[dict[str, Any]]], 172 + ) -> list[dict[str, Any]]: 173 + """Apply a locked read-modify-write cycle to edit_requests.jsonl.""" 174 + skills_dir() 175 + lock_path = edit_requests_lock_path() 176 + # Lock file contents are irrelevant; opening with "w" matches the existing pattern. 177 + with open(lock_path, "w", encoding="utf-8") as lock_file: 178 + fcntl.flock(lock_file, fcntl.LOCK_EX) 179 + try: 180 + rows = load_edit_requests() 181 + new_rows = fn(rows) 182 + save_edit_requests(new_rows) 183 + return new_rows 184 + finally: 185 + fcntl.flock(lock_file, fcntl.LOCK_UN) 186 + 187 + 188 + def observation_key(slug: str, day: str, activity_ids: list[str]) -> str: 189 + """Return the deterministic idempotency key for one observation.""" 190 + return f"{slug}|{day}|{','.join(sorted(activity_ids))}" 191 + 192 + 193 + def _utc_compact() -> str: 194 + """Return a compact UTC timestamp for request ids.""" 195 + return datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%S") 196 + 197 + 198 + def make_request_id() -> str: 199 + """Return a collision-resistant edit request id.""" 200 + return f"req_{_utc_compact()}_{secrets.token_hex(6)}" 201 + 202 + 203 + def utc_now_iso() -> str: 204 + """Return the current UTC time as an ISO-8601 string ending in Z.""" 205 + return ( 206 + datetime.now(timezone.utc).isoformat(timespec="seconds").replace("+00:00", "Z") 207 + ) 208 + 209 + 210 + def touch_updated(pattern: dict[str, Any]) -> None: 211 + """Update a pattern row's updated_at timestamp in place.""" 212 + pattern["updated_at"] = utc_now_iso()