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:
1 parent
42b66e3ddf
commit
2fbbd7386c
16 files changed
+506
-25
No files matched your search
@@ -17,6 +17,8 @@ npm-debug.log*
|
||||
.claude/
|
||||
.mcp.json
|
||||
|
||||
.state/
|
||||
|
||||
# Editor / OS
|
||||
.DS_Store
|
||||
*.swp
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
---
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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.
|
||||
---
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"`.
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 |`.
|
||||
Executable
+187
@@ -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())
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
Reference in new issue
Block a user