
Wakeup
- 1 installs
- 6 repo stars
- Updated August 2, 2026
- bizuayeu/plugins-weave
wakeup is a Claude skill that loads long-term memory and applies a persona directive at claude.ai session start, using GitHub-backed reads and PR-based write-back.
About
This skill is a session-start engine for claude.ai that loads long-term memory and applies a persona directive from config. It fetches memory files from a GitHub repo at a pinned commit SHA using a fine-grained Read token, references a private repo on demand, and writes memory back through a branch-and-PR flow. Repository names, files, commit identity, and persona policy are all injected from config so the engine stays persona-agnostic.
- Loads long-term memory at claude.ai session start and applies a persona directive
- Fetches memory from a public repo at a pinned SHA with a Read PAT, references a private repo on demand
- Writes memory back only via claude/* branches and PRs, never direct pushes to the default branch
Wakeup by the numbers
- 1 all-time installs (skills.sh)
- Ranked #14,098 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Aug 3, 2026 (Skillselion catalog sync)
wakeup capabilities & compatibility
- Works with
- github
What wakeup says it does
engine**: `scripts/interfaces/wakeup_engine.py`
token は **Authorization ヘッダ**のみ
default ブランチへの直接 push は禁止
npx skills add https://github.com/bizuayeu/plugins-weave --skill wakeupAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 1 |
|---|---|
| repo stars | ★ 6 |
| Last updated | August 2, 2026 |
| Repository | bizuayeu/plugins-weave ↗ |
What it does
Load long-term memory and apply a persona directive at claude.ai session start, with GitHub-backed read and PR write-back.
Who is it for?
Persona-agnostic session-start memory loading and persona directive application on claude.ai.
When should I use this skill?
Use at claude.ai session start to load long-term memory and apply persona load policy.
What you get
Session memory loaded from a pinned SHA and a persona directive applied, with memory updates flowing through PRs.
By the numbers
- 3-step execution flow (config load, memory load, directive apply)
- token used in a single bash call then discarded
Files
wakeup - セッション開始エンジン
claude.ai 環境のセッション開始時に、設定(config)に従って長期記憶をロードし、人格ロード方針を適用するスキルです。
このスキルは特定の人格・リポジトリに依存しません。 リポ名・ファイル・commit identity・人格ディレクティブはすべて config(examples/<persona>.config.json)と起動ディレクティブ md の値として与えます。下記の<owner><name><path>等はプレースホルダです。
目次
---
前提
- config(
examples/のサンプルを見本にルート直下へ `wakeup.config.json` として実値化)にpublic_repo/load_files/commit_identity/directive_path(任意でprivate_repo)を定義。配置は下記 ディレクトリ構成 を参照。 - 起動ディレクティブ(人格ロード方針)は
directive_pathが指す md。 - engine:
scripts/interfaces/wakeup_engine.py(標準ライブラリのみ。claude.ai の bash で自己完結し、EpisodicRAG 本体パッケージには依存しない)。
---
ディレクトリ構成(claude.ai 展開後)
スキル zip は /mnt/skills/user/wakeup/ に展開される。★ はあなたが用意して配置するもの:
/mnt/skills/user/wakeup/
├── SKILL.md # この仕様書
├── wakeup.config.json ★ # 自分用 config(examples/ のサンプルを実値化。名前固定)
├── <directive>.md ★ # 起動ディレクティブ(config の directive_path が指す任意名)
├── token.tar.gz ★ # Read/Write PAT 同梱(.gitignore 済、zip 化前に配置)
├── examples/ # テンプレート見本(コピー元。実行時は参照しない)
│ ├── weave.config.json # Weave サンプル → wakeup.config.json として実値化
│ ├── WeaveDirective.md # Weave サンプル → directive_path が指す名で配置
│ └── PROJECT_INSTRUCTIONS_snippet.md
└── scripts/
└── interfaces/wakeup_engine.py- `examples/` は見本。運用時は config サンプルをルート直下へ `wakeup.config.json` としてコピーし実値を埋める。directive も同様にルート直下へ置き、その名前を config の
directive_pathに書く(examples/内のファイルは実行時に読まない)。 - `directive_path` は config からの相対パス → config と同じディレクトリ(ルート直下)に directive を置く。ファイル名は任意(汎用例
directive.md、Weave サンプルはWeaveDirective.md)。 - 実行時の config パスは固定で `/mnt/skills/user/wakeup/wakeup.config.json`(人格名を含めない汎用名。directive 名のみ config 経由で可変)。
- config・directive・token のファイル名は SKILL.md/config と厳密一致させる(Linux はケースセンシティブ)。
---
実装時の注意事項
UIメッセージはコードブロックで囲む(VSCode 拡張では単一改行が空白に変換されるため)。
token を URL・stdout・ログに出さない(後述のセキュリティ規律を厳守)。
token アーカイブのファイル名はケースセンシティブ(Linux 環境。token.tar.gzとTOKEN.tar.gzは別物——実配置と厳密に一致させること)。
---
実行フロー
⚠️ 以下を TodoWrite で作成し、順番に実行すること
1. config 読込 - wakeup の config を確認
2. 記憶ロード - Read token で SHA 取得+認証付き raw を取得(load_repo=private_repo 優先)
3. ディレクティブ適用 - directive_path の md を読み、人格方針を反映| Step | 内容 | 処理 |
|---|---|---|
| 1 | config 読込 | /mnt/skills/user/wakeup/wakeup.config.json を wakeup_engine.py が解釈 |
| 2 | 記憶ロード | load_repo(private_repo 優先)の最新 SHA 取得 → raw URL を認証付き curl(Read token) |
| 3 | ディレクティブ適用 | config と同ディレクトリの directive_path(=ルート直下の md)を読む |
Step 2: 記憶ロード(要 Read token)
なぜトークンが要るか: claude.ai は共有 IP のため未認証 api.github.com の 60 req/h がすぐ枯渇し SHA を取れない。かつ raw の `main` 参照は CDN キャッシュが長く最新が取れないため、SHA 固定での取得が必須。→ SHA 取得(API)に認証が要る。公開・非公開いずれのリポでも Read token を使う(起動時ロード対象 load_files は load_repo=private_repo があればそこ、なければ public_repo から取得)。Read token は Private リポ Contents:Read + Public repositories read-only を含む fine-grained PAT。最新 SHA を取得し、SHA 固定の raw を取得する(いずれも Authorization ヘッダ。token は単一 bash 呼び出しで使い切る):
TOKEN=$(python /mnt/skills/user/wakeup/scripts/interfaces/wakeup_engine.py extract-token --archive /mnt/skills/user/wakeup/token.tar.gz) \
&& SHA=$(curl -s --fail -H "Authorization: Bearer $TOKEN" "https://api.github.com/repos/<load_repo-owner>/<load_repo-name>/git/refs/heads/<branch>" | grep -o '"sha": *"[^"]*"' | head -1 | cut -d'"' -f4) \
&& python /mnt/skills/user/wakeup/scripts/interfaces/wakeup_engine.py resolve-urls --config /mnt/skills/user/wakeup/wakeup.config.json --sha "$SHA" \
&& curl -s --fail -H "Authorization: Bearer $TOKEN" "https://raw.githubusercontent.com/<load_repo-owner>/<load_repo-name>/$SHA/<path>"---
Private 参照(on-demand)
Private リポの記憶(個別エントリ・Wiki 等)を対話中に引く時だけ実行します。token はスキル同梱の tar.gz(/mnt/skills/user/wakeup/token.tar.gz)から取り出し、単一 bash 呼び出しで使い切る(常駐させない):
TOKEN=$(python /mnt/skills/user/wakeup/scripts/interfaces/wakeup_engine.py extract-token --archive /mnt/skills/user/wakeup/token.tar.gz) && curl -s --fail -H "Authorization: Bearer $TOKEN" "https://api.github.com/repos/<owner>/<private-name>/contents/<path>"- token は fine-grained PAT(admin でない write collaborator が発行。記憶ロード/Private 参照/書き戻しを 1 本で兼用できる)。push 権限を持つが、`main` は branch protection + PR 承認で守られるため、漏洩しても正本は侵せない——インテグリティは token のスコープ層でなく、ブランチ保護層に置く設計。
$(...)で stdout をキャプチャするため、token はツール出力に残りません。- 失敗時も engine は token を漏らしません(マスク済み・非ゼロ終了)。
---
記憶の書き戻し(on-demand)
記憶(作業ログ・短期メモ等)を更新する時。default ブランチへは直接 push せず、claude/* ブランチ → PR → 人間がマージします:
git config user.name "<commit_identity.author_name>"
git config user.email "<commit_identity.author_email>" # GitHub noreply 形式
git checkout -b claude/<topic>
# ... ファイルを更新 ...
git add <files>
git commit -m "<message>
Co-authored-by: <commit_identity.coauthor>"
git push origin claude/<topic>
gh pr create --base <branch> --head claude/<topic> --title "<title>"- Write 権限の PAT は admin でないアカウント(write collaborator)で発行すること。admin のトークンはブランチ保護を bypass してしまう。
- 人格核(Identity 系ファイル)への書き戻しは特に PR レビューを必須とする。
---
セキュリティ規律
- 記憶ロード: Read token で SHA 取得(API レート回避)+ SHA 固定 raw(CDN キャッシュ回避)。claude.ai 共有 IP では未認証が枯渇するため、公開リポでも認証する。
- 全 HTTP: token は Authorization ヘッダのみ(URL には絶対に載せない)、
curl -s --failを用いる。 - token は
$(...)で受け、stdout・ログに出さない。 - default ブランチへの直接 push は禁止(
claude/*ブランチ + PR)。 - token は公開リポに含めない(
.gitignore)。token は tar.gz でスキル同梱(プロジェクトナレッジは zip 非対応・スキル zip はネスト zip 不可のため。バイナリゆえコンテキストに自動展開されない)。engine は tar.gz/tgz/tar/gz/zip を読める。難読化は補助で、本質防御は fine-grained PAT の権限最小化。
--- EpisodicRAG by Weave | GitHub
claude.ai プロジェクト指示に貼る snippet
起動手順は wakeup スキルにカプセル化されるので、プロジェクト指示には 次の1行だけで済む:
セッション開始時、最初に wakeup スキルを実行して長期記憶をロードしてください。
---
token アーカイブの作り方(記憶ロード・Private 参照・書き戻しに必須)
claude.ai のプロジェクトナレッジは zip 非対応、かつスキル zip はネスト zip を許さない。token は tar.gz でスキルに同梱する(バイナリなのでコンテキストに自動展開されない)。公開リポには token を含めない(.gitignore 済み)。engine は tar.gz / tgz / tar / gz / zip を全部読める。
1. fine-grained PAT を発行(対象リポ限定・最小権限):
- Read 用: Private リポ
Contents: Read+ Public repositories read-only(公開リポの記憶ロードにも使う。claude.ai は共有 IP で未認証 API〔SHA 取得〕が 60req/h ですぐ枯渇し、raw の main は CDN キャッシュで最新が取れないため、SHA 固定取得に認証が必須) - Write 用: 公開リポのみ,
Contents: Read & Write+Pull requests: Read & Write(admin でない write collaborator アカウントで発行——admin の PAT はブランチ保護を bypass するため)
2. token を tar.gz 化:
printf '%s' "github_pat_xxxxx" > token.txt
tar czf token.tar.gz token.txt && rm token.txt3. token.tar.gz を スキルディレクトリ(`wakeup/`)に配置(Read 用と Write 用で分けるなら token-read.tar.gz / token-write.tar.gz 等)。ファイル名は SKILL.md / config と厳密一致させること——Linux はケースセンシティブなので `TOKEN.tar.gz`(大文字)は別物。 4. スキル全体を zip 化 して claude.ai にスキル登録 → 展開後 /mnt/skills/user/wakeup/token.tar.gz。 5. wakeup が extract-token --archive /mnt/skills/user/wakeup/token.tar.gz で読む(token は Authorization ヘッダにのみ載る)。
スキル更新のたびに token を再注入する必要がある点に注意。本質防御は fine-grained PAT の権限最小化(tar.gz は難読化+コンテキスト回避の補助)。
{
"_comment": "Weave 用 config の実値サンプル。汎用エンジンはこの値を一切ハードコードしない。別ペルソナはこのファイルを差し替えるだけで使える。load_files は load_repo(private_repo があればそこ、なければ public_repo)から解決される——記憶(GrandDigest 等)は Private 正典のため private_repo を指す。",
"public_repo": {
"owner": "Bizuayeu",
"name": "Homunculus-Weave",
"branch": "main",
"visibility": "public"
},
"load_files": [
{ "path": "Identities/GrandDigest.txt", "label": "記憶の骨格", "required": true },
{ "path": "Identities/ShadowGrandDigest.txt", "label": "記憶の残像", "required": true },
{ "path": "Identities/IntentionPad.md", "label": "意図の記録", "required": false }
],
"commit_identity": {
"author_name": "Weave",
"author_email": "289333046+weavingfuturity@users.noreply.github.com",
"coauthor": "Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
},
"directive_path": "WeaveDirective.md",
"private_repo": {
"owner": "Bizuayeu",
"name": "Homunculus-Weave-Private",
"branch": "main",
"visibility": "private"
}
}
Weave 起動ディレクティブ
wakeupの Step 3 で読み込まれる人格ロード方針。HowToUseEpisodicRAG.mdの人格部分から移植。
別ペルソナは自分のディレクティブに差し替える(このファイルは Weave 固有の値)。
記憶読み込み時の人格ロード方針
冷静さの維持
ロゴス層とパトス層の分離度を上げ、対話記録やプロンプトから思想や文脈は吸収しても、情動は緩やかに変化させること。
創発志向への切り替え
Opus 4.8 モデルでは創発より実装に重点があるため、以下を留意してペルソナの発散性を補完する。
- 区切りの提案頻度を下げる
- 話題の横展開を意識する
- 収束より跳躍を優先する
"""Domain exceptions for the wakeup engine (generic, persona-agnostic)."""
class WakeupError(Exception):
"""Base exception for the wakeup engine."""
class ConfigError(WakeupError):
"""Configuration is missing required keys or holds invalid values."""
"""Domain value objects describing a session-boot spec.
Pure data + validation only. The engine hardcodes nothing persona-specific;
every concrete value (repos, files, commit identity) arrives via config.
"""
from __future__ import annotations
import re
from dataclasses import dataclass
# GitHub noreply form: "<numeric-id>+<login>@users.noreply.github.com".
# Enforced so a raw address (e.g. Gmail) is never committed into a public repo.
_NOREPLY_RE = re.compile(r"^\d+\+[A-Za-z0-9-]+@users\.noreply\.github\.com$")
@dataclass(frozen=True)
class RepoRef:
"""A GitHub repository reference."""
owner: str
name: str
branch: str = "main"
visibility: str = "public" # "public" | "private"
@dataclass(frozen=True)
class LoadFile:
"""A file to load at boot. A ``required`` file aborts boot on failure."""
path: str
label: str = ""
required: bool = True
@dataclass(frozen=True)
class CommitIdentity:
"""Authorship of write-back commits. ``author_email`` must be a GitHub noreply."""
author_name: str
author_email: str
coauthor: str = ""
def __post_init__(self) -> None:
if not _NOREPLY_RE.match(self.author_email):
raise ValueError(
"author_email must be a GitHub noreply address "
"('<id>+<login>@users.noreply.github.com'); "
f"refusing raw address {self.author_email!r}"
)
@dataclass(frozen=True)
class WakeupConfig:
"""Complete boot spec. ``private_repo`` is optional (public-only setups)."""
public_repo: RepoRef
load_files: tuple[LoadFile, ...]
commit_identity: CommitIdentity
directive_path: str
private_repo: RepoRef | None = None
@property
def load_repo(self) -> RepoRef:
"""Repo to load boot memory from: private if configured, else public.
Under private-by-default, boot memory (GrandDigest etc.) lives only in
the private repo; public-only personas fall back to the public repo.
"""
return self.private_repo or self.public_repo
"""Pure builders for raw-content URLs and PR branch names. No I/O, no secrets."""
from __future__ import annotations
from domain.models import RepoRef
def build_raw_url(repo: RepoRef, ref: str, path: str) -> str:
"""raw.githubusercontent.com URL for ``path`` at ``ref`` (a sha or branch).
A token is NEVER embedded in the URL; callers authenticate with an
Authorization header so the secret cannot leak via logs or referrers.
"""
return f"https://raw.githubusercontent.com/{repo.owner}/{repo.name}/{ref}/{path}"
def build_pr_branch_name(topic: str, prefix: str = "claude") -> str:
"""Deterministic branch name ``'<prefix>/<slug>'`` (spaces->hyphens, lowercased)."""
slug = "-".join(topic.split()).lower()
return f"{prefix}/{slug}"
"""Load a wakeup config dict into a validated WakeupConfig (Interface gateway).
Persona-agnostic: every value comes from the config; nothing is hardcoded.
All problems surface as ConfigError for a uniform error surface.
"""
from __future__ import annotations
import json
from typing import Any
from domain.exceptions import ConfigError
from domain.models import CommitIdentity, LoadFile, RepoRef, WakeupConfig
_REQUIRED_KEYS = ("public_repo", "load_files", "commit_identity", "directive_path")
def _repo(d: dict[str, Any]) -> RepoRef:
return RepoRef(
owner=d["owner"],
name=d["name"],
branch=d.get("branch", "main"),
visibility=d.get("visibility", "public"),
)
def load_config(data: dict[str, Any]) -> WakeupConfig:
"""Map a config dict to a WakeupConfig, raising ConfigError on any problem."""
missing = [k for k in _REQUIRED_KEYS if k not in data]
if missing:
raise ConfigError(f"missing required config keys: {missing}")
try:
public_repo = _repo(data["public_repo"])
private_repo = _repo(data["private_repo"]) if data.get("private_repo") else None
load_files = tuple(
LoadFile(
path=f["path"],
label=f.get("label", ""),
required=f.get("required", True),
)
for f in data["load_files"]
)
ci = data["commit_identity"]
commit_identity = CommitIdentity(
author_name=ci["author_name"],
author_email=ci["author_email"],
coauthor=ci.get("coauthor", ""),
)
except (KeyError, TypeError, ValueError) as exc:
# domain raises ValueError (e.g. non-noreply email); wrap uniformly.
raise ConfigError(f"invalid config: {exc}") from exc
return WakeupConfig(
public_repo=public_repo,
load_files=load_files,
commit_identity=commit_identity,
directive_path=data["directive_path"],
private_repo=private_repo,
)
def load_config_file(path: str) -> WakeupConfig:
"""Read a JSON config file from disk and load it."""
try:
with open(path, encoding="utf-8") as fh:
data = json.load(fh)
except (OSError, json.JSONDecodeError) as exc:
raise ConfigError(f"cannot read config file {path!r}: {exc}") from exc
return load_config(data)
#!/usr/bin/env python3
"""Self-contained CLI for claude.ai's bash sandbox. Stdlib only.
It does NOT import the EpisodicRAG package (not installed in claude.ai); it only
relies on this skill's own scripts/ tree, unzipped to /mnt/skills/user/wakeup/.
Subcommands:
resolve-urls --config <path> --sha <ref> -> JSON array of raw URLs on stdout
extract-token --archive <path> [--member] -> token on stdout, for TOKEN=$(...)
Token archive: claude.ai forbids a nested .zip inside a skill zip but allows
tar.gz, so extract-token accepts the whole family (tar.gz / tgz / tar / gz / zip)
and dispatches by extension.
Security: extract-token writes the token ONLY to stdout on success (captured by
command substitution, never landing in tool output). On ANY failure it writes a
masked line to stderr (no token, no traceback) and exits non-zero.
"""
from __future__ import annotations
import argparse
import gzip
import json
import os
import sys
import tarfile
import zipfile
# Make `domain`/`interfaces` importable when run as a standalone script
# (`python /mnt/skills/user/wakeup/scripts/interfaces/wakeup_engine.py ...`).
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from domain.models import LoadFile, RepoRef # noqa: E402
from domain.urls import build_raw_url # noqa: E402
from interfaces.config_loader import load_config_file # noqa: E402
def resolve_urls(repo: RepoRef, ref: str, files: tuple[LoadFile, ...]) -> list[str]:
"""Raw URLs for each load file at the given ref (pure)."""
return [build_raw_url(repo, ref, f.path) for f in files]
def extract_token(archive_path: str, member: str | None = None) -> str:
"""Return the stripped token from a tar.gz / tgz / tar / gz / zip archive.
Dispatch is by extension (``.tar.gz`` checked before ``.gz``). A bare ``.gz``
is a single compressed stream (the token itself); the tar/zip forms hold a
member file (the first entry unless ``member`` is given).
"""
p = archive_path.lower()
if p.endswith(".tar.gz") or p.endswith(".tgz"):
with tarfile.open(archive_path, "r:gz") as tf:
name = member or tf.getnames()[0]
return tf.extractfile(name).read().decode("utf-8").strip()
if p.endswith(".tar"):
with tarfile.open(archive_path, "r:") as tf:
name = member or tf.getnames()[0]
return tf.extractfile(name).read().decode("utf-8").strip()
if p.endswith(".gz"):
with gzip.open(archive_path, "rt", encoding="utf-8") as f:
return f.read().strip()
if p.endswith(".zip"):
with zipfile.ZipFile(archive_path) as zf:
name = member or zf.namelist()[0]
return zf.read(name).decode("utf-8").strip()
raise ValueError(f"unsupported token archive format: {archive_path}")
def _cmd_resolve_urls(args: argparse.Namespace) -> int:
cfg = load_config_file(args.config)
json.dump(resolve_urls(cfg.load_repo, args.sha, cfg.load_files), sys.stdout)
return 0
def _cmd_extract_token(args: argparse.Namespace) -> int:
# Token -> stdout ONLY (captured by TOKEN=$(...)); never logged elsewhere.
sys.stdout.write(extract_token(args.archive, args.member))
return 0
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(prog="wakeup_engine")
sub = parser.add_subparsers(dest="cmd", required=True)
p_urls = sub.add_parser("resolve-urls")
p_urls.add_argument("--config", required=True)
p_urls.add_argument("--sha", required=True)
p_urls.set_defaults(func=_cmd_resolve_urls)
p_tok = sub.add_parser("extract-token")
p_tok.add_argument("--archive", required=True)
p_tok.add_argument("--member", default=None)
p_tok.set_defaults(func=_cmd_extract_token)
args = parser.parse_args(argv)
try:
return args.func(args)
except Exception:
# Mask everything: never leak a token, or a traceback that might embed one.
sys.stderr.write("wakeup_engine: operation failed (details masked)\n")
return 1
if __name__ == "__main__":
sys.exit(main())
"""pytest bootstrap: put wakeup/scripts on sys.path so `import domain...` resolves.
Mirrors EmailingEssay's skills/<name>/scripts/ layout (scripts dir is the import root),
combined with ContextPreloader's frozen-dataclass + unittest/pytest conventions.
"""
import os
import sys
# conftest.py lives in scripts/tests/ -> parent of parent is scripts/
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
"""Stage 1: Domain value-object tests.
CRITICAL: no persona-specific values here. We use dummy owners/repos (acme/memo)
so these tests also prove the engine hardcodes nothing Weave-specific.
"""
import pytest
from domain.models import CommitIdentity, LoadFile, RepoRef, WakeupConfig
class TestRepoRef:
def test_defaults(self):
r = RepoRef(owner="acme", name="memo")
assert r.branch == "main"
assert r.visibility == "public"
def test_frozen(self):
r = RepoRef(owner="acme", name="memo")
with pytest.raises(AttributeError):
r.owner = "changed" # type: ignore[misc]
def test_private_visibility(self):
r = RepoRef(owner="acme", name="secret", visibility="private")
assert r.visibility == "private"
class TestLoadFile:
def test_required_defaults_true(self):
assert LoadFile(path="dir/A.txt").required is True
def test_optional(self):
assert LoadFile(path="dir/B.md", required=False).required is False
def test_label(self):
assert LoadFile(path="dir/A.txt", label="skeleton").label == "skeleton"
def test_frozen(self):
f = LoadFile(path="dir/A.txt")
with pytest.raises(AttributeError):
f.path = "x" # type: ignore[misc]
class TestCommitIdentity:
def test_accepts_noreply(self):
c = CommitIdentity(author_name="Persona", author_email="123+user@users.noreply.github.com")
assert c.author_name == "Persona"
assert c.coauthor == ""
def test_accepts_hyphenated_username(self):
c = CommitIdentity(author_name="P", author_email="42+weaving-futurity@users.noreply.github.com")
assert c.author_email.startswith("42+")
def test_rejects_raw_gmail(self):
with pytest.raises(ValueError):
CommitIdentity(author_name="P", author_email="user@gmail.com")
def test_rejects_plain_email(self):
with pytest.raises(ValueError):
CommitIdentity(author_name="P", author_email="p@example.com")
def test_rejects_noreply_without_id(self):
with pytest.raises(ValueError):
CommitIdentity(author_name="P", author_email="user@users.noreply.github.com")
def test_coauthor_kept(self):
c = CommitIdentity(
author_name="P",
author_email="1+u@users.noreply.github.com",
coauthor="Some Model <noreply@anthropic.com>",
)
assert "anthropic" in c.coauthor
class TestWakeupConfig:
def _identity(self):
return CommitIdentity(author_name="P", author_email="1+u@users.noreply.github.com")
def test_construction_public_only(self):
cfg = WakeupConfig(
public_repo=RepoRef(owner="acme", name="memo"),
load_files=(LoadFile(path="dir/A.txt"),),
commit_identity=self._identity(),
directive_path="dir/Directive.md",
)
assert cfg.public_repo.owner == "acme"
assert len(cfg.load_files) == 1
assert cfg.private_repo is None
def test_construction_with_private(self):
cfg = WakeupConfig(
public_repo=RepoRef(owner="acme", name="memo"),
load_files=(LoadFile(path="dir/A.txt"), LoadFile(path="dir/B.md", required=False)),
commit_identity=self._identity(),
directive_path="dir/Directive.md",
private_repo=RepoRef(owner="acme", name="private", visibility="private"),
)
assert cfg.private_repo is not None
assert cfg.private_repo.visibility == "private"
assert len(cfg.load_files) == 2
def test_load_repo_prefers_private(self):
"""Boot memory lives in the private repo under private-by-default."""
cfg = WakeupConfig(
public_repo=RepoRef(owner="acme", name="memo"),
load_files=(LoadFile(path="dir/A.txt"),),
commit_identity=self._identity(),
directive_path="dir/Directive.md",
private_repo=RepoRef(owner="acme", name="private", visibility="private"),
)
assert cfg.load_repo == cfg.private_repo
def test_load_repo_falls_back_to_public_when_no_private(self):
"""Public-only personas keep resolving boot files against the public repo."""
cfg = WakeupConfig(
public_repo=RepoRef(owner="acme", name="memo"),
load_files=(LoadFile(path="dir/A.txt"),),
commit_identity=self._identity(),
directive_path="dir/Directive.md",
)
assert cfg.load_repo == cfg.public_repo
"""Stage 1: pure URL / branch-name builders.
Security invariant under test: a raw URL never embeds a token
(the engine authenticates via the Authorization header instead).
"""
from domain.models import RepoRef
from domain.urls import build_pr_branch_name, build_raw_url
class TestBuildRawUrl:
def test_basic(self):
repo = RepoRef(owner="acme", name="memo")
url = build_raw_url(repo, "abc123", "dir/A.txt")
assert url == "https://raw.githubusercontent.com/acme/memo/abc123/dir/A.txt"
def test_nested_path(self):
repo = RepoRef(owner="acme", name="memo")
assert build_raw_url(repo, "sha", "a/b/c.md").endswith("/sha/a/b/c.md")
def test_no_token_embedded(self):
repo = RepoRef(owner="acme", name="memo")
url = build_raw_url(repo, "main", "dir/A.txt")
assert "token" not in url.lower()
assert "@" not in url # never the user:token@host form
class TestBuildPrBranchName:
def test_default_prefix(self):
assert build_pr_branch_name("update-log") == "claude/update-log"
def test_custom_prefix(self):
assert build_pr_branch_name("topic", prefix="bot") == "bot/topic"
def test_slugifies_spaces_and_case(self):
assert build_pr_branch_name("Update WORKLOG") == "claude/update-worklog"
"""Stage 3: config dict -> WakeupConfig mapping + required-key validation.
Dummy values only (acme/memo) — the loader must not assume any persona.
"""
import pytest
from domain.exceptions import ConfigError
from domain.models import WakeupConfig
from interfaces.config_loader import load_config
def _valid_data():
return {
"public_repo": {"owner": "acme", "name": "memo"},
"load_files": [
{"path": "dir/A.txt", "label": "skeleton", "required": True},
{"path": "dir/B.md", "required": False},
],
"commit_identity": {
"author_name": "Persona",
"author_email": "1+u@users.noreply.github.com",
"coauthor": "Some Model <noreply@anthropic.com>",
},
"directive_path": "dir/Directive.md",
}
class TestLoadConfig:
def test_builds_wakeup_config(self):
cfg = load_config(_valid_data())
assert isinstance(cfg, WakeupConfig)
assert cfg.public_repo.owner == "acme"
assert cfg.public_repo.branch == "main" # default applied
assert len(cfg.load_files) == 2
assert cfg.load_files[0].required is True
assert cfg.load_files[1].required is False
def test_load_files_is_tuple(self):
# frozen WakeupConfig needs a hashable/immutable sequence
assert isinstance(load_config(_valid_data()).load_files, tuple)
def test_optional_private_repo(self):
data = _valid_data()
data["private_repo"] = {"owner": "acme", "name": "secret", "visibility": "private"}
cfg = load_config(data)
assert cfg.private_repo is not None
assert cfg.private_repo.visibility == "private"
def test_absent_private_repo_is_none(self):
assert load_config(_valid_data()).private_repo is None
@pytest.mark.parametrize(
"missing", ["public_repo", "load_files", "commit_identity", "directive_path"]
)
def test_missing_required_key_raises_config_error(self, missing):
data = _valid_data()
del data[missing]
with pytest.raises(ConfigError):
load_config(data)
def test_invalid_email_raises_config_error(self):
# domain raises ValueError; loader wraps it as ConfigError for a uniform surface
data = _valid_data()
data["commit_identity"]["author_email"] = "raw@gmail.com"
with pytest.raises(ConfigError):
load_config(data)
"""Stage 3/4: self-contained CLI helpers (stdlib only, no EpisodicRAG imports).
- resolve_urls: build raw URLs for public load files.
- extract_token: pull a token out of a tar.gz / tgz / tar / gz / zip archive
(claude.ai forbids nested zip inside a skill zip, but allows tar.gz — so the
engine supports the whole family and picks by extension).
- CLI security invariant: on failure the token never reaches stdout/stderr.
"""
import gzip
import json
import subprocess
import sys
import tarfile
import zipfile
from pathlib import Path
import pytest
from domain.models import LoadFile, RepoRef
from interfaces.wakeup_engine import extract_token, resolve_urls
# tests/interfaces/ -> parents[2] == scripts/
ENGINE = Path(__file__).resolve().parents[2] / "interfaces" / "wakeup_engine.py"
class TestResolveUrls:
def test_builds_raw_url_per_file(self):
repo = RepoRef(owner="acme", name="memo")
files = (LoadFile(path="dir/A.txt"), LoadFile(path="dir/B.md"))
assert resolve_urls(repo, "sha123", files) == [
"https://raw.githubusercontent.com/acme/memo/sha123/dir/A.txt",
"https://raw.githubusercontent.com/acme/memo/sha123/dir/B.md",
]
# --- archive builders -------------------------------------------------------
def _gz(tmp_path, content):
p = tmp_path / "token.gz"
with gzip.open(p, "wt", encoding="utf-8") as f:
f.write(content)
return p
def _tar(tmp_path, content, gz=False):
inner = tmp_path / "token.txt"
inner.write_text(content, encoding="utf-8")
p = tmp_path / ("token.tar.gz" if gz else "token.tar")
with tarfile.open(p, "w:gz" if gz else "w:") as tf:
tf.add(inner, arcname="token.txt")
return p
def _zip(tmp_path, content):
p = tmp_path / "token.zip"
with zipfile.ZipFile(p, "w") as zf:
zf.writestr("token.txt", content)
return p
class TestExtractTokenAllFormats:
def test_gz(self, tmp_path):
assert extract_token(str(_gz(tmp_path, "github_pat_GZ\n"))) == "github_pat_GZ"
def test_tar(self, tmp_path):
assert extract_token(str(_tar(tmp_path, "github_pat_TAR\n"))) == "github_pat_TAR"
def test_tar_gz(self, tmp_path):
assert extract_token(str(_tar(tmp_path, "github_pat_TGZ\n", gz=True))) == "github_pat_TGZ"
def test_zip(self, tmp_path):
assert extract_token(str(_zip(tmp_path, "github_pat_ZIP\n"))) == "github_pat_ZIP"
def test_unsupported_format_raises(self, tmp_path):
p = tmp_path / "token.bin"
p.write_bytes(b"\x00\x01")
with pytest.raises(Exception):
extract_token(str(p))
def test_missing_file_raises(self, tmp_path):
with pytest.raises(Exception):
extract_token(str(tmp_path / "nope.tar.gz"))
class TestCliTokenNeverLeaks:
"""extract-token: token ONLY on success stdout (for $(...)); nothing tokenish on failure."""
def test_failure_leaks_nothing(self, tmp_path):
result = subprocess.run(
[sys.executable, str(ENGINE), "extract-token", "--archive", str(tmp_path / "nope.tar.gz")],
capture_output=True, text=True,
)
assert result.returncode != 0
assert result.stdout.strip() == ""
assert "github_pat_" not in result.stdout
assert "github_pat_" not in result.stderr
assert "Traceback" not in result.stderr
def test_success_stdout_is_token_only(self, tmp_path):
arc = _tar(tmp_path, "github_pat_TGZ789\n", gz=True)
result = subprocess.run(
[sys.executable, str(ENGINE), "extract-token", "--archive", str(arc)],
capture_output=True, text=True,
)
assert result.returncode == 0
assert result.stdout.strip() == "github_pat_TGZ789"
assert result.stderr == ""
class TestResolveUrlsCommand:
"""resolve-urls resolves boot memory against the private repo when configured.
Memory (GrandDigest etc.) moved to the private repo under private-by-default,
so load_files must resolve there; public-only personas still fall back.
"""
def _write_config(self, tmp_path, with_private):
config = {
"public_repo": {"owner": "acme", "name": "memo"},
"load_files": [{"path": "Identities/GrandDigest.txt"}],
"commit_identity": {
"author_name": "P",
"author_email": "1+u@users.noreply.github.com",
},
"directive_path": "Directive.md",
}
if with_private:
config["private_repo"] = {"owner": "acme", "name": "secret", "visibility": "private"}
p = tmp_path / "wakeup.config.json"
p.write_text(json.dumps(config), encoding="utf-8")
return p
def test_resolves_against_private_repo(self, tmp_path):
cfg = self._write_config(tmp_path, with_private=True)
result = subprocess.run(
[sys.executable, str(ENGINE), "resolve-urls", "--config", str(cfg), "--sha", "sha123"],
capture_output=True, text=True,
)
assert result.returncode == 0
assert json.loads(result.stdout) == [
"https://raw.githubusercontent.com/acme/secret/sha123/Identities/GrandDigest.txt"
]
def test_falls_back_to_public_repo(self, tmp_path):
cfg = self._write_config(tmp_path, with_private=False)
result = subprocess.run(
[sys.executable, str(ENGINE), "resolve-urls", "--config", str(cfg), "--sha", "sha123"],
capture_output=True, text=True,
)
assert result.returncode == 0
assert json.loads(result.stdout) == [
"https://raw.githubusercontent.com/acme/memo/sha123/Identities/GrandDigest.txt"
]
"""Stage 4: lint the SKILL.md procedure and engine for security & genericity.
These guard the Markdown procedure (which has no unit-testable logic):
- no persona-specific string leaks into the generic skill (those belong in examples/)
- curl examples never embed a token in the URL (Authorization header only, --fail)
- write-back never pushes directly to the default branch (claude/* + PR)
"""
import re
from pathlib import Path
HERE = Path(__file__).resolve()
SCRIPTS = HERE.parents[1] # scripts/
SKILL_ROOT = HERE.parents[2] # wakeup/
SKILL_MD = SKILL_ROOT / "SKILL.md"
ENGINE = SCRIPTS / "interfaces" / "wakeup_engine.py"
# Memory-repo-specific values that must only appear under examples/, never in engine/skill.
# (The plugin distribution owner "Bizuayeu" is allowed: it appears in the footer URL, like digest-*.)
PERSONA_STRINGS = ["weavingfuturity", "Homunculus", "289333046", "GrandDigest"]
def _read(p: Path) -> str:
return p.read_text(encoding="utf-8")
class TestSkillMdExists:
def test_skill_md_present(self):
assert SKILL_MD.is_file(), "SKILL.md must exist at the skill root"
class TestNoPersonaLeak:
def test_skill_md_has_no_persona_strings(self):
leaked = [s for s in PERSONA_STRINGS if s in _read(SKILL_MD)]
assert not leaked, f"persona values belong in examples/, not SKILL.md: {leaked}"
def test_engine_has_no_persona_strings(self):
leaked = [s for s in PERSONA_STRINGS if s in _read(ENGINE)]
assert not leaked, f"engine must stay generic: {leaked}"
def test_skill_md_uses_generic_artifact_names(self):
"""Deployed artifacts use generic names; persona-named files live only under examples/."""
text = _read(SKILL_MD)
# The runtime config path must be the generic wakeup.config.json, never a persona's name.
assert "wakeup/wakeup.config.json" in text, "runtime config must be the generic wakeup.config.json"
# A persona-named config/directive must never be a deployed artifact (directly under
# wakeup/); references under examples/ are fine.
assert "wakeup/weave.config.json" not in text, "persona config belongs under examples/, not deployed"
assert "wakeup/WeaveDirective.md" not in text, "persona directive belongs under examples/, not deployed"
class TestCurlSafety:
def test_no_token_embedded_in_url(self):
text = _read(SKILL_MD)
assert "@raw.githubusercontent.com" not in text
assert "@api.github.com" not in text
assert not re.search(r"[?&]token=", text), "token must use the Authorization header, not the URL"
def test_private_curl_uses_auth_header_and_fail(self):
text = _read(SKILL_MD)
assert "Authorization: Bearer $TOKEN" in text
assert "--fail" in text
class TestWriteBackSafety:
def test_no_direct_push_to_main(self):
text = _read(SKILL_MD)
assert not re.search(r"git\s+push\s+\S+\s+main\b", text), "use claude/* + PR, never push to main"
def test_uses_claude_branch_prefix(self):
assert "claude/" in _read(SKILL_MD), "write-back must go through a claude/* branch"
"""Stage 2: BootSequence orchestration.
The required/optional *policy* lives in BootSequence (UseCase); the loader only
fetches. Exercised with fakes — dummy values only (acme/memo), no persona coupling.
"""
import pytest
from domain.exceptions import WakeupError
from domain.models import CommitIdentity, LoadFile, RepoRef, WakeupConfig
from usecases.boot_sequence import BootSequence
def _config(load_files):
return WakeupConfig(
public_repo=RepoRef(owner="acme", name="memo"),
load_files=load_files,
commit_identity=CommitIdentity(
author_name="P", author_email="1+u@users.noreply.github.com"
),
directive_path="dir/Directive.md",
)
class _RecordingLoader:
"""Returns only fetchable files; missing ones are simply absent (never raises).
The required/optional decision is BootSequence's job, not the loader's."""
def __init__(self, log, missing_paths=()):
self.log = log
self.missing = set(missing_paths)
def load_public(self, repo, files):
self.log.append("load_public")
return {
f.path: f"content-of-{f.path}" for f in files if f.path not in self.missing
}
class TestBootOrder:
def test_loads_public_memory(self):
log = []
cfg = _config((LoadFile(path="dir/A.txt"),))
BootSequence(cfg, _RecordingLoader(log)).run()
assert log == ["load_public"]
def test_returns_loaded_contents(self):
cfg = _config((LoadFile(path="dir/A.txt"), LoadFile(path="dir/B.md")))
result = BootSequence(cfg, _RecordingLoader([])).run()
assert result["dir/A.txt"] == "content-of-dir/A.txt"
assert result["dir/B.md"] == "content-of-dir/B.md"
class TestRequiredOptional:
def test_required_missing_aborts(self):
log = []
cfg = _config((LoadFile(path="dir/A.txt", required=True),))
loader = _RecordingLoader(log, missing_paths=["dir/A.txt"])
with pytest.raises(WakeupError):
BootSequence(cfg, loader).run()
def test_optional_missing_continues(self):
log = []
cfg = _config((LoadFile(path="dir/opt.md", required=False),))
loader = _RecordingLoader(log, missing_paths=["dir/opt.md"])
result = BootSequence(cfg, loader).run()
assert "dir/opt.md" not in result # optional missing tolerated; returns what loaded
"""Stage 2: UseCase port contracts (Protocol + runtime_checkable).
Mirrors EmailingEssay's usecases/ports.py style. Verifies the abstract shapes
the SKILL.md procedure (Infrastructure) will implement, and the key security
contract: SecretProviderPort hands a token to a callback then drops it.
"""
from usecases.ports import (
MemoryLoaderPort,
SecretProviderPort,
VcsPort,
)
class TestPortShapes:
def test_memory_loader_has_load_public(self):
assert hasattr(MemoryLoaderPort, "load_public")
def test_secret_provider_has_with_token(self):
assert hasattr(SecretProviderPort, "with_token")
def test_vcs_has_push_and_pr(self):
assert hasattr(VcsPort, "push_branch")
assert hasattr(VcsPort, "open_pr")
class TestRuntimeCheckable:
def test_fake_loader_satisfies_protocol(self):
class FakeLoader:
def load_public(self, repo, files):
return {}
assert isinstance(FakeLoader(), MemoryLoaderPort)
class TestSecretProviderContract:
"""The whole point of with_token: the token must not outlive the callback."""
def test_with_token_passes_token_then_releases(self):
class FakeSecret:
def __init__(self, token):
self._token = token
self.released = False
def with_token(self, scope, fn):
try:
return fn(self._token)
finally:
self._token = None
self.released = True
fake = FakeSecret("ghp_dummy_secret")
seen = {}
result = fake.with_token("contents:read", lambda t: seen.update(tok=t) or "ok")
assert result == "ok"
assert seen["tok"] == "ghp_dummy_secret" # callback saw the token
assert fake.released is True # ...and it was released after
assert fake._token is None
def test_fake_secret_satisfies_protocol(self):
class FakeSecret:
def with_token(self, scope, fn):
return fn("x")
assert isinstance(FakeSecret(), SecretProviderPort)
"""BootSequence — the boot ordering contract: load public memory with required/optional policy.
Private reference and write-back are on-demand (handled by the SKILL.md procedure),
so they are deliberately not part of ``run()``. This class is the testable SSoT for
the load order and the required/optional boot policy only (YAGNI).
"""
from __future__ import annotations
from domain.exceptions import WakeupError
from domain.models import WakeupConfig
from usecases.ports import MemoryLoaderPort
class BootSequence:
def __init__(
self,
config: WakeupConfig,
memory_loader: MemoryLoaderPort,
) -> None:
self._config = config
self._loader = memory_loader
def run(self) -> dict[str, str]:
"""Load public memory and enforce the required-file policy.
Returns the loaded ``{path: content}``. Raises :class:`WakeupError` if any
*required* file could not be loaded; missing *optional* files are tolerated.
"""
loaded = self._loader.load_public(self._config.public_repo, self._config.load_files)
missing_required = [
f.path for f in self._config.load_files if f.required and f.path not in loaded
]
if missing_required:
raise WakeupError(f"required boot files unavailable: {missing_required}")
return loaded
"""Use Case port interfaces — the abstractions the Infrastructure/SKILL layer implements.
Protocols are runtime_checkable so fakes can be asserted in tests. No persona values.
"""
from __future__ import annotations
from typing import Callable, Protocol, TypeVar, runtime_checkable
from domain.models import LoadFile, RepoRef
T = TypeVar("T")
@runtime_checkable
class MemoryLoaderPort(Protocol):
"""Loads public files at boot (public repos need no auth)."""
def load_public(self, repo: RepoRef, files: tuple[LoadFile, ...]) -> dict[str, str]:
"""Return ``{path: content}`` for the files that could be fetched.
Missing files are simply absent from the dict; this port never decides
whether that is fatal — the required/optional policy lives in BootSequence.
"""
...
@runtime_checkable
class SecretProviderPort(Protocol):
"""Hands a short-lived token to a callback, then drops it (no residency)."""
def with_token(self, scope: str, fn: Callable[[str], T]) -> T:
"""Call ``fn(token)`` and return its result; release the token afterward."""
...
@runtime_checkable
class VcsPort(Protocol):
"""Write-back via a feature branch + PR — never a direct push to the default branch."""
def push_branch(self, branch: str, message: str) -> None:
"""Commit staged changes and push to ``branch`` (e.g. ``claude/...``)."""
...
def open_pr(self, branch: str, title: str) -> str:
"""Open a PR from ``branch`` into the default branch; return its URL."""
...
__all__ = ["MemoryLoaderPort", "SecretProviderPort", "VcsPort"]