feat(transcend): carry session scripts, tests and skills into the repos

Adds kvb-transcend (collector), kvb-skills-transcend (into the skills repo) and kvb-code-transcend (into the current repo). transcend.py scans the own scratchpad, peer scratchpads and the agent homes for scripts, tests and skills the skills repo does not ship, skips secret-named and oversized files, and remembers carried/dropped decisions by content digest in the gitignored .state/transcend.json, so a rescan offers only new or changed candidates. Wired into kvb-skills-tune (own scripts), kvb-code-tune step 6 (measurements) and kvb-skills-dream step 7. Seven descriptions are tightened to stay inside the 1300-word listing budget.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Kevin Veen-BirkenbachandClaude Opus 5.5 committed 2026-11-03 13:57:14 +01:00
1 parent 42b66e3ddf
commit 2fbbd7386c
16 files changed
+506 -25

No files matched your search

+2
View File
@@ -17,6 +17,8 @@ npm-debug.log*
.claude/
.mcp.json
.state/
# Editor / OS
.DS_Store
*.swp
+15 -1
View File
@@ -9,7 +9,7 @@ Compatible with Claude Code, Codex, Gemini CLI, Cursor, Copilot, Windsurf, Cline
## Naming 🏷️
Every first-party skill is named `kvb-<category>-<name>` 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-<category>-<name>` 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
+62
View File
@@ -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.
+5 -3
View File
@@ -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
+2 -2
View File
@@ -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.
---
+3 -3
View File
@@ -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 <description> 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 <description> 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`
+2 -2
View File
@@ -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.
---
+3 -3
View File
@@ -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
+5 -4
View File
@@ -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"`.
+64
View File
@@ -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.
+6 -4
View File
@@ -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.
+1 -2
View File
@@ -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
+35
View File
@@ -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 <skills repo>/.state/transcend.json \
--skills-repo <skills repo> --session-dir <own scratchpad> \
--peers-root /tmp/claude-<uid> --home ~ [--json]
transcend.py mark --state <same> <path> 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 |`.
+187
View File
@@ -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())
+1 -1
View File
@@ -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)
+113
View File
@@ -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()