eos-sdk-extract
Detect cross-app duplication and extract the smallest honest shared unit into emptyos/sdk/, codifying CLAUDE.md Dev Rule 9 (extract on the *second* caller, not the first). Use when the user says "extr
SKILL.md.claude/skills/eos-sdk-extract/SKILL.md
GitHubEmptyOS SDK Extract
Detect cross-app duplication and extract shared logic into emptyos/sdk/ — codifying CLAUDE.md §Development Rule 9 ("build specific first in one app, extract to sdk/ when a second app needs it"). The rule is active only if someone runs it; this skill is the runner.
Goal: find duplicated patterns across ≥2 apps, extract the smallest honest shared unit, update callers, verify tests still pass. Not refactoring for beauty — refactoring to stop the third app from also copying it.
When to Use
- User says "extract", "sdk-extract", "dedupe the apps", "what's been copy-pasted"
- You just noticed while editing an app that the pattern looked familiar
/eos-simplifyflagged "this duplicates fragment fromapps/<other>"- Periodically, as a health pass — even if no one asked
Not for single-app refactors, new abstractions without a concrete second caller, or "design-for-the-future" helpers. Two callers is the floor. One caller is premature.
Prerequisites
No daemon required. Every phase runs offline: the scans are static, the SDK
unit tests and the Phase 6 equivalence harness are pure-Python, and pyflakes /
check_undefined_names.py need nothing running.
A daemon is used by exactly one optional check — Phase 6 step 5, the caller
system tests — and only if :9000 happens to already be up. If it isn't,
do not start it: python -m emptyos start / restart.bat / taskkill
against :9000 are forbidden from a Claude session
(.claude/rules/daemon-handling.md). Skip the step and say so, or lease a
sandbox member (.claude/rules/sandbox-driven-testing.md).
Needs pyflakes on PATH (pip install pyflakes) — Phase 6 step 2 is the cheapest
catch for an import the extraction left behind.
Process
Run phases in order. Every extraction is a behaviour-preserving change — no signature drift, no widened contract.
Phase 1: Find Candidates
Look for duplication at three granularities — start wide, narrow down:
1a. Module-level: duplicate imports across apps
grep -rhn "^from emptyos.sdk" apps/ apps/personal/ --include="*.py" 2>/dev/null | sort | uniq -c | sort -rn | head -20
If many apps reuse the same SDK helpers, good — that's what the SDK is for. If many apps re-implement what an SDK helper already does, that's the inverse problem (Phase 1e).
1b. Function-level: similarly-named private helpers
grep -rhn "^ def _\|^def _" apps/ apps/personal/ --include="*.py" 2>/dev/null \
| awk -F: '{print $3}' | sort | uniq -c | sort -rn | head -20
When _parse_session, _extract_metrics, _score_attempt appear in ≥2 apps, open all copies and compare.
1c. Fragment-level: distinctive 3–5 line blocks Pick a distinctive-looking line from the app you're currently touching — something with a unique string literal, regex, or sequence of calls — and grep for near-duplicates:
grep -rn "<distinctive-fragment>" apps/ apps/personal/ --include="*.py"
Manually compare bodies around the hits. Rename drift (session_id vs sess_id) is still a duplicate.
1d. Structural duplicates (names diverged, bodies identical)
python scripts/sdk_duplicate_scan.py # default: apps/, min 4 stmts
python scripts/sdk_duplicate_scan.py --min 3 # loosen
python scripts/sdk_duplicate_scan.py apps plugins # extra roots
Parses every function body, strips locals to positional placeholders, hashes the normalised AST, and reports groups of identical bodies. Catches what 1b/1c miss: copy-paste where callers renamed variables, and inline code in one app that matches a helper in another. The duplicate that surfaced _weekly_path across 3 personal apps + 2 inline copies in apps/public/standard/journal/app.py came from this pass — grep would never have found it because the core app had no method name to grep for.
Treat each reported group as a Phase 2 candidate: open all listed sites, confirm they're doing the same job, then proceed.
1e. Reimplementation of existing SDK helpers Scan for functions that do what these already do:
parse_frontmatter/strip_frontmatter→ many apps still hand-split on---parse_llm_json→ many apps stilljson.loads(text.strip("").removeprefix("json"))`slugify→ many apps roll their owntask_tier,compute_task_decay→ task-like apps reinvent scoringVaultLibrary→ collections apps reinvent query+createSessionStore/HistoryStore→ practice-style apps reinvent session runners- SRS helpers
sm2_schedule,srs_due_items→ flashcard apps
1f. Mixin families across apps
grep -rn "^class \w\+Mixin" apps/ apps/personal/ --include="*.py"
Mixins are cohesive bundles (methods + state) composed via multiple inheritance. The 1d scanner hashes function bodies individually and will miss the bundle even when individual methods drift enough to not hash-collide. For each mixin file, list its public method names; if ≥2 apps have mixins whose method-name sets overlap meaningfully (≥3 shared names, or same domain — "vault stats", "session runner", "timeline"), that's a candidate for promotion to emptyos/sdk/ as a shared mixin or small base class.
Classify each hit before treating it as a candidate:
- App-family decomposition (one app split into sibling mixins:
apps/reactor/reactions_*,apps/music-studio/{library,lyrics,visual,compose}.py) → not duplication, skip. These are compositional decomposition within one app. - Cross-app reuse (same mixin shape appears in ≥2 unrelated apps, e.g. two apps both exposing vault-health methods) → candidate for Phase 2.
- Absorbed from a retired app (single file like
apps/public/standard/app-analytics/vault_mixin.pywith no second caller today) → park; flag for re-review when a second caller appears.
Extraction target for cross-app mixins: a module in emptyos/sdk/ exposing the mixin class (e.g. emptyos/sdk/<name>_mixin.py), apps changing class MyApp(BaseApp, VaultMixin) to import from sdk. Phase 3's "don't add a new base class casually" still applies — prefer pure functions when state isn't actually shared across methods.
For each candidate, record: file paths, function names, approximate LOC per copy, variance (identical / renamed / behavioural drift).
Phase 2: Pick One and Confirm the Threshold
The bar from CLAUDE.md: two callers minimum, actual duplication, not "might be useful someday."
For the candidate, ask honestly:
| Check | Must hold? |
|---|---|
| Does it appear in ≥2 apps today? | Yes — one caller = premature |
| Are the copies doing the same job, not just looking similar? | Yes — coincidental shape is not duplication |
| Is the shared core stable, or are the copies diverging in meaningful ways? | Stable — if they're diverging on purpose, leave them |
| Would a third app landing tomorrow copy it again? | Yes — that's the predictive signal |
| Is there a natural name for the shared unit? | Yes — if you can't name it, you haven't found the boundary |
If any answer is "no," park the candidate and pick another. Don't extract to hit a quota.
Phase 3: Design the Shared Unit
Smallest honest surface. Follow the shape of what's already in emptyos/sdk/:
- Pure functions go in
utils.py(or a new single-purpose module) — e.g.parse_llm_json,slugify - Stateful helpers get their own module —
session.py(SessionStore),srs.py(scheduler),vault_library.py(collection base) - Domain scorers / math get their own module —
scoring.py,stats.py,audio.py - Base classes stay in
base_*.py— don't add a new base class casually
Rules:
- Inputs/outputs must match the union of existing callers — no widening, no narrowing. If caller A passes
session_id: strand caller B passesint, either (a) both accept and normalise, or (b) don't extract — the drift is real. - No app-specific defaults — if one copy hardcodes a path or tag, that config stays in the app, not in the SDK.
- No imports from
apps/orplugins/— SDK is a leaf; apps depend on it, never the other way around. - Add to
emptyos/sdk/__init__.pysofrom emptyos.sdk import Xworks.
Write the signature down (single line) and show the user before writing the code:
Proposed extraction:
emptyos.sdk.<module>.<name>(<args>) -> <return>
Callers today: apps/<a>/..., apps/<b>/...
Replaces: ~<N> lines per caller, <M> lines total
OK to proceed?
Phase 4: Write the Shared Unit
Add the function / class to the chosen SDK module (new file only if none of the existing ones fit).
Constraints:
- Docstring: 1 line + rules section (when to use, when not to use). Follow
vault_library.py/srs.pystyle. - No f-string logging with secret paths — use
log.info("session %s", session_id)style - Prompts, if any, follow CLAUDE.md §Development Rule 12 —
UPPERCASEconstants,system=kwarg, explicit temperature, include "do NOT" examples - Export from
emptyos/sdk/__init__.py— add to both thefrom ... import ...block and__all__ - No new dependencies unless the user approves — check
pyproject.tomlbeforeimport-ing something new
Write a tiny test at the same time. Pick the pattern from tests/test_sdk_*.py if those exist; otherwise add a new tests/test_sdk_<name>.py. Do NOT put the test in
tests/helpers.py — that is the shared-assertion module, it holds no test_*
functions, and pytest's python_files = test_*.py never collects it, so a test
written there silently never runs. One test per branch of the function's behaviour — not comprehensive, just enough to catch a regression from a careless caller update.
Phase 5: Update the Callers
Touch only the N apps that were Phase 1 candidates. Don't drag third-party callers in.
Per caller:
- Replace the local helper/method with
from emptyos.sdk import <name>+ call site - Delete the old helper body (don't leave dead code behind)
- If the local copy had app-specific config baked in, pass it as an argument — don't add a default to the SDK helper
- Remove any now-unused imports in the caller
Diff hygiene:
- Each caller's diff should show: N lines removed, 1–2 lines added for the call + 1 import line
- If a caller's diff is growing (more lines after than before), stop — either the extraction is wrong or the caller needs app-specific glue you're hiding in the SDK
Phase 6: Verify
Step 0 — the equivalence harness. Do this first; it is the real evidence.
Unit tests prove the new helper does what you decided it should. They say nothing
about whether it does what each old copy did — which is the only question an
extraction has to answer. Write a throwaway script that defines every pre-migration
body verbatim (copy them out of git diff / git show HEAD:<file>) and diffs
them against the helper over a deliberately hostile corpus:
# scratchpad/<name>_equiv.py — throwaway, not committed
def orig_kb(s): # verbatim pre-migration body, one per caller
...
CASES = [("kb", orig_kb, dict(fallback="untitled")), ...] # + each caller's kwargs
CORPUS = ["", " ", "!!!", "机器学习", "Café", "a -- b", "2026-08-16",
"a" * 90, "\xa0nbsp\xa0", "MULTI SPACE", "_under_"] # exotic > typical
for name, orig, kw in CASES:
for t in CORPUS:
assert orig(t) == new_helper(t, **kw), (name, t, orig(t), new_helper(t, **kw))
Report the comparison count (N callers × M inputs). The corpus must include the
inputs the union of callers could see — non-ASCII if any caller is vault-facing,
empty/whitespace-only, and anything longer than the shortest max_len.
When the originals disagree with each other on some input (they usually do, on
out-of-contract input like None), that is a finding, not a blocker: pick one,
state which callers change, and say so in the Phase 7 report. Don't paper over it.
# 1. SDK tests pass
python -m pytest tests/test_sdk_<name>.py tests/test_sdk_utils.py -q # whichever applies
# 2. Static gates — catch an import the move left behind
python -m pyflakes <every changed file> # undefined names + now-dead imports
python scripts/check_undefined_names.py # incl. TYPE_CHECKING-only names
# 3. No lingering copy of the old helper anywhere
grep -rn "def <old_helper_name>" apps/ --include="*.py" # expect 0
grep -rnF '<the distinctive regex/fragment>' <migrated files> # expect 0
# 4. Import surface is stable
python -c "from emptyos.sdk import <name>; print('<name> importable')"
# 5. Each caller's system tests — ONLY if the daemon is already up
curl -s -m 5 http://127.0.0.1:9000/api/health -o /dev/null -w "%{http_code}\n"
python -m pytest tests/test_sys_<a>.py -q
Never run python -m emptyos start or python -m emptyos health to make step 5
possible. Both boot the kernel and open a syslog SQLite handle — forbidden from a
Claude session by .claude/rules/daemon-handling.md. If the daemon is down, say so
and lean on step 0; to verify Python changes end-to-end, lease a sandbox member
instead (.claude/rules/sandbox-driven-testing.md).
Also note the daemon holds the pre-change Python until it restarts, so a green
test_sys_<a> against a stale :9000 is evidence about the old code, not yours —
see feedback_harness_that_cannot_reproduce_lies.
Regression bar: behaviour of the callers must be indistinguishable from before. Step 0 is what establishes that; system tests are corroboration. If step 0 diverges on in-contract input, the extraction widened or narrowed the contract — narrow the helper, or leave that caller out.
Phase 7: Report
SDK Extract: <name>
Location:
emptyos/sdk/<module>.py (+<N> lines)
emptyos/sdk/__init__.py (+2 lines — import + __all__)
tests/test_sdk_<name>.py (+<T> lines, <C> cases)
Callers migrated:
apps/<a>/app.py −<X> lines
apps/<b>/helpers.py −<Y> lines
Net: −<X+Y−N> lines across the codebase
Verified:
equivalence <N> callers x <M> inputs = <N*M> comparisons, all identical
pytest sdk + sys_<a> + sys_<b> all passed (or: daemon down, not run)
static pyflakes + check_undefined_names clean
no orphan copies (grep clean)
Behaviour deltas (if any):
- <input>: originals disagreed (<a> gave X, <b> gave Y) — unified to Z
Deferred (not extracted):
- <candidate>: only 1 caller today — reconsider when a 2nd appears
- <candidate>: copies are drifting deliberately — keep separate
End of turn: suggest /eos-simplify on the diff (catches any convention slip in the new SDK file) and /eos-session-wrapup at end of session.
Safety
- Two callers minimum. One-caller "extractions" are the single most common mistake this skill exists to avoid. Park them.
- Never widen the contract to unify drifting copies. If A and B are diverging, they're not the same function anymore — leave them.
- SDK has no upward deps. No
from apps..., nofrom plugins.... SDK is a leaf. - Behaviour-preserving only. Don't "fix" a bug in the copies during extraction — that belongs in a separate commit so it shows up in git log.
- Don't batch extractions in one pass. One extraction, one commit, one verification. Stacking them makes rollback impossible.
- Don't add new dependencies to satisfy the extraction. If the pattern needs a new library, stop and ask.
apps/personal/code can be a caller but must not be the only second caller if the SDK helper is meant to ship — personal apps are gitignored, community apps can't depend on their patterns.
Relationship
- Pre-commit on the extraction diff →
/eos-simplify(catches convention slip in the new SDK file) - End of session with an extraction →
/eos-session-wrapup(devlog captures the before/after LOC) - Find candidates proactively as part of health review →
/eos-architecture-reviewcheck mode - New app built after extraction →
/eos-new-appscaffold will naturally use the extracted helper