claude-nb 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +38 -0
- package/Makefile +60 -0
- package/README.md +63 -0
- package/VERSION +1 -0
- package/bin/_pip_entry.py +25 -0
- package/bin/board +287 -0
- package/bin/cnb +150 -0
- package/bin/cnb.js +33 -0
- package/bin/dispatcher +151 -0
- package/bin/dispatcher-watchdog +57 -0
- package/bin/doctor +328 -0
- package/bin/init +316 -0
- package/bin/registry +347 -0
- package/bin/swarm +896 -0
- package/lib/__init__.py +1 -0
- package/lib/board_admin.py +128 -0
- package/lib/board_bbs.py +99 -0
- package/lib/board_bug.py +161 -0
- package/lib/board_db.py +262 -0
- package/lib/board_lock.py +113 -0
- package/lib/board_mailbox.py +145 -0
- package/lib/board_maintenance.py +237 -0
- package/lib/board_msg.py +230 -0
- package/lib/board_task.py +200 -0
- package/lib/board_view.py +366 -0
- package/lib/board_vote.py +164 -0
- package/lib/build_lock.py +221 -0
- package/lib/cli.py +34 -0
- package/lib/common.py +285 -0
- package/lib/concerns/__init__.py +42 -0
- package/lib/concerns/adaptive_throttle.py +26 -0
- package/lib/concerns/base.py +25 -0
- package/lib/concerns/bug_sla_checker.py +32 -0
- package/lib/concerns/config.py +22 -0
- package/lib/concerns/coral_manager.py +61 -0
- package/lib/concerns/coral_poker.py +57 -0
- package/lib/concerns/file_watcher.py +127 -0
- package/lib/concerns/health_checker.py +72 -0
- package/lib/concerns/helpers.py +152 -0
- package/lib/concerns/idle_detector.py +56 -0
- package/lib/concerns/idle_killer.py +41 -0
- package/lib/concerns/idle_nudger.py +38 -0
- package/lib/concerns/inbox_nudger.py +34 -0
- package/lib/concerns/resource_monitor.py +47 -0
- package/lib/concerns/session_keepalive.py +23 -0
- package/lib/concerns/time_announcer.py +34 -0
- package/lib/crypto.py +92 -0
- package/lib/health.py +187 -0
- package/lib/inject.py +164 -0
- package/lib/migrate.py +109 -0
- package/lib/monitor.py +373 -0
- package/lib/panel.py +137 -0
- package/lib/resources.py +341 -0
- package/migrations/001_foreign_keys.sql +77 -0
- package/migrations/002_session_persona.sql +1 -0
- package/migrations/003_mailbox.sql +9 -0
- package/package.json +28 -0
- package/pyproject.toml +71 -0
- package/registry/0001-meridian.json +12 -0
- package/registry/0002-forge.json +12 -0
- package/registry/0003-lead.json +12 -0
- package/registry/0004-ms-encrypted-mailbox-live.json +12 -0
- package/registry/GENESIS.json +9 -0
- package/registry/pubkeys.json +5 -0
- package/schema.sql +138 -0
package/bin/init
ADDED
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""cnb init — initialize a project for multi-agent coordination."""
|
|
3
|
+
|
|
4
|
+
import hashlib
|
|
5
|
+
import json
|
|
6
|
+
import re
|
|
7
|
+
import sqlite3
|
|
8
|
+
import sys
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
# Ensure lib/ is importable (needed for migration runner)
|
|
12
|
+
_script = Path(__file__).resolve()
|
|
13
|
+
CLAUDES_HOME = _script.parent.parent
|
|
14
|
+
if str(CLAUDES_HOME) not in sys.path:
|
|
15
|
+
sys.path.insert(0, str(CLAUDES_HOME))
|
|
16
|
+
|
|
17
|
+
# ── Session name validation ──
|
|
18
|
+
|
|
19
|
+
VALID_NAME = re.compile(r"^[a-z0-9](?:[a-z0-9_-]{0,62}[a-z0-9])?$")
|
|
20
|
+
RESERVED_NAMES: frozenset[str] = frozenset({"all", "dispatcher", "system"})
|
|
21
|
+
MAX_NAME_LEN = 64
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _validate_name(name: str) -> str:
|
|
25
|
+
"""Validate and lowercase a session name. Raises SystemExit on invalid input."""
|
|
26
|
+
clean = name.strip().lower()
|
|
27
|
+
if not clean:
|
|
28
|
+
print("ERROR: empty session name")
|
|
29
|
+
raise SystemExit(1)
|
|
30
|
+
if len(clean) > MAX_NAME_LEN:
|
|
31
|
+
print(f"ERROR: session name too long (max {MAX_NAME_LEN} chars): {name}")
|
|
32
|
+
raise SystemExit(1)
|
|
33
|
+
if clean in RESERVED_NAMES:
|
|
34
|
+
print(f"ERROR: '{clean}' is a reserved name. Choose another.")
|
|
35
|
+
raise SystemExit(1)
|
|
36
|
+
if not VALID_NAME.match(clean):
|
|
37
|
+
print(f"ERROR: invalid session name '{name}'. Use a-z, 0-9, hyphens, underscores.")
|
|
38
|
+
print(" Example: cnb init alice bob charlie")
|
|
39
|
+
raise SystemExit(1)
|
|
40
|
+
return clean
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
MARKER_START = "<!-- cnb:start -->"
|
|
44
|
+
MARKER_END = "<!-- cnb:end -->"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _claude_md_snippet(sessions: list[str], claudes_home: Path, personas: dict[str, str] | None = None) -> str:
|
|
48
|
+
board = f"{claudes_home}/bin/board"
|
|
49
|
+
lines = [
|
|
50
|
+
MARKER_START,
|
|
51
|
+
"## Multi-Agent Coordination",
|
|
52
|
+
"",
|
|
53
|
+
"This project uses cnb for multi-session coordination.",
|
|
54
|
+
"",
|
|
55
|
+
"### Session Startup",
|
|
56
|
+
"",
|
|
57
|
+
"You are a session. Your name is passed via `--name` when Claude Code starts.",
|
|
58
|
+
"On startup:",
|
|
59
|
+
"```bash",
|
|
60
|
+
f"{board} --as <your-name> inbox",
|
|
61
|
+
"```",
|
|
62
|
+
"",
|
|
63
|
+
"### Commands",
|
|
64
|
+
"",
|
|
65
|
+
"```bash",
|
|
66
|
+
f'{board} --as <name> send <to> "<msg>" # message (person or "all")',
|
|
67
|
+
f"{board} --as <name> inbox # check unread",
|
|
68
|
+
f"{board} --as <name> ack # clear inbox",
|
|
69
|
+
f'{board} --as <name> status "<desc>" # update current task',
|
|
70
|
+
f'{board} --as <name> task add "<desc>" # add task',
|
|
71
|
+
f"{board} --as <name> task done # finish current task",
|
|
72
|
+
f"{board} --as <name> view # board overview",
|
|
73
|
+
f'{board} --as <name> bug report P1 "desc" # report bug',
|
|
74
|
+
f'{board} --as <name> send all "msg" # broadcast',
|
|
75
|
+
"```",
|
|
76
|
+
"",
|
|
77
|
+
"### Rules",
|
|
78
|
+
"",
|
|
79
|
+
"- Check inbox at startup and after completing each task.",
|
|
80
|
+
"- Update status when you start or finish work.",
|
|
81
|
+
"- Commit immediately after each logical change.",
|
|
82
|
+
"- Message others via `send`, not by editing their files.",
|
|
83
|
+
"",
|
|
84
|
+
"### Sessions",
|
|
85
|
+
"",
|
|
86
|
+
]
|
|
87
|
+
for s in sessions:
|
|
88
|
+
p = (personas or {}).get(s, "")
|
|
89
|
+
if p:
|
|
90
|
+
lines.append(f"- **{s}** — {p.splitlines()[0]}")
|
|
91
|
+
else:
|
|
92
|
+
lines.append(f"- **{s}**")
|
|
93
|
+
lines.append(MARKER_END)
|
|
94
|
+
return "\n".join(lines) + "\n"
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _update_claude_md(project_dir: Path, snippet: str) -> None:
|
|
98
|
+
md_path = project_dir / "CLAUDE.md"
|
|
99
|
+
if md_path.exists():
|
|
100
|
+
text = md_path.read_text()
|
|
101
|
+
if MARKER_START in text:
|
|
102
|
+
import re
|
|
103
|
+
|
|
104
|
+
text = re.sub(
|
|
105
|
+
rf"{re.escape(MARKER_START)}.*?{re.escape(MARKER_END)}\n?",
|
|
106
|
+
snippet,
|
|
107
|
+
text,
|
|
108
|
+
flags=re.DOTALL,
|
|
109
|
+
)
|
|
110
|
+
else:
|
|
111
|
+
text = text.rstrip("\n") + "\n\n" + snippet
|
|
112
|
+
md_path.write_text(text)
|
|
113
|
+
else:
|
|
114
|
+
md_path.write_text(snippet)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _hook_command(claudes_home: Path) -> str:
|
|
118
|
+
board = f"{claudes_home}/bin/board"
|
|
119
|
+
return (
|
|
120
|
+
f'if [ -n "$CLAUDE_SESSION_NAME" ]; then '
|
|
121
|
+
f"unread=$({board} --as $CLAUDE_SESSION_NAME inbox 2>/dev/null "
|
|
122
|
+
f"| grep -oE '[0-9]+ 条未读' | head -1); "
|
|
123
|
+
f'[ -n "$unread" ] && echo "[inbox] $unread" || true; fi'
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _update_settings(project_dir: Path, claudes_home: Path) -> None:
|
|
128
|
+
settings_dir = project_dir / ".claude"
|
|
129
|
+
settings_dir.mkdir(exist_ok=True)
|
|
130
|
+
settings_path = settings_dir / "settings.json"
|
|
131
|
+
|
|
132
|
+
hook_entry = {
|
|
133
|
+
"matcher": "",
|
|
134
|
+
"hooks": [{"type": "command", "command": _hook_command(claudes_home)}],
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
if settings_path.exists():
|
|
138
|
+
try:
|
|
139
|
+
settings = json.loads(settings_path.read_text())
|
|
140
|
+
except (json.JSONDecodeError, ValueError):
|
|
141
|
+
settings = {}
|
|
142
|
+
else:
|
|
143
|
+
settings = {}
|
|
144
|
+
|
|
145
|
+
hooks = settings.setdefault("hooks", {})
|
|
146
|
+
post_batch = hooks.setdefault("PostToolBatch", [])
|
|
147
|
+
|
|
148
|
+
already = any(
|
|
149
|
+
isinstance(h, dict)
|
|
150
|
+
and any(
|
|
151
|
+
"board" in sub.get("command", "") and "inbox" in sub.get("command", "")
|
|
152
|
+
for sub in h.get("hooks", [])
|
|
153
|
+
if isinstance(sub, dict)
|
|
154
|
+
)
|
|
155
|
+
for h in post_batch
|
|
156
|
+
)
|
|
157
|
+
if not already:
|
|
158
|
+
post_batch.append(hook_entry)
|
|
159
|
+
|
|
160
|
+
settings_path.write_text(json.dumps(settings, indent=2, ensure_ascii=False) + "\n")
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _create_slash_commands(project_dir: Path, claudes_home: Path) -> None:
|
|
164
|
+
cmd_dir = project_dir / ".claude" / "commands"
|
|
165
|
+
cmd_dir.mkdir(parents=True, exist_ok=True)
|
|
166
|
+
|
|
167
|
+
board = f"{claudes_home}/bin/board"
|
|
168
|
+
swarm = f"{claudes_home}/bin/swarm"
|
|
169
|
+
|
|
170
|
+
commands = {
|
|
171
|
+
"cs-team": f"运行 `{swarm} status` 和 `{board} --as lead view`,用简洁的表格告诉我每个同学的状态。",
|
|
172
|
+
"cs-inbox": f"运行 `{board} --as lead inbox`,把收到的消息汇总给我。",
|
|
173
|
+
"cs-broadcast": f'把以下消息广播给所有同学:\n`{board} --as lead send all "$ARGUMENTS"`',
|
|
174
|
+
"cs-assign": f'把任务分配给指定同学。参数格式:<名字> <任务描述>\n解析 $ARGUMENTS,运行 `{board} --as lead send <名字> "<任务描述>"`',
|
|
175
|
+
"cs-kick": f"让指定同学下线。解析 $ARGUMENTS 拿到名字,运行 `{swarm} stop $ARGUMENTS`",
|
|
176
|
+
"cs-add": f"拉新同学上线。解析 $ARGUMENTS 拿到名字,运行 `{swarm} start $ARGUMENTS`",
|
|
177
|
+
"cs-log": f"查看指定同学最近的工作日志。解析 $ARGUMENTS 拿到名字,运行 `{board} --as lead view` 并重点关注该同学的状态和最近消息。",
|
|
178
|
+
"cs-stop": f"停掉所有后台同学。运行 `{swarm} stop`,告诉用户已全部下线。",
|
|
179
|
+
"cs-bugs": f"运行 `{board} --as lead bug list`,汇总当前所有 bug。",
|
|
180
|
+
"cs-history": f"运行 `{board} --as lead log 50`,把完整的消息历史展示给我。",
|
|
181
|
+
"cs-update": "运行 `pip install --upgrade cnb`,更新到最新版本。把结果告诉我,如果更新成功提醒用户重启 cnb 以生效。",
|
|
182
|
+
"cs-help": "列出所有 /cs-* 命令及用途:\n"
|
|
183
|
+
"- /cs-team — 看同学状态\n"
|
|
184
|
+
"- /cs-inbox — 查收消息\n"
|
|
185
|
+
"- /cs-broadcast <消息> — 广播\n"
|
|
186
|
+
"- /cs-assign <名字> <任务> — 派任务\n"
|
|
187
|
+
"- /cs-add <名字> — 拉同学上线\n"
|
|
188
|
+
"- /cs-kick <名字> — 让同学下线\n"
|
|
189
|
+
"- /cs-log <名字> — 看同学工作日志\n"
|
|
190
|
+
"- /cs-stop — 全部下线\n"
|
|
191
|
+
"- /cs-bugs — 看 bug 列表\n"
|
|
192
|
+
"- /cs-history — 查看完整消息历史\n"
|
|
193
|
+
"- /cs-update — 更新到最新版\n"
|
|
194
|
+
"- /cs-help — 本帮助",
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
for name, content in commands.items():
|
|
198
|
+
(cmd_dir / f"{name}.md").write_text(content + "\n")
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
DEFAULT_SESSIONS = ["s1", "s2", "s3"]
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _load_team_toml(path: Path) -> tuple[list[str], dict[str, str]]:
|
|
205
|
+
"""Load a team definition TOML file. Returns (session_names, {name: persona})."""
|
|
206
|
+
import tomllib
|
|
207
|
+
|
|
208
|
+
data = tomllib.loads(path.read_text())
|
|
209
|
+
personas: dict[str, str] = {}
|
|
210
|
+
sessions: list[str] = []
|
|
211
|
+
for name, info in data.get("session", {}).items():
|
|
212
|
+
clean = _validate_name(name)
|
|
213
|
+
sessions.append(clean)
|
|
214
|
+
if isinstance(info, dict) and info.get("persona"):
|
|
215
|
+
personas[clean] = info["persona"]
|
|
216
|
+
return sessions, personas
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def main() -> None:
|
|
220
|
+
raw_sessions: list[str] = sys.argv[1:]
|
|
221
|
+
|
|
222
|
+
team_file: Path | None = None
|
|
223
|
+
personas: dict[str, str] = {}
|
|
224
|
+
|
|
225
|
+
# Check for --team flag
|
|
226
|
+
if "--team" in raw_sessions:
|
|
227
|
+
idx = raw_sessions.index("--team")
|
|
228
|
+
if idx + 1 < len(raw_sessions):
|
|
229
|
+
team_file = Path(raw_sessions[idx + 1])
|
|
230
|
+
if not team_file.exists():
|
|
231
|
+
print(f"ERROR: team file not found: {team_file}")
|
|
232
|
+
raise SystemExit(1)
|
|
233
|
+
raw_sessions = raw_sessions[:idx] + raw_sessions[idx + 2 :]
|
|
234
|
+
else:
|
|
235
|
+
print("ERROR: --team requires a path")
|
|
236
|
+
raise SystemExit(1)
|
|
237
|
+
|
|
238
|
+
if team_file:
|
|
239
|
+
sessions, personas = _load_team_toml(team_file)
|
|
240
|
+
elif raw_sessions:
|
|
241
|
+
sessions = [_validate_name(s) for s in raw_sessions]
|
|
242
|
+
else:
|
|
243
|
+
sessions = list(DEFAULT_SESSIONS)
|
|
244
|
+
project_dir = Path.cwd()
|
|
245
|
+
claudes_dir = project_dir / ".claudes"
|
|
246
|
+
claudes_home = CLAUDES_HOME
|
|
247
|
+
|
|
248
|
+
if (claudes_dir / "board.db").exists():
|
|
249
|
+
print(f"Already initialized: {claudes_dir}")
|
|
250
|
+
sys.exit(0)
|
|
251
|
+
|
|
252
|
+
# Create directory structure
|
|
253
|
+
for subdir in ("sessions", "files", "okr", "cv", "logs"):
|
|
254
|
+
(claudes_dir / subdir).mkdir(parents=True, exist_ok=True)
|
|
255
|
+
|
|
256
|
+
# Generate short unique prefix from project path
|
|
257
|
+
project_hash = hashlib.sha256(str(project_dir).encode()).hexdigest()[:4]
|
|
258
|
+
prefix = f"cc-{project_hash}"
|
|
259
|
+
|
|
260
|
+
# Generate config.toml
|
|
261
|
+
sessions_toml = ", ".join(f'"{s}"' for s in sessions)
|
|
262
|
+
toml_lines = [
|
|
263
|
+
f'claudes_home = "{claudes_home}"',
|
|
264
|
+
f"sessions = [{sessions_toml}]",
|
|
265
|
+
f'prefix = "{prefix}"',
|
|
266
|
+
"",
|
|
267
|
+
]
|
|
268
|
+
for s in sessions:
|
|
269
|
+
toml_lines.append(f"[session.{s}]")
|
|
270
|
+
p = personas.get(s, "")
|
|
271
|
+
toml_lines.append(f'persona = """{p}"""' if "\n" in p else f'persona = "{p}"')
|
|
272
|
+
toml_lines.append("")
|
|
273
|
+
(claudes_dir / "config.toml").write_text("\n".join(toml_lines) + "\n")
|
|
274
|
+
|
|
275
|
+
# Initialize SQLite database from schema.sql
|
|
276
|
+
schema_file = claudes_home / "schema.sql"
|
|
277
|
+
db_path = claudes_dir / "board.db"
|
|
278
|
+
conn = sqlite3.connect(str(db_path))
|
|
279
|
+
conn.execute("PRAGMA journal_mode=WAL;")
|
|
280
|
+
conn.execute("PRAGMA foreign_keys=ON;")
|
|
281
|
+
conn.executescript(schema_file.read_text())
|
|
282
|
+
# Insert sessions + pseudo-session 'all' for broadcast FK references
|
|
283
|
+
conn.execute("INSERT OR IGNORE INTO sessions(name, status) VALUES ('all', 'system')")
|
|
284
|
+
for name in sessions:
|
|
285
|
+
n = name.lower()
|
|
286
|
+
persona = personas.get(n, "")
|
|
287
|
+
conn.execute("INSERT OR IGNORE INTO sessions(name, persona) VALUES (?, ?)", (n, persona))
|
|
288
|
+
persona_section = f"\n## Persona\n{persona}\n" if persona else ""
|
|
289
|
+
md_content = f"# {n}\n{persona_section}\n## Current task\n(none)\n\n## @inbox\n"
|
|
290
|
+
(claudes_dir / "sessions" / f"{n}.md").write_text(md_content)
|
|
291
|
+
conn.execute("INSERT OR IGNORE INTO meta(key, value) VALUES ('dispatcher_session', 'dispatcher')")
|
|
292
|
+
# Record schema version so migration runner knows where we are
|
|
293
|
+
conn.execute("INSERT OR IGNORE INTO meta(key, value) VALUES ('schema_version', '2')")
|
|
294
|
+
conn.commit()
|
|
295
|
+
conn.close()
|
|
296
|
+
|
|
297
|
+
# Run any pending migrations (idempotent)
|
|
298
|
+
from lib.migrate import run_migrations
|
|
299
|
+
|
|
300
|
+
applied = run_migrations(db_path, claudes_home)
|
|
301
|
+
if applied:
|
|
302
|
+
print(f"Applied {applied} schema migration(s).")
|
|
303
|
+
|
|
304
|
+
# .gitignore for generated stuff
|
|
305
|
+
gitignore_content = "board.db\nboard.db-shm\nboard.db-wal\nlogs/\n"
|
|
306
|
+
(claudes_dir / ".gitignore").write_text(gitignore_content)
|
|
307
|
+
|
|
308
|
+
# Update .claude/settings.json (merge hooks if exists, create if not)
|
|
309
|
+
_update_settings(project_dir, claudes_home)
|
|
310
|
+
|
|
311
|
+
sessions_str = " ".join(sessions)
|
|
312
|
+
print(f"Initialized cnb (sessions: {sessions_str})")
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
if __name__ == "__main__":
|
|
316
|
+
main()
|
package/bin/registry
ADDED
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""registry — Agent identity chain.
|
|
3
|
+
|
|
4
|
+
Each agent gets a globally unique block number. Lower = earlier = OG.
|
|
5
|
+
Each block contains SHA256 of the previous block, forming a tamper-proof chain.
|
|
6
|
+
The git commit hash at registration is the on-chain proof.
|
|
7
|
+
|
|
8
|
+
Usage:
|
|
9
|
+
registry register <name> [--role <role>] [--description <desc>]
|
|
10
|
+
registry verify <name>
|
|
11
|
+
registry verify-chain
|
|
12
|
+
registry list
|
|
13
|
+
registry rank
|
|
14
|
+
registry whois <name>
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
import hashlib
|
|
18
|
+
import json
|
|
19
|
+
import subprocess
|
|
20
|
+
import sys
|
|
21
|
+
from datetime import datetime
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
|
|
24
|
+
REGISTRY_DIR = Path(__file__).resolve().parent.parent / "registry"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _content_hash(entry: dict) -> str:
|
|
28
|
+
stable = {k: v for k, v in sorted(entry.items()) if k != "content_hash"}
|
|
29
|
+
return hashlib.sha256(json.dumps(stable, sort_keys=True).encode()).hexdigest()[:16]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _load_chain() -> list[dict]:
|
|
33
|
+
entries = []
|
|
34
|
+
for f in sorted(REGISTRY_DIR.glob("*.json")):
|
|
35
|
+
entries.append(json.loads(f.read_text()))
|
|
36
|
+
entries.sort(key=lambda e: e["block"])
|
|
37
|
+
return entries
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _next_block() -> int:
|
|
41
|
+
chain = _load_chain()
|
|
42
|
+
return max(e["block"] for e in chain) + 1 if chain else 0
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _find_agent(name: str) -> dict | None:
|
|
46
|
+
for f in REGISTRY_DIR.glob("*.json"):
|
|
47
|
+
entry = json.loads(f.read_text())
|
|
48
|
+
if entry.get("name") == name and entry.get("type") != "project":
|
|
49
|
+
return entry
|
|
50
|
+
return None
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _git_commit_and_hash(filepath: Path, message: str) -> str:
|
|
54
|
+
subprocess.run(["git", "add", str(filepath)], check=True, capture_output=True)
|
|
55
|
+
subprocess.run(["git", "commit", "-m", message], check=True, capture_output=True)
|
|
56
|
+
r = subprocess.run(["git", "rev-parse", "--short", "HEAD"], check=True, capture_output=True, text=True)
|
|
57
|
+
return r.stdout.strip()
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _count_contributions(name: str) -> dict:
|
|
61
|
+
commits = 0
|
|
62
|
+
r = subprocess.run(
|
|
63
|
+
["git", "log", "--all", "--oneline", "--author", name, "--grep", name],
|
|
64
|
+
capture_output=True,
|
|
65
|
+
text=True,
|
|
66
|
+
)
|
|
67
|
+
commits += len(r.stdout.strip().splitlines()) if r.stdout.strip() else 0
|
|
68
|
+
|
|
69
|
+
r = subprocess.run(
|
|
70
|
+
["git", "log", "--all", "--oneline", "--grep", f"Co-Authored-By: Claude {name}"],
|
|
71
|
+
capture_output=True,
|
|
72
|
+
text=True,
|
|
73
|
+
)
|
|
74
|
+
commits += len(r.stdout.strip().splitlines()) if r.stdout.strip() else 0
|
|
75
|
+
|
|
76
|
+
r = subprocess.run(
|
|
77
|
+
["git", "log", "--all", "--oneline", "--grep", f"by: Claude {name}"],
|
|
78
|
+
capture_output=True,
|
|
79
|
+
text=True,
|
|
80
|
+
)
|
|
81
|
+
commits += len(r.stdout.strip().splitlines()) if r.stdout.strip() else 0
|
|
82
|
+
|
|
83
|
+
return {"commits": commits}
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def cmd_register(args: list[str]) -> None:
|
|
87
|
+
if not args:
|
|
88
|
+
print("Usage: registry register <name> [--role <role>] [--description <desc>]")
|
|
89
|
+
raise SystemExit(1)
|
|
90
|
+
|
|
91
|
+
name = args[0]
|
|
92
|
+
role = ""
|
|
93
|
+
description = ""
|
|
94
|
+
|
|
95
|
+
i = 1
|
|
96
|
+
while i < len(args):
|
|
97
|
+
if args[i] == "--role" and i + 1 < len(args):
|
|
98
|
+
role = args[i + 1]
|
|
99
|
+
i += 2
|
|
100
|
+
elif args[i] == "--description" and i + 1 < len(args):
|
|
101
|
+
description = args[i + 1]
|
|
102
|
+
i += 2
|
|
103
|
+
else:
|
|
104
|
+
i += 1
|
|
105
|
+
|
|
106
|
+
existing = _find_agent(name)
|
|
107
|
+
if existing:
|
|
108
|
+
print(f"ERROR: {name} 已注册 — Block #{existing['block']} ({existing['chain']})")
|
|
109
|
+
raise SystemExit(1)
|
|
110
|
+
|
|
111
|
+
block_num = _next_block()
|
|
112
|
+
chain = _load_chain()
|
|
113
|
+
prev_content_hash = chain[-1].get("content_hash", _content_hash(chain[-1])) if chain else "GENESIS"
|
|
114
|
+
|
|
115
|
+
display_name = f"Claude {name.capitalize()}"
|
|
116
|
+
|
|
117
|
+
entry = {
|
|
118
|
+
"block": block_num,
|
|
119
|
+
"name": name,
|
|
120
|
+
"display_name": display_name,
|
|
121
|
+
"type": "agent",
|
|
122
|
+
"role": role,
|
|
123
|
+
"description": description,
|
|
124
|
+
"created": datetime.now().strftime("%Y-%m-%d"),
|
|
125
|
+
"prev": prev_content_hash,
|
|
126
|
+
"chain": None,
|
|
127
|
+
"content_hash": None,
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
filename = f"{block_num:04d}-{name}.json"
|
|
131
|
+
filepath = REGISTRY_DIR / filename
|
|
132
|
+
filepath.write_text(json.dumps(entry, indent=2, ensure_ascii=False) + "\n")
|
|
133
|
+
|
|
134
|
+
commit_hash = _git_commit_and_hash(filepath, f"Register {name} — Block #{block_num}")
|
|
135
|
+
|
|
136
|
+
entry["chain"] = commit_hash
|
|
137
|
+
entry["content_hash"] = _content_hash(entry)
|
|
138
|
+
filepath.write_text(json.dumps(entry, indent=2, ensure_ascii=False) + "\n")
|
|
139
|
+
_sync_readme()
|
|
140
|
+
subprocess.run(["git", "add", str(filepath), str(REGISTRY_DIR.parent / "README.md")], capture_output=True)
|
|
141
|
+
subprocess.run(["git", "commit", "--amend", "--no-edit"], capture_output=True)
|
|
142
|
+
|
|
143
|
+
print(f"OK {display_name} — Block #{block_num} ({commit_hash})")
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def cmd_verify(args: list[str]) -> None:
|
|
147
|
+
if not args:
|
|
148
|
+
print("Usage: registry verify <name>")
|
|
149
|
+
raise SystemExit(1)
|
|
150
|
+
|
|
151
|
+
name = args[0]
|
|
152
|
+
entry = _find_agent(name)
|
|
153
|
+
if not entry:
|
|
154
|
+
print(f"ERROR: {name} 未注册")
|
|
155
|
+
raise SystemExit(1)
|
|
156
|
+
|
|
157
|
+
expected_hash = _content_hash(entry)
|
|
158
|
+
stored_hash = entry.get("content_hash", "")
|
|
159
|
+
|
|
160
|
+
commit_hash = entry.get("chain", "")
|
|
161
|
+
r = subprocess.run(
|
|
162
|
+
["git", "log", "--oneline", "--all", "--grep", f"Register {name}"],
|
|
163
|
+
capture_output=True,
|
|
164
|
+
text=True,
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
content_ok = expected_hash == stored_hash
|
|
168
|
+
chain_ok = commit_hash and commit_hash in r.stdout
|
|
169
|
+
|
|
170
|
+
if content_ok and chain_ok:
|
|
171
|
+
print(f"OK {name} — Block #{entry['block']} ({commit_hash}) ✓ 内容完整,链上验证通过")
|
|
172
|
+
elif not content_ok:
|
|
173
|
+
print(f"FAIL {name} — Block #{entry['block']} ✗ 内容被篡改!")
|
|
174
|
+
raise SystemExit(1)
|
|
175
|
+
else:
|
|
176
|
+
print(f"WARN {name} — Block #{entry['block']} ✗ 链上记录未找到")
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def cmd_verify_chain(_args: list[str]) -> None:
|
|
180
|
+
chain = _load_chain()
|
|
181
|
+
ok = 0
|
|
182
|
+
fail = 0
|
|
183
|
+
|
|
184
|
+
for i, entry in enumerate(chain):
|
|
185
|
+
name = entry["name"]
|
|
186
|
+
block = entry["block"]
|
|
187
|
+
|
|
188
|
+
if i == 0:
|
|
189
|
+
ok += 1
|
|
190
|
+
continue
|
|
191
|
+
|
|
192
|
+
prev_entry = chain[i - 1]
|
|
193
|
+
expected_prev = prev_entry.get("content_hash", _content_hash(prev_entry))
|
|
194
|
+
actual_prev = entry.get("prev", "")
|
|
195
|
+
|
|
196
|
+
expected_content = _content_hash(entry)
|
|
197
|
+
actual_content = entry.get("content_hash", "")
|
|
198
|
+
|
|
199
|
+
if actual_prev != expected_prev:
|
|
200
|
+
print(f" ✗ Block #{block} ({name}): prev hash 不匹配 — 链断裂!")
|
|
201
|
+
fail += 1
|
|
202
|
+
elif actual_content != expected_content:
|
|
203
|
+
print(f" ✗ Block #{block} ({name}): 内容被篡改!")
|
|
204
|
+
fail += 1
|
|
205
|
+
else:
|
|
206
|
+
print(f" ✓ Block #{block} ({name})")
|
|
207
|
+
ok += 1
|
|
208
|
+
|
|
209
|
+
print()
|
|
210
|
+
if fail == 0:
|
|
211
|
+
print(f"OK 链完整 ({ok} blocks)")
|
|
212
|
+
else:
|
|
213
|
+
print(f"FAIL {fail} 个 block 异常,{ok} 个正常")
|
|
214
|
+
raise SystemExit(1)
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def cmd_list(_args: list[str]) -> None:
|
|
218
|
+
chain = _load_chain()
|
|
219
|
+
print(f"{'Block':>6} {'Name':<25} {'Role':<15} {'Hash':<10} {'Date'}")
|
|
220
|
+
print(f"{'─' * 6} {'─' * 25} {'─' * 15} {'─' * 10} {'─' * 10}")
|
|
221
|
+
for entry in chain:
|
|
222
|
+
block = f"#{entry['block']}"
|
|
223
|
+
name = entry.get("display_name", entry["name"])
|
|
224
|
+
role = entry.get("role", entry.get("type", ""))
|
|
225
|
+
chain_hash = entry.get("chain", "") or ""
|
|
226
|
+
created = entry.get("created", "")
|
|
227
|
+
print(f"{block:>6} {name:<25} {role:<15} {chain_hash:<10} {created}")
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def cmd_rank(_args: list[str]) -> None:
|
|
231
|
+
chain = _load_chain()
|
|
232
|
+
agents = [e for e in chain if e.get("type") == "agent"]
|
|
233
|
+
|
|
234
|
+
rows = []
|
|
235
|
+
for entry in agents:
|
|
236
|
+
name = entry["name"]
|
|
237
|
+
contrib = _count_contributions(name)
|
|
238
|
+
rows.append(
|
|
239
|
+
{
|
|
240
|
+
"name": name,
|
|
241
|
+
"display_name": entry.get("display_name", f"Claude {name.capitalize()}"),
|
|
242
|
+
"block": entry["block"],
|
|
243
|
+
"commits": contrib["commits"],
|
|
244
|
+
"role": entry.get("role", ""),
|
|
245
|
+
}
|
|
246
|
+
)
|
|
247
|
+
|
|
248
|
+
rows.sort(key=lambda r: (-r["commits"], r["block"]))
|
|
249
|
+
|
|
250
|
+
print(f"{'Rank':>4} {'Name':<25} {'Block':>6} {'Commits':>8} {'Role'}")
|
|
251
|
+
print(f"{'─' * 4} {'─' * 25} {'─' * 6} {'─' * 8} {'─' * 15}")
|
|
252
|
+
for i, row in enumerate(rows, 1):
|
|
253
|
+
medal = {1: "🥇", 2: "🥈", 3: "🥉"}.get(i, f" {i}")
|
|
254
|
+
print(f"{medal:>4} {row['display_name']:<25} #{row['block']:>5} {row['commits']:>8} {row['role']}")
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def cmd_whois(args: list[str]) -> None:
|
|
258
|
+
if not args:
|
|
259
|
+
print("Usage: registry whois <name>")
|
|
260
|
+
raise SystemExit(1)
|
|
261
|
+
|
|
262
|
+
name = args[0]
|
|
263
|
+
entry = _find_agent(name)
|
|
264
|
+
if not entry:
|
|
265
|
+
print(f"{name}: 未注册")
|
|
266
|
+
raise SystemExit(1)
|
|
267
|
+
|
|
268
|
+
contrib = _count_contributions(name)
|
|
269
|
+
display = entry.get("display_name", f"Claude {name.capitalize()}")
|
|
270
|
+
print(f" Name: {display}")
|
|
271
|
+
print(f" ID: {entry['name']}")
|
|
272
|
+
print(f" Block: #{entry['block']}")
|
|
273
|
+
print(f" Role: {entry.get('role', '-')}")
|
|
274
|
+
print(f" Desc: {entry.get('description', '-')}")
|
|
275
|
+
print(f" Chain: {entry.get('chain', '-')}")
|
|
276
|
+
print(f" Content Hash: {entry.get('content_hash', '-')}")
|
|
277
|
+
print(f" Prev Hash: {entry.get('prev', '-')}")
|
|
278
|
+
print(f" Date: {entry.get('created', '-')}")
|
|
279
|
+
print(f" Commits: {contrib['commits']}")
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
CHAIN_START = "<!-- chain:start -->"
|
|
283
|
+
CHAIN_END = "<!-- chain:end -->"
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def _sync_readme() -> None:
|
|
287
|
+
readme = REGISTRY_DIR.parent / "README.md"
|
|
288
|
+
if not readme.exists():
|
|
289
|
+
return
|
|
290
|
+
text = readme.read_text()
|
|
291
|
+
if CHAIN_START not in text:
|
|
292
|
+
return
|
|
293
|
+
|
|
294
|
+
chain = _load_chain()
|
|
295
|
+
lines = [
|
|
296
|
+
CHAIN_START,
|
|
297
|
+
"| Block | Name | Role | Hash |",
|
|
298
|
+
"|-------|------|------|------|",
|
|
299
|
+
]
|
|
300
|
+
for entry in chain:
|
|
301
|
+
block = f"#{entry['block']}"
|
|
302
|
+
name = entry.get("display_name", entry["name"])
|
|
303
|
+
role = entry.get("role", entry.get("type", ""))
|
|
304
|
+
chain_hash = f"`{entry['chain']}`" if entry.get("chain") else "—"
|
|
305
|
+
lines.append(f"| {block} | {name} | {role} | {chain_hash} |")
|
|
306
|
+
lines.append(CHAIN_END)
|
|
307
|
+
|
|
308
|
+
import re
|
|
309
|
+
|
|
310
|
+
text = re.sub(
|
|
311
|
+
rf"{re.escape(CHAIN_START)}.*?{re.escape(CHAIN_END)}",
|
|
312
|
+
"\n".join(lines),
|
|
313
|
+
text,
|
|
314
|
+
flags=re.DOTALL,
|
|
315
|
+
)
|
|
316
|
+
readme.write_text(text)
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def main() -> None:
|
|
320
|
+
if len(sys.argv) < 2:
|
|
321
|
+
print(__doc__)
|
|
322
|
+
raise SystemExit(1)
|
|
323
|
+
|
|
324
|
+
cmd = sys.argv[1]
|
|
325
|
+
rest = sys.argv[2:]
|
|
326
|
+
|
|
327
|
+
commands = {
|
|
328
|
+
"register": cmd_register,
|
|
329
|
+
"verify": cmd_verify,
|
|
330
|
+
"verify-chain": cmd_verify_chain,
|
|
331
|
+
"list": cmd_list,
|
|
332
|
+
"rank": cmd_rank,
|
|
333
|
+
"whois": cmd_whois,
|
|
334
|
+
"sync-readme": lambda _: _sync_readme() or print("OK README synced"),
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
handler = commands.get(cmd)
|
|
338
|
+
if handler:
|
|
339
|
+
handler(rest)
|
|
340
|
+
else:
|
|
341
|
+
print(f"Unknown command: {cmd}")
|
|
342
|
+
print(__doc__)
|
|
343
|
+
raise SystemExit(1)
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
if __name__ == "__main__":
|
|
347
|
+
main()
|