diff --git a/.gitignore b/.gitignore index 9da82fd..f89d809 100644 --- a/.gitignore +++ b/.gitignore @@ -17,6 +17,8 @@ npm-debug.log* .claude/ .mcp.json +.state/ + # Editor / OS .DS_Store *.swp diff --git a/README.md b/README.md index 15aec61..a9f8c36 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ Compatible with Claude Code, Codex, Gemini CLI, Cursor, Copilot, Windsurf, Cline ## Naming 🏷️ -Every first-party skill is named `kvb--` with one of these categories: `think`, `create`, `code`, `ci`, `git`, `check`, `org`, `skills`. Only `kvb-cleanup` and `kvb-help` stand without a category. Names use `a-z`, `0-9` and single hyphens, at most 64 characters, and equal their directory name. `tests/test_naming.py` enforces all of it, including that the organigram below lists every skill under its category. +Every first-party skill is named `kvb--` with one of these categories: `think`, `create`, `code`, `ci`, `git`, `check`, `org`, `skills`. Only `kvb-cleanup`, `kvb-help` and the collector `kvb-transcend` stand without a category. Names use `a-z`, `0-9` and single hyphens, at most 64 characters, and equal their directory name. `tests/test_naming.py` enforces all of it, including that the organigram below lists every skill under its category. ## Organigram πŸ›οΈ @@ -39,6 +39,7 @@ flowchart LR code --> code_refactor[kvb-code-refactor] code --> code_test[kvb-code-test] code --> code_tune[kvb-code-tune] + code --> code_transcend[kvb-code-transcend] ci --> ci_triage[kvb-ci-triage] ci --> ci_artifacts[kvb-ci-artifacts] ci --> ci_observe[kvb-ci-observe] @@ -65,8 +66,10 @@ flowchart LR skills --> skills_tune[kvb-skills-tune] skills --> skills_auto[kvb-skills-auto] skills --> skills_essence[kvb-skills-essence] + skills --> skills_transcend[kvb-skills-transcend] none --> cleanup[kvb-cleanup] none --> help[kvb-help] + none --> transcend_all[kvb-transcend] ``` ## Skill map πŸ—ΊοΈ @@ -100,6 +103,7 @@ flowchart LR test[kvb-code-test] tune[kvb-code-tune] comments_clean[kvb-code-no-comments] + code_transcend[kvb-code-transcend] end subgraph cat_ci [ci] triage[kvb-ci-triage] @@ -136,10 +140,12 @@ flowchart LR autotune[kvb-skills-tune] autoskill[kvb-skills-auto] essence[kvb-skills-essence] + skills_transcend[kvb-skills-transcend] end subgraph cat_none [no category] cleanup[kvb-cleanup] help_skills[kvb-help] + transcend[kvb-transcend] end commit_req -.-> commit_next @@ -209,6 +215,14 @@ flowchart LR dream --> autoskill autoskill --> autotune autotune --> essence + dream --> transcend + transcend --> skills_transcend + transcend --> code_transcend + skills_transcend --> active_listening + skills_transcend --> autotune + code_transcend --> active_listening + autotune -. own scripts .-> skills_transcend + tune -. measurements .-> code_transcend test --> triage test --> dialectic diff --git a/skills/kvb-code-transcend/SKILL.md b/skills/kvb-code-transcend/SKILL.md new file mode 100644 index 0000000..c00a0e2 --- /dev/null +++ b/skills/kvb-code-transcend/SKILL.md @@ -0,0 +1,62 @@ +--- +name: kvb-code-transcend +description: > + Carry the session's ad-hoc tests and scripts into the current repo. + Trigger on /kvb-code-transcend. +--- + +The test the agent ran from its scratchpad, the probe that proved a cause, the +benchmark behind an optimization: they protected this session's work and +nothing after it. This skill moves them into the repository the agent works +in (`git rev-parse --show-toplevel`), in that repository's own form. It +changes nothing before the operator confirms. + +## Step 1: inventory + +Read-only: + +- **Tests and scripts outside the repo**: `transcend.py scan` of the + `kvb-transcend` skill lists them in the own and peers' scratchpads and + skips what was already decided and has not changed; peer candidates follow + that skill's consent step. Add the `python -c` checks and negative controls + this session ran inline. +- **Probes and reproductions** that proved a root cause or a fix. +- **Measurements**: prototypes and before/after harnesses of the + `kvb-code-tune` skill. +- **Repeated commands** a make target or script of the repository could own. +- **Untracked helpers** the agent left inside the repository. + +One line each: `candidate -> source -> what it guards`. + +## Step 2: classify + +Read the repository's rules first (AGENTS.md, CONTRIBUTING, the Makefile, the +test layout). Per candidate one target in that repository's form: a test in +its test tree and framework, a make target, a script under its scripts +directory, a CI step, or drop. No new framework, dependency or language +without the operator. A kept optimization keeps its measurement as a +benchmark or regression test. + +## Step 3: listen and confirm + +Run the `kvb-think-listen` skill on the open points: target path, framework, +which repository line owns the file. Then `AskUserQuestion`: one +`multiSelect` with the candidates, nothing preselected, and per pick a +single-select `Write it` / `Revise it` / `Discard` with the drafted diff. + +## Step 4: write + +- Write only the confirmed files, path-scoped; a peer's line goes to its + owner through the `kvb-org-coordinate` skill. +- Every carried test fails without the code it guards: show the negative + control once. +- Run the repository's tests only on the resource chair's grant (the + `kvb-org-organize` skill); leave the result uncommitted for the + `kvb-git-commit-next` skill. +- `transcend.py mark` every offered candidate with its verdict. + +## Output + +| Candidate | Source | Target in the repo | Verdict | + +Dropped candidates stay with their reason. diff --git a/skills/kvb-code-tune/SKILL.md b/skills/kvb-code-tune/SKILL.md index e38bce3..aaf2b69 100644 --- a/skills/kvb-code-tune/SKILL.md +++ b/skills/kvb-code-tune/SKILL.md @@ -2,9 +2,8 @@ name: kvb-code-tune description: > Find and apply performance optimizations (CI, deploy, build, gate, hot - paths) without reducing logic, coverage or correctness. Trigger on - /kvb-code-tune, a job dying on its time budget, a slower pipeline or deploy, - or asked where time goes. + paths) without losing logic or coverage. Trigger on /kvb-code-tune, a job + hitting its time budget, a slower pipeline, or asked where time goes. --- # Tune @@ -205,6 +204,9 @@ failure, or only moves the cost somewhere the measurement does not look. proposals. Re-measure from the same source under the same conditions and report a before and an after number. 5. Repeat until the next candidate is worth less than the run that measures it. +6. Hand the prototypes and before/after harnesses of every kept candidate to + the `kvb-code-transcend` skill, so the measurement stays in the repository + as a benchmark or regression test. ## Output diff --git a/skills/kvb-git-changelog/SKILL.md b/skills/kvb-git-changelog/SKILL.md index 7996e65..3b7b783 100644 --- a/skills/kvb-git-changelog/SKILL.md +++ b/skills/kvb-git-changelog/SKILL.md @@ -1,8 +1,8 @@ --- name: kvb-git-changelog description: > - Write the release message since the last release for `pkgmgr release`; - prints it only, never edits CHANGELOG.md, tags or releases. Trigger on + Write the release message for `pkgmgr release`; + prints only, never edits, tags or releases. Trigger on /kvb-git-changelog, "release message", "changelog entry", before cutting a release, or on pkgmgr's markdown-lint rejection. --- diff --git a/skills/kvb-git-merge-fix/SKILL.md b/skills/kvb-git-merge-fix/SKILL.md index 5b30945..eacc118 100644 --- a/skills/kvb-git-merge-fix/SKILL.md +++ b/skills/kvb-git-merge-fix/SKILL.md @@ -1,9 +1,9 @@ --- name: kvb-git-merge-fix description: > - Hunt down the commits on any branch that carry a described fix and merge it - into the current branch. Trigger on /kvb-git-merge-fix or when the - operator asks to bring over a fix from another branch. + Find the commits on any branch that carry a described fix and merge them + here. Trigger on /kvb-git-merge-fix or a request to bring over + a fix from another branch. --- The free text after `/kvb-git-merge-fix` is the fix description, exactly like `/loop` diff --git a/skills/kvb-org-organize/SKILL.md b/skills/kvb-org-organize/SKILL.md index 9d6df63..0bfce62 100644 --- a/skills/kvb-org-organize/SKILL.md +++ b/skills/kvb-org-organize/SKILL.md @@ -1,8 +1,8 @@ --- name: kvb-org-organize description: > - Keep parallel agents from duplicating work: the first to claim it owns it; - line representatives and a resource chair coordinate. Run AUTOMATICALLY + Keep parallel agents from duplicating work: the first claim owns it; a + resource chair allocates. Run AUTOMATICALLY before work another agent might also do (failure, cluster, feature, gate, deploy, cleanup), and on /kvb-org-organize. --- diff --git a/skills/kvb-org-split/SKILL.md b/skills/kvb-org-split/SKILL.md index 2067c0b..48033d2 100644 --- a/skills/kvb-org-split/SKILL.md +++ b/skills/kvb-org-split/SKILL.md @@ -1,9 +1,9 @@ --- name: kvb-org-split description: > - Keep agent work in its own git worktree until it reaches 90% fix confidence - and under 5% break risk; below that bar it becomes a proposal. Trigger when - delegating work to agents, when agents work in parallel, or on /kvb-org-split. + Keep agent work in its own worktree until 90% fix confidence and under 5% + break risk; below that it becomes a proposal. Trigger when delegating to or + parallelizing agents, or on /kvb-org-split. --- # Funnel diff --git a/skills/kvb-skills-dream/SKILL.md b/skills/kvb-skills-dream/SKILL.md index a9959da..d2de619 100644 --- a/skills/kvb-skills-dream/SKILL.md +++ b/skills/kvb-skills-dream/SKILL.md @@ -109,12 +109,13 @@ as radio buttons: the improvement, the evidence behind it, and the options `Apply now`, `Record as a task`, `Skip`. Ranked by evidence, one question per improvement. No repository file changes before an answer comes back. -## Step 7: kvb-skills-tune, then kvb-skills-auto +## Step 7: kvb-skills-tune, kvb-skills-auto, kvb-transcend With the dialectic done and memory written, run `kvb-skills-tune` β€” hand it this run's -findings as its evidence so it does not re-collect them β€” and then `kvb-skills-auto` -for the operator corrections the window revealed. Both keep their own -confirmation steps; the dream never writes a skill on their behalf. +findings as its evidence so it does not re-collect them β€” then `kvb-skills-auto` +for the operator corrections the window revealed, and `kvb-transcend` for the +scripts and tests the window's sessions wrote outside a repository. All keep +their own confirmation steps; the dream never writes a skill on their behalf. Close the run by refreshing this repository's reminder stamp, so the next hint is twelve hours out: `date +%s > "$(git rev-parse --path-format=absolute --git-common-dir)/.dream-stamp"`. diff --git a/skills/kvb-skills-transcend/SKILL.md b/skills/kvb-skills-transcend/SKILL.md new file mode 100644 index 0000000..b23ab12 --- /dev/null +++ b/skills/kvb-skills-transcend/SKILL.md @@ -0,0 +1,64 @@ +--- +name: kvb-skills-transcend +description: > + Carry the session's skill use and own scripts into the skills repo. + Trigger on /kvb-skills-transcend. +--- + +What an agent improvised in one session dies with it: the helper script in its +scratchpad, the command sequence it ran five times, the skill it had to work +around. This skill carries that into the operator's skills repository, so the +next agent starts from it. It changes nothing before the operator confirms. + +## Step 1: inventory + +Read-only: + +- **Scripts and skills**: `transcend.py scan` of the `kvb-transcend` skill + lists the own scratchpad's scripts, peers' scripts, and skills, commands, + hooks and rules in `~/.claude`, `~/.agents` and `~/.codex` that the skills + repository does not ship; it skips what was already decided and has not + changed. Peer and home candidates follow that skill's consent step. +- **Skills used**: every skill this session invoked, how often, and where it + fell short (a workaround, a step skipped, an operator correction). +- **Recurring moves without a skill**: command sequences, peer exchanges or + lookups run three times or more. `rasci.py tools` of the `kvb-org-rasci` + register lists the scripts the session's tasks recorded. + +One line each: `candidate -> source -> evidence`. + +## Step 2: classify + +Per candidate one target: a script under an existing skill's `scripts/`, a +new skill, a sharper step in an existing skill, a memory entry instead, or +drop. Recurring moves without a script go to the `kvb-skills-tune` skill as +evidence. A script is portable only once its host paths, names and values +became explicit parameters (the `kvb-code-no-defaults` skill); one that needs +a secret, a credential or a single host is dropped or stays a memory entry. + +## Step 3: listen and confirm + +Run the `kvb-think-listen` skill on the open points: scope, target skill, +generality. Then `AskUserQuestion`: one `multiSelect` with the candidates +(target and evidence in each description, nothing preselected), and per pick a +single-select `Write it` / `Revise it` / `Discard` showing the drafted file or +diff. + +## Step 4: write + +- Write where the `kvb-skills-tune` skill's **Persistence** section says: + the skills repository as source of truth, then the live mirrors. +- Read every script whole before carrying it; strip comments the way the + `kvb-code-no-comments` skill does. +- A carried script gets a unit test in the repository's `tests/` with a + negative control. Tests run only on the resource chair's grant (the + `kvb-org-organize` skill). +- `transcend.py mark` every offered candidate with its verdict. +- Leave the result uncommitted; committing is the `kvb-git-commit-next` + skill's job. + +## Output + +| Candidate | Source | Target | Verdict | + +Dropped candidates stay with their reason. diff --git a/skills/kvb-skills-tune/SKILL.md b/skills/kvb-skills-tune/SKILL.md index 2cef335..369b950 100644 --- a/skills/kvb-skills-tune/SKILL.md +++ b/skills/kvb-skills-tune/SKILL.md @@ -1,10 +1,9 @@ --- name: kvb-skills-tune description: > - Generate new skills or optimize existing ones (fewer tokens, fewer errors, - persistent learnings), each proposal confirmed by single-select question. - Trigger ONLY on an explicit request: /kvb-skills-tune, "autotune", "tune the - skills"; never on your own initiative. + Generate new skills or optimize existing ones (fewer tokens and errors, + persistent learnings), each proposal confirmed. Trigger ONLY on an explicit + request: /kvb-skills-tune, "autotune", "tune the skills". --- Autotune turns observed friction into durable skills. It runs on demand only, @@ -63,6 +62,9 @@ where readable, from the project's memory and agent instruction files: deploys, questions that stalled an autonomous run. - **Lost learnings**: facts re-derived this session that a memory file or a skill should already have carried. +- **Own scripts**: helpers the agent wrote outside any repository belong to + the `kvb-skills-transcend` skill, which carries them over; this skill + takes the frictions they reveal. List each finding as one line: `symptom -> cost -> candidate fix`. Skip anything that happened once and is not a pattern. diff --git a/skills/kvb-think-dialectic/SKILL.md b/skills/kvb-think-dialectic/SKILL.md index f23d711..9ce1227 100644 --- a/skills/kvb-think-dialectic/SKILL.md +++ b/skills/kvb-think-dialectic/SKILL.md @@ -3,8 +3,7 @@ name: kvb-think-dialectic description: > Reach a defensible conclusion by thesis, independent skeptic antithesis and synthesis, iterated to ~99% confidence. Trigger for any consequential root - cause, design or decision where being wrong is expensive and the answer is - uncertain. + cause, design or decision that is uncertain and expensive to get wrong. --- Reach the truth by dialectic, not by first guess. Do not stop at a plausible diff --git a/skills/kvb-transcend/SKILL.md b/skills/kvb-transcend/SKILL.md new file mode 100644 index 0000000..913e95d --- /dev/null +++ b/skills/kvb-transcend/SKILL.md @@ -0,0 +1,35 @@ +--- +name: kvb-transcend +description: > + Run both transcend skills in one pass. Trigger on /kvb-transcend. +--- + +Collector for the `kvb-skills-transcend` and `kvb-code-transcend` skills, so +one harvest needs one round of questions. Its script finds the candidates and +remembers every decision, so a rescan offers only what is new or changed: + +```bash +transcend.py scan --state /.state/transcend.json \ + --skills-repo --session-dir \ + --peers-root /tmp/claude- --home ~ [--json] +transcend.py mark --state carried|dropped|later +``` + +`${CLAUDE_SKILL_DIR}/scripts/transcend.py` scans three sources: the own +scratchpad, every peer session's scratchpad, and the agent homes +(`~/.claude`, `~/.agents`, `~/.codex`) for skills, commands, agents, hooks +and rules the skills repository does not ship. It keeps scripts, tests and +skills, and skips files named like secrets and files over 1 MB. Carried or +dropped candidates return only when their content changes; `later` returns +every time. + +1. Scan, then classify each candidate by both skills' classify steps. +2. Consent: a peer's candidates go to that peer, one message listing them, + and only what it releases stays; home candidates need the operator's yes. +3. One `kvb-think-listen` round and one confirmation for all candidates, + grouped by target: the skills repository or the current repository. In + the skills repository itself, scripts and skills go to + `kvb-skills-transcend`, tests to `kvb-code-transcend`. +4. Hand each confirmed candidate to its skill's write step, then `mark` every + offered candidate with its verdict. +5. One table: `| Candidate | Source | Target | Verdict |`. diff --git a/skills/kvb-transcend/scripts/transcend.py b/skills/kvb-transcend/scripts/transcend.py new file mode 100755 index 0000000..90e186d --- /dev/null +++ b/skills/kvb-transcend/scripts/transcend.py @@ -0,0 +1,187 @@ +#!/usr/bin/env python3 +"""Find scripts, tests and skills worth carrying over, and remember every decision. + +Exit codes: + 0 done, 2 usage error. +""" + +from __future__ import annotations + +import argparse +import datetime +import hashlib +import json +import os +import re +import sys +from pathlib import Path + +SCRIPT_SUFFIXES = {".py", ".sh", ".bash", ".js", ".mjs", ".ts", ".rb", ".pl"} +TEST_NAME = re.compile(r"(^test_.+|.+_test|.+\.(spec|test))\.[a-z]+$") +SECRET_NAME = re.compile( + r"(secret|token|credential|passw|auth|\.env$|id_[a-z0-9]+$|\.pem$|\.key$)", re.IGNORECASE +) +MAX_BYTES = 1_000_000 +HOME_SOURCES = { + ".claude/skills": "skill", + ".agents/skills": "skill", + ".codex/skills": "skill", + ".claude/commands": "command", + ".claude/agents": "agent", + ".claude/hooks": "script", + ".claude/scripts": "script", + ".codex/rules": "rule", +} +SETTLED = ("carried", "dropped") +VERDICTS = (*SETTLED, "later") + + +def fail(message: str) -> None: + print(f"transcend: {message}", file=sys.stderr) + sys.exit(2) + + +def digest(path: Path) -> str: + sha = hashlib.sha256() + files = sorted(p for p in path.rglob("*") if p.is_file()) if path.is_dir() else [path] + for file in files: + sha.update(str(file.relative_to(path) if path.is_dir() else file.name).encode()) + sha.update(file.read_bytes()) + return sha.hexdigest() + + +def file_kind(path: Path) -> str | None: + if SECRET_NAME.search(path.name) or path.stat().st_size > MAX_BYTES: + return None + if TEST_NAME.match(path.name): + return "test" + if path.suffix in SCRIPT_SUFFIXES or os.access(path, os.X_OK): + return "script" + return None + + +def session_names(home: Path) -> dict[str, str]: + names = {} + for path in (home / ".claude" / "sessions").glob("*.json"): + try: + data = json.loads(path.read_text(encoding="utf-8")) + except (OSError, ValueError): + continue + if data.get("sessionId"): + names[data["sessionId"]] = data.get("name") or data["sessionId"] + return names + + +def scratch_candidates(root: Path, source: str, owner: str) -> list[dict]: + found = [] + for path in sorted(p for p in root.rglob("*") if p.is_file()): + kind = file_kind(path) + if kind: + found.append({"source": source, "owner": owner, "kind": kind, "path": str(path)}) + return found + + +def shipped(skills_repo: Path) -> set[str]: + names = {p.name for p in (skills_repo / "skills").iterdir() if p.is_dir()} + lock = json.loads((skills_repo / "skills-lock.json").read_text(encoding="utf-8")) + return names | set(lock.get("skills", {})) + + +def home_candidates(home: Path, skills_repo: Path) -> list[dict]: + known = shipped(skills_repo) + found = [] + for sub, kind in HOME_SOURCES.items(): + base = home / sub + if not base.is_dir(): + continue + for entry in sorted(base.iterdir()): + if entry.name.startswith(".") or SECRET_NAME.search(entry.name): + continue + if kind == "skill" and (entry.name in known or not (entry / "SKILL.md").is_file()): + continue + if kind != "skill" and not entry.is_file(): + continue + found.append({"source": "home", "owner": "operator", "kind": kind, "path": str(entry)}) + return found + + +def load_state(path: Path) -> dict[str, dict]: + try: + return json.loads(path.read_text(encoding="utf-8")) + except FileNotFoundError: + return {} + except ValueError: + fail(f"{path} is not valid JSON") + + +def cmd_scan(args: argparse.Namespace) -> int: + session = Path(args.session_dir).resolve() + home = Path(args.home) + names = session_names(home) + candidates = scratch_candidates(session, "own", "self") + for scratch in sorted(Path(args.peers_root).glob("*/*/scratchpad")): + if scratch.resolve() == session: + continue + sid = scratch.parent.name + candidates += scratch_candidates(scratch, "peer", names.get(sid, sid)) + candidates += home_candidates(home, Path(args.skills_repo)) + + state = load_state(Path(args.state)) + offered = [] + for item in candidates: + seen = state.get(item["path"]) + item["digest"] = digest(Path(item["path"])) + if seen and seen["verdict"] in SETTLED and seen["digest"] == item["digest"]: + continue + item["previous"] = seen["verdict"] if seen else None + offered.append(item) + if args.json: + print(json.dumps(offered, indent=1)) + else: + for item in offered: + note = f"\t(was {item['previous']})" if item["previous"] else "" + print(f"{item['source']}\t{item['owner']}\t{item['kind']}\t{item['path']}{note}") + return 0 + + +def cmd_mark(args: argparse.Namespace) -> int: + target = Path(args.path) + if not target.exists(): + fail(f"{target} does not exist") + state_path = Path(args.state) + state = load_state(state_path) + state[str(target)] = { + "verdict": args.verdict, + "digest": digest(target), + "at": datetime.datetime.now(datetime.UTC).isoformat(timespec="seconds"), + } + state_path.parent.mkdir(parents=True, exist_ok=True) + tmp = state_path.with_suffix(".tmp") + tmp.write_text(json.dumps(state, indent=1, sort_keys=True), encoding="utf-8") + tmp.replace(state_path) + print(f"{args.verdict} {target}") + return 0 + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + sub = parser.add_subparsers(dest="cmd", required=True) + p = sub.add_parser("scan") + p.add_argument("--state", required=True) + p.add_argument("--skills-repo", required=True) + p.add_argument("--session-dir", required=True) + p.add_argument("--peers-root", required=True) + p.add_argument("--home", required=True) + p.add_argument("--json", action="store_true") + p.set_defaults(func=cmd_scan) + p = sub.add_parser("mark") + p.add_argument("--state", required=True) + p.add_argument("path") + p.add_argument("verdict", choices=VERDICTS) + p.set_defaults(func=cmd_mark) + args = parser.parse_args() + return args.func(args) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/test_naming.py b/tests/test_naming.py index f8a5d4c..995ac45 100644 --- a/tests/test_naming.py +++ b/tests/test_naming.py @@ -10,7 +10,7 @@ REPO_ROOT = Path(__file__).resolve().parents[1] SKILLS = REPO_ROOT / "skills" README = REPO_ROOT / "README.md" CATEGORIES = ("think", "create", "code", "ci", "git", "check", "org", "skills") -UNCATEGORIZED = frozenset({"kvb-cleanup", "kvb-help"}) +UNCATEGORIZED = frozenset({"kvb-cleanup", "kvb-help", "kvb-transcend"}) SPEC = re.compile(r"^[a-z0-9]+(-[a-z0-9]+)*$") SCHEMA = re.compile(rf"^kvb-(?:{'|'.join(CATEGORIES)})-[a-z0-9]+(?:-[a-z0-9]+)*$") NAME_LINE = re.compile(r"^name:\s*(\S+)\s*$", re.MULTILINE) diff --git a/tests/test_transcend.py b/tests/test_transcend.py new file mode 100644 index 0000000..55e6ee1 --- /dev/null +++ b/tests/test_transcend.py @@ -0,0 +1,113 @@ +"""Exercise transcend.py: sources, secret and size filters, and the decision memory.""" + +from __future__ import annotations + +import json +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[1] +SCRIPT = REPO_ROOT / "skills" / "kvb-transcend" / "scripts" / "transcend.py" + + +class TestTranscend(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.addCleanup(self.tmp.cleanup) + root = Path(self.tmp.name) + self.home = root / "home" + self.peers = root / "peers" + self.own = self.peers / "proj" / "s1" / "scratchpad" + self.peer = self.peers / "proj" / "s2" / "scratchpad" + self.repo = root / "skills-repo" + self.state = self.repo / ".state" / "transcend.json" + for path in (self.own, self.peer, self.repo / "skills" / "kvb-x"): + path.mkdir(parents=True) + (self.repo / "skills-lock.json").write_text( + json.dumps({"skills": {"caveman": {}}}), encoding="utf-8" + ) + sessions = self.home / ".claude" / "sessions" + sessions.mkdir(parents=True) + (sessions / "s2.json").write_text( + json.dumps({"sessionId": "s2", "name": "Peer"}), encoding="utf-8" + ) + self._write(self.own / "probe.py", "print(1)") + self._write(self.own / "test_probe.py", "assert True") + self._write(self.own / "notes.log", "noise") + self._write(self.own / "auth_token.py", "secret") + self._write(self.own / "big.py", "x" * 1_000_001) + self._write(self.peer / "helper.sh", "echo hi") + for name in ("kvb-x", "caveman", "custom"): + self._write(self.home / ".claude" / "skills" / name / "SKILL.md", name) + self._write(self.home / ".codex" / "rules" / "default.rules", "allow") + + def _write(self, path: Path, text: str) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(text, encoding="utf-8") + + def _run(self, *args: str) -> subprocess.CompletedProcess: + return subprocess.run( + [sys.executable, str(SCRIPT), *args], capture_output=True, text=True, check=False + ) + + def _scan(self) -> list[dict]: + result = self._run( + "scan", + "--state", str(self.state), + "--skills-repo", str(self.repo), + "--session-dir", str(self.own), + "--peers-root", str(self.peers), + "--home", str(self.home), + "--json", + ) + self.assertEqual(result.returncode, 0, result.stderr) + return json.loads(result.stdout) + + def _found(self) -> dict[str, tuple[str, str, str]]: + return { + Path(i["path"]).name: (i["source"], i["owner"], i["kind"]) for i in self._scan() + } + + def test_scan_finds_own_peer_and_home_candidates(self): + self.assertEqual( + self._found(), + { + "probe.py": ("own", "self", "script"), + "test_probe.py": ("own", "self", "test"), + "helper.sh": ("peer", "Peer", "script"), + "custom": ("home", "operator", "skill"), + "default.rules": ("home", "operator", "rule"), + }, + ) + + def test_settled_candidates_are_remembered_until_they_change(self): + probe = self.own / "probe.py" + self.assertEqual(self._run("mark", "--state", str(self.state), str(probe), "carried").returncode, 0) + self._run("mark", "--state", str(self.state), str(self.own / "test_probe.py"), "later") + found = {Path(i["path"]).name: i["previous"] for i in self._scan()} + self.assertNotIn("probe.py", found) + self.assertEqual(found["test_probe.py"], "later") + + probe.write_text("print(2)", encoding="utf-8") + found = {Path(i["path"]).name: i["previous"] for i in self._scan()} + self.assertEqual(found["probe.py"], "carried") + + def test_skill_directory_change_is_reoffered(self): + custom = self.home / ".claude" / "skills" / "custom" + self._run("mark", "--state", str(self.state), str(custom), "dropped") + self.assertNotIn("custom", self._found()) + self._write(custom / "scripts" / "run.py", "print(3)") + self.assertIn("custom", self._found()) + + def test_invalid_input_exits_2(self): + self.assertEqual(self._run("mark", "--state", str(self.state), "/no/such/path", "carried").returncode, 2) + self.assertEqual(self._run("mark", "--state", str(self.state), str(self.own), "kept").returncode, 2) + self.assertEqual(self._run("scan", "--state", str(self.state)).returncode, 2) + self.assertFalse(self.state.exists()) + + +if __name__ == "__main__": + unittest.main()