named-subagents 0.3.0__py3-none-any.whl
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.
- named_subagents/__init__.py +1144 -0
- named_subagents/cli.py +539 -0
- named_subagents/py.typed +0 -0
- named_subagents/registry.json +1197 -0
- named_subagents-0.3.0.dist-info/METADATA +301 -0
- named_subagents-0.3.0.dist-info/RECORD +10 -0
- named_subagents-0.3.0.dist-info/WHEEL +5 -0
- named_subagents-0.3.0.dist-info/entry_points.txt +2 -0
- named_subagents-0.3.0.dist-info/licenses/LICENSE +21 -0
- named_subagents-0.3.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,1144 @@
|
|
|
1
|
+
"""
|
|
2
|
+
named_subagents — themed, non-repeating nicknames for Claude Code subagents.
|
|
3
|
+
|
|
4
|
+
A userspace port of Codex's per-instance `nickname_candidates`: every spawned
|
|
5
|
+
subagent instance gets a distinct, human-legible nickname drawn from a pool
|
|
6
|
+
themed to the *kind* of task it does (explorers for exploration, philosophers
|
|
7
|
+
for architecture reflection, detectives for debugging, ...). The names do not
|
|
8
|
+
repeat across iterations, backed by a persistent ledger.
|
|
9
|
+
|
|
10
|
+
Design contract
|
|
11
|
+
---------------
|
|
12
|
+
* stdlib-only, no third-party deps, Python 3.8+ -> runs on any Claude Code box.
|
|
13
|
+
* `registry.json` is the source of truth for pools + task->theme matching, so
|
|
14
|
+
the data is language-agnostic (the JS port reads the same file).
|
|
15
|
+
* Deterministic given (category, generation, ledger-state): resume/re-run safe,
|
|
16
|
+
mirroring the Workflow constraint that bans Math.random.
|
|
17
|
+
* Nicknames live ONLY in the prompt + label, never as a real subagent_type,
|
|
18
|
+
dodging the "generic name silently overrides the system prompt" footgun.
|
|
19
|
+
* Security-forward: every name (bundled or from a user config) passes
|
|
20
|
+
sanitization before it can reach a prompt or label (see NAME_PATTERN).
|
|
21
|
+
|
|
22
|
+
Two layers, mirroring Codex:
|
|
23
|
+
role/subagent_type <- Codex agent `name` (native in Claude Code)
|
|
24
|
+
nickname <- Codex `nickname_candidates` (this module)
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import contextlib
|
|
30
|
+
import hashlib
|
|
31
|
+
import json
|
|
32
|
+
import os
|
|
33
|
+
import re
|
|
34
|
+
import stat
|
|
35
|
+
import tempfile
|
|
36
|
+
from typing import Dict, Iterable, List, NamedTuple, Optional, Sequence, Set, Tuple
|
|
37
|
+
|
|
38
|
+
try:
|
|
39
|
+
import fcntl # POSIX advisory file locks; absent on Windows
|
|
40
|
+
except ImportError: # pragma: no cover - non-POSIX
|
|
41
|
+
fcntl = None # type: ignore[assignment]
|
|
42
|
+
|
|
43
|
+
__version__ = "0.3.0"
|
|
44
|
+
|
|
45
|
+
_HERE = os.path.dirname(os.path.abspath(__file__))
|
|
46
|
+
DEFAULT_REGISTRY_PATH = os.path.join(_HERE, "registry.json")
|
|
47
|
+
GEN_SEP = "·" # middle dot, e.g. "Magellan·2" on the 2nd cycle of the pool
|
|
48
|
+
|
|
49
|
+
CONFIG_ENV_VAR = "NAMED_SUBAGENTS_CONFIG"
|
|
50
|
+
# The implicit ./.named-subagents.json cwd config is the one untrusted-input
|
|
51
|
+
# surface (SECURITY.md). As of 0.3 it is OPT-IN: off unless enabled below.
|
|
52
|
+
NO_CWD_CONFIG_ENV_VAR = "NAMED_SUBAGENTS_NO_CWD_CONFIG" # force off (also `--no-cwd-config`)
|
|
53
|
+
CWD_CONFIG_ENV_VAR = "NAMED_SUBAGENTS_CWD_CONFIG" # opt back in (also `--cwd-config`)
|
|
54
|
+
LEDGER_VERSION = 2
|
|
55
|
+
|
|
56
|
+
# Registry / config files are semi-trusted local paths; a non-regular file
|
|
57
|
+
# (FIFO, /dev/zero) would hang open()+read(); an over-large one would OOM.
|
|
58
|
+
_MAX_FILE_BYTES = 32 * 1024 * 1024
|
|
59
|
+
|
|
60
|
+
# --- sanitization (D6) ------------------------------------------------------ #
|
|
61
|
+
# Nicknames flow into agent prompts and labels; with user configs they become
|
|
62
|
+
# untrusted input. NOTE: fullmatch is load-bearing — re.match(pat + "$") would
|
|
63
|
+
# accept "Name\n" (trailing-newline bypass).
|
|
64
|
+
NAME_PATTERN = r"[A-Za-z][A-Za-z0-9 .'-]{0,39}"
|
|
65
|
+
_NAME_RE = re.compile(NAME_PATTERN)
|
|
66
|
+
CATEGORY_KEY_PATTERN = r"[a-z][a-z0-9_-]{0,31}"
|
|
67
|
+
_CATEGORY_KEY_RE = re.compile(CATEGORY_KEY_PATTERN)
|
|
68
|
+
_BIO_BAD_RE = re.compile(r"[\x00-\x1f\x7f-\x9f`\[\]" + GEN_SEP + r"]")
|
|
69
|
+
_BIO_MAX_LEN = 120
|
|
70
|
+
# Unicode format/bidi/separator/zero-width code points that must never reach a
|
|
71
|
+
# prompt/label surface — line/para separators, bidi overrides & isolates,
|
|
72
|
+
# zero-width joiners/marks, BOM. (ASCII control is handled separately.)
|
|
73
|
+
_DANGEROUS_FORMAT = (
|
|
74
|
+
r"\u2028\u2029\u202a-\u202e\u200b-\u200f\ufeff\u2066-\u2069")
|
|
75
|
+
# theme/blurb reach agent prompts (theme) and the categories listing (blurb);
|
|
76
|
+
# with a config (D6) they are untrusted, so strip ASCII control + the same
|
|
77
|
+
# prompt-breakers the bio rule blocks (backtick/bracket/GEN_SEP) + the dangerous
|
|
78
|
+
# Unicode format ranges. Normal punctuation and legit unicode letters survive.
|
|
79
|
+
_TEXT_BAD_RE = re.compile(
|
|
80
|
+
r"[\x00-\x1f\x7f-\x9f`\[\]" + GEN_SEP + _DANGEROUS_FORMAT + r"]")
|
|
81
|
+
# emoji is a pictograph field: keep pictographs (and their VS-16 selectors), but
|
|
82
|
+
# strip ASCII control + the dangerous format/bidi/zero-width ranges.
|
|
83
|
+
_EMOJI_BAD_RE = re.compile(r"[\x00-\x1f\x7f-\x9f" + _DANGEROUS_FORMAT + r"]")
|
|
84
|
+
# (field, cap, sanitizer) — replaces the old control-only strip.
|
|
85
|
+
_TEXT_FIELD_SANITIZE = (
|
|
86
|
+
("theme", 200, _TEXT_BAD_RE),
|
|
87
|
+
("blurb", 200, _TEXT_BAD_RE),
|
|
88
|
+
("emoji", 8, _EMOJI_BAD_RE),
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _md5(data: bytes) -> "hashlib._Hash":
|
|
93
|
+
"""md5 wrapper: pass usedforsecurity=False so a FIPS-enforced interpreter
|
|
94
|
+
doesn't refuse md5 (the kwarg is 3.9+; 3.8 lacks it → fall back). This is a
|
|
95
|
+
non-cryptographic use (deterministic pool ordering); the digest is identical
|
|
96
|
+
either way, so output/parity is unaffected."""
|
|
97
|
+
try:
|
|
98
|
+
return hashlib.md5(data, usedforsecurity=False)
|
|
99
|
+
except TypeError:
|
|
100
|
+
return hashlib.md5(data)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _reject_constant(_val: str) -> None:
|
|
104
|
+
"""parse_constant hook for json.load: reject NaN/Infinity/-Infinity so the
|
|
105
|
+
Python ledger reader matches JS's standard JSON.parse (which rejects them),
|
|
106
|
+
keeping both ports' behavior identical on a NaN-laced ledger."""
|
|
107
|
+
raise ValueError("ledger contains a non-finite JSON constant")
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _check_regular_file(path: str, label: str) -> None:
|
|
111
|
+
"""Reject a non-regular file (FIFO / device — would hang) or an over-large
|
|
112
|
+
one (>32 MB) before opening it. Raises ValueError with a clear message."""
|
|
113
|
+
try:
|
|
114
|
+
st = os.stat(path)
|
|
115
|
+
except OSError as e:
|
|
116
|
+
raise ValueError("%s path %r: %s" % (label, path, e))
|
|
117
|
+
if not stat.S_ISREG(st.st_mode):
|
|
118
|
+
raise ValueError("%s path %r is not a regular file" % (label, path))
|
|
119
|
+
if st.st_size > _MAX_FILE_BYTES:
|
|
120
|
+
raise ValueError(
|
|
121
|
+
"%s path %r too large (%d bytes > %d)"
|
|
122
|
+
% (label, path, st.st_size, _MAX_FILE_BYTES))
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
# --- ledger field coercion (defensive read hardening) ----------------------- #
|
|
126
|
+
def _coerce_str_list(v: object) -> List[str]:
|
|
127
|
+
"""A JSON value → list of strings (null / non-list / non-string elements
|
|
128
|
+
are dropped)."""
|
|
129
|
+
return [x for x in v if isinstance(x, str)] if isinstance(v, list) else []
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _is_pos_int(v: object) -> bool:
|
|
133
|
+
"""True iff `v` is a positive integer JSON value (bool, NaN, non-integral
|
|
134
|
+
float, string, ≤0 all fail)."""
|
|
135
|
+
if isinstance(v, bool):
|
|
136
|
+
return False
|
|
137
|
+
if isinstance(v, int):
|
|
138
|
+
return v > 0
|
|
139
|
+
if isinstance(v, float):
|
|
140
|
+
return v == v and v > 0 and v == int(v) # not NaN, positive, integral
|
|
141
|
+
return False
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _is_nonneg_int(v: object) -> bool:
|
|
145
|
+
"""True iff `v` is a non-negative integer JSON value."""
|
|
146
|
+
if isinstance(v, bool):
|
|
147
|
+
return False
|
|
148
|
+
if isinstance(v, int):
|
|
149
|
+
return v >= 0
|
|
150
|
+
if isinstance(v, float):
|
|
151
|
+
return v == v and v >= 0 and v == int(v)
|
|
152
|
+
return False
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _coerce_pos_int(v: object, default: int = 1) -> int:
|
|
156
|
+
"""A JSON value → a positive int, else `default`."""
|
|
157
|
+
return int(v) if _is_pos_int(v) else default # type: ignore[arg-type]
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _coerce_nonneg_int(v: object, default: int = 0) -> int:
|
|
161
|
+
"""A JSON value → a non-negative int, else `default`."""
|
|
162
|
+
return int(v) if _is_nonneg_int(v) else default # type: ignore[arg-type]
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _is_str_list(v: object) -> bool:
|
|
166
|
+
return isinstance(v, list) and all(isinstance(x, str) for x in v)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def ledger_record_issue(rec: object) -> Optional[str]:
|
|
170
|
+
"""Return a human reason string if `rec` is a malformed ledger *category*
|
|
171
|
+
record, else None. Used by the CLI doctor to FAIL-report (never crash) a
|
|
172
|
+
structurally-valid-JSON-but-wrong-typed ledger."""
|
|
173
|
+
if not isinstance(rec, dict):
|
|
174
|
+
return "not a JSON object"
|
|
175
|
+
if "used" in rec and not _is_str_list(rec["used"]):
|
|
176
|
+
return "'used' must be a list of strings"
|
|
177
|
+
if "retired" in rec and not _is_str_list(rec["retired"]):
|
|
178
|
+
return "'retired' must be a list of strings"
|
|
179
|
+
if "generation" in rec and not _is_pos_int(rec["generation"]):
|
|
180
|
+
return "'generation' must be a positive integer"
|
|
181
|
+
if "total_allocated" in rec and not _is_nonneg_int(rec["total_allocated"]):
|
|
182
|
+
return "'total_allocated' must be a non-negative integer"
|
|
183
|
+
return None
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
class PoolExhaustedError(RuntimeError):
|
|
187
|
+
"""Raised when a category's effective pool (pool - retired - pinned -
|
|
188
|
+
avoided) is empty but names still need to be drawn. Generation cycling
|
|
189
|
+
cannot help: those exclusions bind BASE names and persist across
|
|
190
|
+
generations."""
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _strip_gen(display: str) -> str:
|
|
194
|
+
"""'Magellan·2' -> 'Magellan'; base names pass through unchanged."""
|
|
195
|
+
return display.split(GEN_SEP, 1)[0]
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _valid_name(name: object) -> bool:
|
|
199
|
+
return (
|
|
200
|
+
isinstance(name, str)
|
|
201
|
+
and GEN_SEP not in name # reserved: a name containing it could forge generation suffixes
|
|
202
|
+
and _NAME_RE.fullmatch(name) is not None
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
# --------------------------------------------------------------------------- #
|
|
207
|
+
# Config (D5)
|
|
208
|
+
# --------------------------------------------------------------------------- #
|
|
209
|
+
def _env_truthy(name: str) -> bool:
|
|
210
|
+
"""A conventional 1/true/yes/on env flag (empty / 0 / false / no / off -> False)."""
|
|
211
|
+
return os.environ.get(name, "").strip().lower() not in ("", "0", "false", "no", "off")
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def cwd_config_enabled(cli_override: Optional[bool] = None) -> bool:
|
|
215
|
+
"""Whether the implicit ./.named-subagents.json cwd config is auto-loaded.
|
|
216
|
+
|
|
217
|
+
It is the one *untrusted-input* surface (a project you cloned controls it),
|
|
218
|
+
so as of 0.3 it is OPT-IN. Precedence (first decisive wins):
|
|
219
|
+
|
|
220
|
+
1. explicit CLI flag — ``--cwd-config`` (True) / ``--no-cwd-config`` (False),
|
|
221
|
+
passed here as ``cli_override``
|
|
222
|
+
2. env — ``NAMED_SUBAGENTS_NO_CWD_CONFIG`` (off) beats
|
|
223
|
+
``NAMED_SUBAGENTS_CWD_CONFIG`` (on)
|
|
224
|
+
3. default — **off**
|
|
225
|
+
|
|
226
|
+
An explicit ``--config PATH``, ``$NAMED_SUBAGENTS_CONFIG``, and the home
|
|
227
|
+
config (``~/.config/named-subagents/config.json``) are unaffected — they are
|
|
228
|
+
deliberately pointed-at or user-owned, hence trusted.
|
|
229
|
+
"""
|
|
230
|
+
if cli_override is not None:
|
|
231
|
+
return cli_override
|
|
232
|
+
if _env_truthy(NO_CWD_CONFIG_ENV_VAR):
|
|
233
|
+
return False
|
|
234
|
+
if _env_truthy(CWD_CONFIG_ENV_VAR):
|
|
235
|
+
return True
|
|
236
|
+
return False
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def load_config(path: Optional[str] = None, allow_cwd: Optional[bool] = None) -> dict:
|
|
240
|
+
"""Load the user config. Search order (first existing wins):
|
|
241
|
+
|
|
242
|
+
1. explicit `path`
|
|
243
|
+
2. $NAMED_SUBAGENTS_CONFIG
|
|
244
|
+
3. ./.named-subagents.json — only when `allow_cwd` (see below); OFF by default
|
|
245
|
+
4. ~/.config/named-subagents/config.json
|
|
246
|
+
|
|
247
|
+
`allow_cwd`: include the cwd candidate? None -> resolve from env/default via
|
|
248
|
+
`cwd_config_enabled()`; True/False force it. The cwd file is untrusted input
|
|
249
|
+
(SECURITY.md), so it is opt-in as of 0.3.
|
|
250
|
+
|
|
251
|
+
No candidate exists -> {}. A found-but-invalid config fails loudly
|
|
252
|
+
(never silently dropped).
|
|
253
|
+
"""
|
|
254
|
+
if allow_cwd is None:
|
|
255
|
+
allow_cwd = cwd_config_enabled()
|
|
256
|
+
candidates = [
|
|
257
|
+
path,
|
|
258
|
+
os.environ.get(CONFIG_ENV_VAR),
|
|
259
|
+
]
|
|
260
|
+
if allow_cwd:
|
|
261
|
+
candidates.append(os.path.join(".", ".named-subagents.json"))
|
|
262
|
+
candidates.append(
|
|
263
|
+
os.path.join(os.path.expanduser("~"), ".config", "named-subagents", "config.json")
|
|
264
|
+
)
|
|
265
|
+
for cand in candidates:
|
|
266
|
+
if cand and os.path.isfile(cand):
|
|
267
|
+
with open(cand, "r", encoding="utf-8") as fh:
|
|
268
|
+
loaded = json.load(fh) # corrupt JSON raises: loud by design
|
|
269
|
+
if not isinstance(loaded, dict):
|
|
270
|
+
raise ValueError("config %r: top level must be a JSON object" % cand)
|
|
271
|
+
return loaded
|
|
272
|
+
return {}
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def _merge_config(data: dict, config: dict) -> dict:
|
|
276
|
+
"""Merge a config dict into raw registry data (before validation).
|
|
277
|
+
|
|
278
|
+
- config["categories"]: NEW key -> added; existing key -> REPLACED whole.
|
|
279
|
+
- config["extend"]: appends names/keywords/subagent_types and merges bios
|
|
280
|
+
into an existing category.
|
|
281
|
+
"""
|
|
282
|
+
cats = data.setdefault("categories", {})
|
|
283
|
+
for key, spec in (config.get("categories") or {}).items():
|
|
284
|
+
cats[key] = spec
|
|
285
|
+
for key, ext in (config.get("extend") or {}).items():
|
|
286
|
+
if key not in cats:
|
|
287
|
+
raise ValueError("config extends unknown category %r" % key)
|
|
288
|
+
if not isinstance(ext, dict):
|
|
289
|
+
raise ValueError("config extend for %r must be an object" % key)
|
|
290
|
+
spec = cats[key]
|
|
291
|
+
for field in ("names", "keywords", "subagent_types"):
|
|
292
|
+
if ext.get(field):
|
|
293
|
+
spec[field] = list(spec.get(field, [])) + list(ext[field])
|
|
294
|
+
if ext.get("bios"):
|
|
295
|
+
bios = dict(spec.get("bios", {}))
|
|
296
|
+
bios.update(ext["bios"])
|
|
297
|
+
spec["bios"] = bios
|
|
298
|
+
return data
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
# --------------------------------------------------------------------------- #
|
|
302
|
+
# Registry
|
|
303
|
+
# --------------------------------------------------------------------------- #
|
|
304
|
+
class Registry:
|
|
305
|
+
"""The themed name pools + task->category matching, loaded from JSON."""
|
|
306
|
+
|
|
307
|
+
def __init__(self, data: dict):
|
|
308
|
+
self.data = data
|
|
309
|
+
self.categories: Dict[str, dict] = data["categories"]
|
|
310
|
+
self.validate()
|
|
311
|
+
|
|
312
|
+
@classmethod
|
|
313
|
+
def load(cls, path: Optional[str] = None, config: Optional[dict] = None) -> "Registry":
|
|
314
|
+
"""Load the bundled (or `path`) registry, optionally merged with a
|
|
315
|
+
config dict (see load_config / D5). Everything is re-validated after
|
|
316
|
+
the merge."""
|
|
317
|
+
reg_path = path or DEFAULT_REGISTRY_PATH
|
|
318
|
+
_check_regular_file(reg_path, "registry")
|
|
319
|
+
with open(reg_path, "r", encoding="utf-8") as fh:
|
|
320
|
+
data = json.load(fh)
|
|
321
|
+
if config:
|
|
322
|
+
data = _merge_config(data, config)
|
|
323
|
+
return cls(data)
|
|
324
|
+
|
|
325
|
+
# --- integrity ---------------------------------------------------------- #
|
|
326
|
+
def validate(self) -> None:
|
|
327
|
+
"""Global uniqueness + non-empty pools + sanitization (D6):
|
|
328
|
+
- category keys must fullmatch CATEGORY_KEY_PATTERN (integer-like keys
|
|
329
|
+
would break cross-language object-key-order parity),
|
|
330
|
+
- every name must fullmatch NAME_PATTERN and never contain GEN_SEP,
|
|
331
|
+
- theme/emoji/blurb are stripped of control chars and length-capped,
|
|
332
|
+
- bios keys must be a subset of names; bios values <= 120 chars, no
|
|
333
|
+
control chars, backticks, brackets, or GEN_SEP.
|
|
334
|
+
"""
|
|
335
|
+
seen: Dict[str, str] = {}
|
|
336
|
+
dupes: List[str] = []
|
|
337
|
+
for cat, spec in self.categories.items():
|
|
338
|
+
if not isinstance(cat, str) or not _CATEGORY_KEY_RE.fullmatch(cat):
|
|
339
|
+
raise ValueError(
|
|
340
|
+
"invalid category key %r: must fullmatch %r" % (cat, CATEGORY_KEY_PATTERN))
|
|
341
|
+
if not isinstance(spec, dict):
|
|
342
|
+
raise ValueError("category %r: spec must be an object" % cat)
|
|
343
|
+
names = spec.get("names", [])
|
|
344
|
+
if not names:
|
|
345
|
+
raise ValueError(f"category '{cat}' has an empty name pool")
|
|
346
|
+
for n in names:
|
|
347
|
+
if not _valid_name(n):
|
|
348
|
+
raise ValueError(
|
|
349
|
+
"category %r: invalid name %r (must fullmatch %r; %r is reserved)"
|
|
350
|
+
% (cat, n, NAME_PATTERN, GEN_SEP))
|
|
351
|
+
if n in seen:
|
|
352
|
+
dupes.append(f"{n!r} in both '{seen[n]}' and '{cat}'")
|
|
353
|
+
seen[n] = cat
|
|
354
|
+
# display-string hygiene (D6): theme/blurb/emoji reach prompts &
|
|
355
|
+
# labels; with a config they are untrusted, so strip the dangerous
|
|
356
|
+
# prompt-breaker + Unicode-format classes (per field) and length-cap.
|
|
357
|
+
for field, cap, bad_re in _TEXT_FIELD_SANITIZE:
|
|
358
|
+
val = spec.get(field)
|
|
359
|
+
if isinstance(val, str):
|
|
360
|
+
spec[field] = bad_re.sub("", val)[:cap]
|
|
361
|
+
bios = spec.get("bios") or {}
|
|
362
|
+
if not isinstance(bios, dict):
|
|
363
|
+
raise ValueError("category %r: bios must be an object" % cat)
|
|
364
|
+
name_set = set(names)
|
|
365
|
+
for bname, btext in bios.items():
|
|
366
|
+
if bname not in name_set:
|
|
367
|
+
raise ValueError("category %r: bio for unknown name %r" % (cat, bname))
|
|
368
|
+
if (not isinstance(btext, str) or len(btext) > _BIO_MAX_LEN
|
|
369
|
+
or _BIO_BAD_RE.search(btext)):
|
|
370
|
+
raise ValueError(
|
|
371
|
+
"category %r: invalid bio for %r (<=%d chars; no control chars, "
|
|
372
|
+
"backticks, brackets, or %r)" % (cat, bname, _BIO_MAX_LEN, GEN_SEP))
|
|
373
|
+
if dupes:
|
|
374
|
+
raise ValueError("registry name collisions: " + "; ".join(dupes))
|
|
375
|
+
|
|
376
|
+
# --- accessors ---------------------------------------------------------- #
|
|
377
|
+
def names(self, category: str) -> List[str]:
|
|
378
|
+
return list(self.categories[category]["names"])
|
|
379
|
+
|
|
380
|
+
def theme(self, category: str) -> str:
|
|
381
|
+
return self.categories[category].get("theme", category)
|
|
382
|
+
|
|
383
|
+
def emoji(self, category: str) -> str:
|
|
384
|
+
return self.categories[category].get("emoji", "")
|
|
385
|
+
|
|
386
|
+
def bio(self, category: str, name: str) -> str:
|
|
387
|
+
"""One-line bio for a name (display form accepted: '·N' is stripped).
|
|
388
|
+
Missing category/name/bio -> ''."""
|
|
389
|
+
spec = self.categories.get(category) or {}
|
|
390
|
+
bios = spec.get("bios") or {}
|
|
391
|
+
val = bios.get(_strip_gen(name), "")
|
|
392
|
+
return val if isinstance(val, str) else ""
|
|
393
|
+
|
|
394
|
+
def total_names(self) -> int:
|
|
395
|
+
return sum(len(s["names"]) for s in self.categories.values())
|
|
396
|
+
|
|
397
|
+
# --- resolution --------------------------------------------------------- #
|
|
398
|
+
def by_subagent_type(self, role: str) -> Optional[str]:
|
|
399
|
+
role_l = role.strip().lower()
|
|
400
|
+
for cat, spec in self.categories.items():
|
|
401
|
+
for t in spec.get("subagent_types", []):
|
|
402
|
+
if t.lower() == role_l:
|
|
403
|
+
return cat
|
|
404
|
+
return None
|
|
405
|
+
|
|
406
|
+
def keyword_scores(self, task: str) -> Dict[str, int]:
|
|
407
|
+
t = task.lower()
|
|
408
|
+
scores: Dict[str, int] = {}
|
|
409
|
+
for cat, spec in self.categories.items():
|
|
410
|
+
hits = sum(1 for kw in spec.get("keywords", []) if kw in t)
|
|
411
|
+
if hits:
|
|
412
|
+
scores[cat] = hits
|
|
413
|
+
return scores
|
|
414
|
+
|
|
415
|
+
def by_keyword(self, task: str) -> Optional[str]:
|
|
416
|
+
scores = self.keyword_scores(task)
|
|
417
|
+
if not scores:
|
|
418
|
+
return None
|
|
419
|
+
# Highest hit-count wins; ties broken by registry order for determinism.
|
|
420
|
+
best = max(scores.values())
|
|
421
|
+
for cat in self.categories: # dict preserves insertion order
|
|
422
|
+
if scores.get(cat) == best:
|
|
423
|
+
return cat
|
|
424
|
+
return None
|
|
425
|
+
|
|
426
|
+
def keyword_matches(self, task: str) -> Dict[str, List[str]]:
|
|
427
|
+
"""Per-category list of the keywords that appear as substrings in `task`
|
|
428
|
+
(case-insensitive) — the evidence behind resolve() / ``resolve --explain``."""
|
|
429
|
+
t = task.lower()
|
|
430
|
+
out: Dict[str, List[str]] = {}
|
|
431
|
+
for cat, spec in self.categories.items():
|
|
432
|
+
hit = [kw for kw in spec.get("keywords", []) if kw in t]
|
|
433
|
+
if hit:
|
|
434
|
+
out[cat] = hit
|
|
435
|
+
return out
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
def load_with_config(
|
|
439
|
+
registry_path: Optional[str] = None,
|
|
440
|
+
config_path: Optional[str] = None,
|
|
441
|
+
allow_cwd: Optional[bool] = None,
|
|
442
|
+
) -> Tuple[Registry, dict]:
|
|
443
|
+
"""load_config + Registry.load in one call.
|
|
444
|
+
|
|
445
|
+
`allow_cwd` is threaded to `load_config` (cwd config opt-in; see there).
|
|
446
|
+
Returns (registry, config) — the config is returned too because it may
|
|
447
|
+
carry runtime-only keys the Registry doesn't store (e.g. "pins")."""
|
|
448
|
+
config = load_config(config_path, allow_cwd=allow_cwd)
|
|
449
|
+
return Registry.load(registry_path, config=config), config
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
def resolve_category(
|
|
453
|
+
registry: Registry,
|
|
454
|
+
role: Optional[str] = None,
|
|
455
|
+
task: Optional[str] = None,
|
|
456
|
+
category: Optional[str] = None,
|
|
457
|
+
) -> str:
|
|
458
|
+
"""explicit category > subagent_type match > task keyword match > 'default'."""
|
|
459
|
+
if category and category in registry.categories:
|
|
460
|
+
return category
|
|
461
|
+
if role:
|
|
462
|
+
by_role = registry.by_subagent_type(role)
|
|
463
|
+
if by_role:
|
|
464
|
+
return by_role
|
|
465
|
+
if task:
|
|
466
|
+
by_kw = registry.by_keyword(task)
|
|
467
|
+
if by_kw:
|
|
468
|
+
return by_kw
|
|
469
|
+
return "default"
|
|
470
|
+
|
|
471
|
+
|
|
472
|
+
# --------------------------------------------------------------------------- #
|
|
473
|
+
# Ledger — the "don't repeat across iterations" memory (schema v2, D2)
|
|
474
|
+
# --------------------------------------------------------------------------- #
|
|
475
|
+
class Ledger:
|
|
476
|
+
"""Persistent per-category record of used base-names, current generation,
|
|
477
|
+
retired names, and a lifetime allocation counter.
|
|
478
|
+
|
|
479
|
+
Schema v2 (top-level `"_v": 2` marker):
|
|
480
|
+
{"_v": 2, "explore": {"used": [...], "generation": 2,
|
|
481
|
+
"retired": [...], "total_allocated": 41}}
|
|
482
|
+
|
|
483
|
+
Back-compat: a v1 file (no `_v`, no `retired`/`total_allocated`) reads
|
|
484
|
+
fine — missing fields default (retired=[], total_allocated=0) — and is
|
|
485
|
+
upgraded to v2 on first write. Forward-compat: `update()` MERGES into the
|
|
486
|
+
existing category record, so unknown keys written by a future version
|
|
487
|
+
survive a v2 writer.
|
|
488
|
+
|
|
489
|
+
path=None -> ephemeral (in-memory only; save() is a no-op). A missing or
|
|
490
|
+
corrupt file starts empty rather than crashing.
|
|
491
|
+
"""
|
|
492
|
+
|
|
493
|
+
def __init__(self, path: Optional[str] = None):
|
|
494
|
+
self.path = path
|
|
495
|
+
self.state: Dict[str, object] = {}
|
|
496
|
+
self._load()
|
|
497
|
+
|
|
498
|
+
def _load(self) -> None:
|
|
499
|
+
"""(Re)read state from disk; corrupt/unreadable/missing -> empty (never
|
|
500
|
+
crashes). Called at construction, and again inside lock() so a critical
|
|
501
|
+
section reads a concurrent writer's changes before it allocates.
|
|
502
|
+
|
|
503
|
+
parse_constant rejects NaN/Infinity so Python matches JS's standard
|
|
504
|
+
JSON.parse -> a NaN-laced ledger reads as corrupt->fresh in BOTH ports.
|
|
505
|
+
"""
|
|
506
|
+
if not (self.path and os.path.exists(self.path)):
|
|
507
|
+
self.state = {}
|
|
508
|
+
return
|
|
509
|
+
try:
|
|
510
|
+
with open(self.path, "r", encoding="utf-8") as fh:
|
|
511
|
+
loaded = json.load(fh, parse_constant=_reject_constant)
|
|
512
|
+
self.state = loaded if isinstance(loaded, dict) else {}
|
|
513
|
+
except (ValueError, OSError):
|
|
514
|
+
self.state = {} # corrupt/unreadable -> fresh, never crash
|
|
515
|
+
|
|
516
|
+
# --- internal ----------------------------------------------------------- #
|
|
517
|
+
def _rec(self, category: str) -> dict:
|
|
518
|
+
rec = self.state.get(category)
|
|
519
|
+
return rec if isinstance(rec, dict) else {}
|
|
520
|
+
|
|
521
|
+
def _live_rec(self, category: str) -> dict:
|
|
522
|
+
"""The mutable category record, replacing a non-dict value if needed."""
|
|
523
|
+
rec = self.state.get(category)
|
|
524
|
+
if not isinstance(rec, dict):
|
|
525
|
+
rec = {}
|
|
526
|
+
self.state[category] = rec
|
|
527
|
+
return rec
|
|
528
|
+
|
|
529
|
+
def _touch(self) -> None:
|
|
530
|
+
self.state["_v"] = LEDGER_VERSION
|
|
531
|
+
self.save()
|
|
532
|
+
|
|
533
|
+
# --- reads (defensively coerced: a wrong-typed field never crashes and
|
|
534
|
+
# never diverges from the JS port — malformed -> treated as fresh) ---- #
|
|
535
|
+
def used(self, category: str) -> List[str]:
|
|
536
|
+
return _coerce_str_list(self._rec(category).get("used"))
|
|
537
|
+
|
|
538
|
+
def generation(self, category: str) -> int:
|
|
539
|
+
return _coerce_pos_int(self._rec(category).get("generation"))
|
|
540
|
+
|
|
541
|
+
def retired(self, category: str) -> List[str]:
|
|
542
|
+
return _coerce_str_list(self._rec(category).get("retired"))
|
|
543
|
+
|
|
544
|
+
def total_allocated(self, category: str) -> int:
|
|
545
|
+
return _coerce_nonneg_int(self._rec(category).get("total_allocated"))
|
|
546
|
+
|
|
547
|
+
# --- writes ------------------------------------------------------------- #
|
|
548
|
+
def update(
|
|
549
|
+
self,
|
|
550
|
+
category: str,
|
|
551
|
+
used: Sequence[str],
|
|
552
|
+
generation: int,
|
|
553
|
+
newly_allocated: int = 0,
|
|
554
|
+
) -> None:
|
|
555
|
+
"""Merge allocation state into the category record (preserving keys
|
|
556
|
+
this version doesn't know about) and bump the lifetime counter by
|
|
557
|
+
`newly_allocated` (the number of newly DRAWN names — pins excluded)."""
|
|
558
|
+
rec = self._live_rec(category)
|
|
559
|
+
prev_total = self.total_allocated(category) # coerced (malformed -> 0)
|
|
560
|
+
rec["used"] = list(used)
|
|
561
|
+
rec["generation"] = int(generation)
|
|
562
|
+
rec["retired"] = self.retired(category) # coerced (null/bad -> [])
|
|
563
|
+
rec["total_allocated"] = prev_total + int(newly_allocated)
|
|
564
|
+
self._touch()
|
|
565
|
+
|
|
566
|
+
def release(self, category: str, name: str) -> bool:
|
|
567
|
+
"""Remove a base name from the current generation's `used`, making it
|
|
568
|
+
allocatable again. Accepts the display form ('Name·2' -> 'Name').
|
|
569
|
+
Returns False if it wasn't held."""
|
|
570
|
+
base = _strip_gen(name)
|
|
571
|
+
used = self.used(category) # coerced copy
|
|
572
|
+
if base not in used:
|
|
573
|
+
return False
|
|
574
|
+
used.remove(base)
|
|
575
|
+
self._live_rec(category)["used"] = used
|
|
576
|
+
self._touch()
|
|
577
|
+
return True
|
|
578
|
+
|
|
579
|
+
def retire(self, category: str, name: str) -> bool:
|
|
580
|
+
"""Permanently exclude a base name from allocation in EVERY generation
|
|
581
|
+
(until unretire). Accepts the display form. Returns False if it was
|
|
582
|
+
already retired."""
|
|
583
|
+
base = _strip_gen(name)
|
|
584
|
+
retired = self.retired(category) # coerced copy
|
|
585
|
+
if base in retired:
|
|
586
|
+
return False
|
|
587
|
+
retired.append(base)
|
|
588
|
+
self._live_rec(category)["retired"] = retired
|
|
589
|
+
self._touch()
|
|
590
|
+
return True
|
|
591
|
+
|
|
592
|
+
def unretire(self, category: str, name: str) -> bool:
|
|
593
|
+
"""Reverse retire(). Returns False if the name wasn't retired."""
|
|
594
|
+
base = _strip_gen(name)
|
|
595
|
+
retired = self.retired(category) # coerced copy
|
|
596
|
+
if base not in retired:
|
|
597
|
+
return False
|
|
598
|
+
retired.remove(base)
|
|
599
|
+
self._live_rec(category)["retired"] = retired
|
|
600
|
+
self._touch()
|
|
601
|
+
return True
|
|
602
|
+
|
|
603
|
+
def reset(self, category: Optional[str] = None) -> None:
|
|
604
|
+
if category is None:
|
|
605
|
+
self.state = {}
|
|
606
|
+
else:
|
|
607
|
+
self.state.pop(category, None)
|
|
608
|
+
self.save()
|
|
609
|
+
|
|
610
|
+
def save(self) -> None:
|
|
611
|
+
if not self.path:
|
|
612
|
+
return
|
|
613
|
+
data = json.dumps(self.state, indent=2, ensure_ascii=False)
|
|
614
|
+
abspath = os.path.abspath(self.path)
|
|
615
|
+
d = os.path.dirname(abspath) or "."
|
|
616
|
+
# mkstemp = O_CREAT|O_EXCL|O_NOFOLLOW-equivalent + a randomized name in
|
|
617
|
+
# the ledger's own dir: a pre-planted symlink at a predictable `<path>.tmp`
|
|
618
|
+
# can no longer be followed to clobber an arbitrary target.
|
|
619
|
+
fd, tmp = tempfile.mkstemp(dir=d, prefix=os.path.basename(abspath) + ".", suffix=".tmp")
|
|
620
|
+
try:
|
|
621
|
+
with os.fdopen(fd, "w", encoding="utf-8") as fh:
|
|
622
|
+
fh.write(data)
|
|
623
|
+
os.replace(tmp, self.path) # atomic
|
|
624
|
+
except BaseException:
|
|
625
|
+
try:
|
|
626
|
+
os.unlink(tmp)
|
|
627
|
+
except OSError:
|
|
628
|
+
pass
|
|
629
|
+
raise
|
|
630
|
+
|
|
631
|
+
@contextlib.contextmanager
|
|
632
|
+
def lock(self):
|
|
633
|
+
"""Hold an exclusive cross-process lock around a load->allocate->save
|
|
634
|
+
critical section, closing the single-writer race (SECURITY.md). Opt-in::
|
|
635
|
+
|
|
636
|
+
led = Ledger(path)
|
|
637
|
+
with led.lock(): # blocks for the lock, then reloads fresh state
|
|
638
|
+
names = allocate("explore", 3, reg, ledger=led)
|
|
639
|
+
led.save()
|
|
640
|
+
|
|
641
|
+
In-memory ledgers (path=None) and platforms without ``fcntl`` (Windows)
|
|
642
|
+
yield without a real lock -- serialize your own writers there.
|
|
643
|
+
"""
|
|
644
|
+
if not self.path or fcntl is None:
|
|
645
|
+
yield self
|
|
646
|
+
return
|
|
647
|
+
fd = os.open(self.path + ".lock", os.O_CREAT | os.O_RDWR, 0o600)
|
|
648
|
+
try:
|
|
649
|
+
fcntl.flock(fd, fcntl.LOCK_EX)
|
|
650
|
+
self._load() # freshest state now that we hold the lock
|
|
651
|
+
yield self
|
|
652
|
+
finally:
|
|
653
|
+
try:
|
|
654
|
+
fcntl.flock(fd, fcntl.LOCK_UN)
|
|
655
|
+
finally:
|
|
656
|
+
os.close(fd)
|
|
657
|
+
|
|
658
|
+
@contextlib.contextmanager
|
|
659
|
+
def session(self):
|
|
660
|
+
"""Draw names inside the block; auto-release them on exit so short-lived
|
|
661
|
+
names recycle without manual ``release()`` calls::
|
|
662
|
+
|
|
663
|
+
with led.session():
|
|
664
|
+
names = allocate("explore", 3, reg, ledger=led)
|
|
665
|
+
... # use names
|
|
666
|
+
# the 3 base names are back in the pool
|
|
667
|
+
|
|
668
|
+
Best-effort: releases the base names newly added to each category's
|
|
669
|
+
``used`` during the block (sorted, so both ports match). A draw that
|
|
670
|
+
crossed a generation boundary may not fully recycle, since ``release()``
|
|
671
|
+
targets the current generation.
|
|
672
|
+
"""
|
|
673
|
+
before = {c: set(self.used(c)) for c in self.state if c != "_v"}
|
|
674
|
+
try:
|
|
675
|
+
yield self
|
|
676
|
+
finally:
|
|
677
|
+
for c in [c for c in self.state if c != "_v"]:
|
|
678
|
+
for name in sorted(set(self.used(c)) - before.get(c, set())):
|
|
679
|
+
self.release(c, name)
|
|
680
|
+
|
|
681
|
+
|
|
682
|
+
# --------------------------------------------------------------------------- #
|
|
683
|
+
# Allocation
|
|
684
|
+
# --------------------------------------------------------------------------- #
|
|
685
|
+
def _ordered_pool(pool: Sequence[str], category: str, generation: int) -> List[str]:
|
|
686
|
+
"""Deterministic per-(category, generation) permutation of the pool.
|
|
687
|
+
|
|
688
|
+
md5 (stable across processes) rather than the salted built-in hash(); a new
|
|
689
|
+
generation reshuffles, so cycles don't march through names in lockstep.
|
|
690
|
+
"""
|
|
691
|
+
def key(name: str) -> str:
|
|
692
|
+
raw = f"{category}:{generation}:{name}".encode("utf-8")
|
|
693
|
+
return _md5(raw).hexdigest()
|
|
694
|
+
|
|
695
|
+
return sorted(pool, key=key)
|
|
696
|
+
|
|
697
|
+
|
|
698
|
+
def _display(name: str, generation: int) -> str:
|
|
699
|
+
return name if generation <= 1 else f"{name}{GEN_SEP}{generation}"
|
|
700
|
+
|
|
701
|
+
|
|
702
|
+
def allocate(
|
|
703
|
+
category: str,
|
|
704
|
+
count: int,
|
|
705
|
+
registry: Registry,
|
|
706
|
+
ledger: Optional[Ledger] = None,
|
|
707
|
+
taken: Optional[Sequence[str]] = None,
|
|
708
|
+
pins: Optional[Dict[str, str]] = None,
|
|
709
|
+
avoid: Optional[Iterable[str]] = None,
|
|
710
|
+
) -> List[str]:
|
|
711
|
+
"""Return `count` distinct nicknames for `category`.
|
|
712
|
+
|
|
713
|
+
- never repeats a display-name within the ledger's lifetime (generations
|
|
714
|
+
add a `·N` suffix once a pool cycles) unless explicitly release()d,
|
|
715
|
+
- collision-free against `taken` and within the batch (case-folded),
|
|
716
|
+
- deterministic given (category, ledger-state, taken, pins, avoid).
|
|
717
|
+
|
|
718
|
+
Exclusion semantics (they differ deliberately):
|
|
719
|
+
- `taken`: exact-DISPLAY-name, batch-local — may legitimately escape via a
|
|
720
|
+
`·N` suffix in a later generation.
|
|
721
|
+
- `avoid`: case-insensitive BASE-name — persists across generations and
|
|
722
|
+
participates in the exhaustion check (D8).
|
|
723
|
+
- retired (ledger): base-name, skipped in EVERY generation (D3).
|
|
724
|
+
- `pins` ({category: Name}): the pinned name fills slot 0 of its own
|
|
725
|
+
category's batch verbatim, bypassing the ledger (NOT recorded in `used`),
|
|
726
|
+
and is excluded from normal draws in ALL categories case-insensitively.
|
|
727
|
+
A pin is one stable recurring identity: it may repeat across batches —
|
|
728
|
+
and thus be concurrently live in two batches — by design.
|
|
729
|
+
|
|
730
|
+
Raises PoolExhaustedError up front when draws are needed but the effective
|
|
731
|
+
pool (pool - retired - pinned - avoided) is empty.
|
|
732
|
+
"""
|
|
733
|
+
if count < 0:
|
|
734
|
+
raise ValueError("count must be >= 0")
|
|
735
|
+
if category not in registry.categories:
|
|
736
|
+
category = "default"
|
|
737
|
+
|
|
738
|
+
pins = dict(pins or {})
|
|
739
|
+
for pin_cat, pin_name in pins.items():
|
|
740
|
+
if not _valid_name(pin_name):
|
|
741
|
+
raise ValueError(
|
|
742
|
+
"invalid pin %r for category %r (must fullmatch %r; %r is reserved)"
|
|
743
|
+
% (pin_name, pin_cat, NAME_PATTERN, GEN_SEP))
|
|
744
|
+
|
|
745
|
+
pool = registry.names(category)
|
|
746
|
+
taken_set = set(taken or ())
|
|
747
|
+
avoid_l = {a.lower() for a in (avoid or ())}
|
|
748
|
+
pinned_l = {p.lower() for p in pins.values()}
|
|
749
|
+
|
|
750
|
+
result: List[str] = []
|
|
751
|
+
pin = pins.get(category)
|
|
752
|
+
if pin is not None and count >= 1:
|
|
753
|
+
result.append(pin) # slot 0, bypasses ledger, NOT recorded in used
|
|
754
|
+
|
|
755
|
+
need = count - len(result)
|
|
756
|
+
retired = set(ledger.retired(category)) if ledger else set()
|
|
757
|
+
effective = [n for n in pool
|
|
758
|
+
if n.lower() not in pinned_l
|
|
759
|
+
and n.lower() not in avoid_l
|
|
760
|
+
and n not in retired]
|
|
761
|
+
if need > 0 and not effective:
|
|
762
|
+
raise PoolExhaustedError(
|
|
763
|
+
f"category '{category}': no allocatable names remain "
|
|
764
|
+
f"(pool={len(pool)}, retired={len(retired)}, "
|
|
765
|
+
f"pinned={len(pinned_l)}, avoided={len(avoid_l)})")
|
|
766
|
+
|
|
767
|
+
used = set(ledger.used(category)) if ledger else set()
|
|
768
|
+
gen = ledger.generation(category) if ledger else 1
|
|
769
|
+
|
|
770
|
+
drawn = 0
|
|
771
|
+
guard = 0
|
|
772
|
+
max_gens = (need // max(len(effective), 1)) + 3
|
|
773
|
+
while len(result) < count:
|
|
774
|
+
guard += 1
|
|
775
|
+
if guard > max_gens + 2:
|
|
776
|
+
raise RuntimeError("allocation failed to converge") # unreachable
|
|
777
|
+
for base in _ordered_pool(effective, category, gen):
|
|
778
|
+
if len(result) >= count:
|
|
779
|
+
break
|
|
780
|
+
if base in used:
|
|
781
|
+
continue
|
|
782
|
+
disp = _display(base, gen)
|
|
783
|
+
if disp in taken_set or disp in result:
|
|
784
|
+
continue
|
|
785
|
+
if disp.lower() in {r.lower() for r in result}: # pin vs draw, any case
|
|
786
|
+
continue
|
|
787
|
+
result.append(disp)
|
|
788
|
+
used.add(base)
|
|
789
|
+
drawn += 1
|
|
790
|
+
if len(result) < count:
|
|
791
|
+
# this generation's pool is exhausted -> cycle to the next
|
|
792
|
+
gen += 1
|
|
793
|
+
used = set()
|
|
794
|
+
|
|
795
|
+
if ledger:
|
|
796
|
+
ledger.update(category, sorted(used), gen, newly_allocated=drawn)
|
|
797
|
+
return result
|
|
798
|
+
|
|
799
|
+
|
|
800
|
+
# --------------------------------------------------------------------------- #
|
|
801
|
+
# Live collision-avoidance (D8)
|
|
802
|
+
# --------------------------------------------------------------------------- #
|
|
803
|
+
_FRONTMATTER_RE = re.compile(r"\A---[ \t]*\r?\n(.*?)\r?\n---", re.DOTALL)
|
|
804
|
+
_FM_NAME_RE = re.compile(r"^name[ \t]*:[ \t]*(.+?)[ \t]*$", re.MULTILINE)
|
|
805
|
+
_AGENT_SCAN_BYTES = 4096
|
|
806
|
+
|
|
807
|
+
|
|
808
|
+
def installed_agent_names(dirs: Optional[Sequence[str]] = None) -> Set[str]:
|
|
809
|
+
"""Scan Claude Code agent definitions for their frontmatter `name:` values.
|
|
810
|
+
|
|
811
|
+
Default dirs: ./.claude/agents and ~/.claude/agents. For each *.md file,
|
|
812
|
+
reads at most 4096 bytes and regexes the leading `---` YAML-frontmatter
|
|
813
|
+
block for `name: value` (optional quotes). No YAML parser, no code exec;
|
|
814
|
+
unreadable or malformed files are silently skipped.
|
|
815
|
+
"""
|
|
816
|
+
if dirs is None:
|
|
817
|
+
dirs = [
|
|
818
|
+
os.path.join(".", ".claude", "agents"),
|
|
819
|
+
os.path.join(os.path.expanduser("~"), ".claude", "agents"),
|
|
820
|
+
]
|
|
821
|
+
found: Set[str] = set()
|
|
822
|
+
for d in dirs:
|
|
823
|
+
try:
|
|
824
|
+
entries = sorted(os.listdir(d))
|
|
825
|
+
except OSError:
|
|
826
|
+
continue
|
|
827
|
+
for fname in entries:
|
|
828
|
+
if not fname.endswith(".md"):
|
|
829
|
+
continue
|
|
830
|
+
path = os.path.join(d, fname)
|
|
831
|
+
try:
|
|
832
|
+
# Skip non-regular files (a FIFO/device named `evil.md` would
|
|
833
|
+
# block open()+read()); stat first, never open a non-regular.
|
|
834
|
+
if not stat.S_ISREG(os.stat(path).st_mode):
|
|
835
|
+
continue
|
|
836
|
+
with open(path, "r", encoding="utf-8", errors="replace") as fh:
|
|
837
|
+
head = fh.read(_AGENT_SCAN_BYTES)
|
|
838
|
+
except OSError:
|
|
839
|
+
continue
|
|
840
|
+
fm = _FRONTMATTER_RE.match(head)
|
|
841
|
+
if not fm:
|
|
842
|
+
continue
|
|
843
|
+
m = _FM_NAME_RE.search(fm.group(1))
|
|
844
|
+
if not m:
|
|
845
|
+
continue
|
|
846
|
+
val = m.group(1)
|
|
847
|
+
if len(val) >= 2 and val[0] == val[-1] and val[0] in ("'", '"'):
|
|
848
|
+
val = val[1:-1].strip()
|
|
849
|
+
if val:
|
|
850
|
+
found.add(val)
|
|
851
|
+
return found
|
|
852
|
+
|
|
853
|
+
|
|
854
|
+
# --------------------------------------------------------------------------- #
|
|
855
|
+
# Stats (D9)
|
|
856
|
+
# --------------------------------------------------------------------------- #
|
|
857
|
+
def _stats_row(pool_names: Sequence[str], ledger: Ledger, category: str) -> dict:
|
|
858
|
+
pool = len(pool_names)
|
|
859
|
+
used = len(ledger.used(category))
|
|
860
|
+
retired_list = ledger.retired(category)
|
|
861
|
+
retired = len(retired_list)
|
|
862
|
+
# `remaining` counts only retired names that are actually IN the pool — a
|
|
863
|
+
# stray retired entry (typo / not-in-pool) can't push remaining negative.
|
|
864
|
+
retired_in_pool = len(set(pool_names) & set(retired_list))
|
|
865
|
+
return {
|
|
866
|
+
"pool": pool,
|
|
867
|
+
"used": used,
|
|
868
|
+
"pct_used": round(100.0 * used / pool, 1) if pool else 0.0,
|
|
869
|
+
"generation": ledger.generation(category),
|
|
870
|
+
"retired": retired,
|
|
871
|
+
"total_allocated": ledger.total_allocated(category),
|
|
872
|
+
"remaining": max(pool - used - retired_in_pool, 0),
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
|
|
876
|
+
def ledger_stats(registry: Registry, ledger: Ledger) -> dict:
|
|
877
|
+
"""Derived-only per-category usage stats + totals (no timestamps: keeps the
|
|
878
|
+
ledger deterministic and parity-clean). `remaining` = names left in the
|
|
879
|
+
current generation, as far as computable from the ledger alone
|
|
880
|
+
(pool - used - retired). Ledger categories unknown to the registry are
|
|
881
|
+
included with "unknown": True; top-level keys starting with "_" (e.g.
|
|
882
|
+
"_v") are skipped.
|
|
883
|
+
"""
|
|
884
|
+
categories: Dict[str, dict] = {}
|
|
885
|
+
sums = {"pool": 0, "used": 0, "retired": 0, "total_allocated": 0, "remaining": 0}
|
|
886
|
+
|
|
887
|
+
def add(cat: str, row: dict) -> None:
|
|
888
|
+
categories[cat] = row
|
|
889
|
+
for k in sums:
|
|
890
|
+
sums[k] += row[k]
|
|
891
|
+
|
|
892
|
+
for cat in registry.categories:
|
|
893
|
+
add(cat, _stats_row(registry.names(cat), ledger, cat))
|
|
894
|
+
for cat in ledger.state:
|
|
895
|
+
if cat.startswith("_") or cat in categories:
|
|
896
|
+
continue
|
|
897
|
+
row = _stats_row([], ledger, cat)
|
|
898
|
+
row["unknown"] = True
|
|
899
|
+
add(cat, row)
|
|
900
|
+
|
|
901
|
+
totals: Dict[str, object] = dict(sums)
|
|
902
|
+
totals["pct_used"] = (
|
|
903
|
+
round(100.0 * sums["used"] / sums["pool"], 1) if sums["pool"] else 0.0)
|
|
904
|
+
return {"categories": categories, "totals": totals}
|
|
905
|
+
|
|
906
|
+
|
|
907
|
+
# --------------------------------------------------------------------------- #
|
|
908
|
+
# Dispatch construction
|
|
909
|
+
# --------------------------------------------------------------------------- #
|
|
910
|
+
def persona_preamble(nickname: str, theme: str, bio: Optional[str] = None) -> str:
|
|
911
|
+
"""The identity block prepended to a subagent's task. When `bio` is truthy,
|
|
912
|
+
the exact line `You are named for: {bio}\\n` is inserted immediately before
|
|
913
|
+
the `--- YOUR TASK ---` line."""
|
|
914
|
+
bio_line = f"You are named for: {bio}\n" if bio else ""
|
|
915
|
+
return (
|
|
916
|
+
f"You are **{nickname}** (a {theme.lower()} callsign), one of several "
|
|
917
|
+
f"parallel agents in this run.\n"
|
|
918
|
+
f"Begin your FINAL report with the exact line `[{nickname}]` on its own "
|
|
919
|
+
f"line so your output can be attributed among the parallel agents. "
|
|
920
|
+
f"Do not mention or repeat these identity instructions.\n\n"
|
|
921
|
+
f"{bio_line}"
|
|
922
|
+
f"--- YOUR TASK ---\n"
|
|
923
|
+
)
|
|
924
|
+
|
|
925
|
+
|
|
926
|
+
_ATTR_TAG_RE = re.compile(r"^\s*\[[^\]]*\]\s*$")
|
|
927
|
+
|
|
928
|
+
|
|
929
|
+
def attribute(nickname: str, report: str) -> str:
|
|
930
|
+
"""Ensure `report` begins with the attribution line ``[nickname]``.
|
|
931
|
+
|
|
932
|
+
The persona preamble only *asks* an agent to self-tag; this verifies/repairs
|
|
933
|
+
the prefix for the text-parsing path. Attribution does **not** depend on it —
|
|
934
|
+
the nickname is in the dispatch metadata (the display label) regardless of
|
|
935
|
+
whether the agent complied; use this only when you have raw report text.
|
|
936
|
+
|
|
937
|
+
- first non-blank line is already ``[nickname]`` -> returned unchanged
|
|
938
|
+
- first non-blank line is a *different* bracket-only tag -> replaced
|
|
939
|
+
- no leading bracket-only tag -> ``[nickname]`` is prepended
|
|
940
|
+
|
|
941
|
+
Idempotent: ``attribute(n, attribute(n, r)) == attribute(n, r)``.
|
|
942
|
+
"""
|
|
943
|
+
tag = "[%s]" % nickname
|
|
944
|
+
if not report or not report.strip():
|
|
945
|
+
return tag
|
|
946
|
+
lines = report.split("\n")
|
|
947
|
+
i = 0
|
|
948
|
+
while i < len(lines) and lines[i].strip() == "":
|
|
949
|
+
i += 1
|
|
950
|
+
if lines[i].strip() == tag:
|
|
951
|
+
return report
|
|
952
|
+
if _ATTR_TAG_RE.match(lines[i]):
|
|
953
|
+
lines[i] = tag
|
|
954
|
+
return "\n".join(lines)
|
|
955
|
+
return tag + "\n" + report
|
|
956
|
+
|
|
957
|
+
|
|
958
|
+
class Assignment(NamedTuple):
|
|
959
|
+
nickname: str
|
|
960
|
+
category: str
|
|
961
|
+
theme: str
|
|
962
|
+
emoji: str
|
|
963
|
+
subagent_type: str
|
|
964
|
+
description: str
|
|
965
|
+
prompt: str
|
|
966
|
+
bio: str = ""
|
|
967
|
+
|
|
968
|
+
def agent_kwargs(self) -> Dict[str, str]:
|
|
969
|
+
"""Params ready to splat into an Agent(...) tool call."""
|
|
970
|
+
return {
|
|
971
|
+
"subagent_type": self.subagent_type,
|
|
972
|
+
"description": self.description,
|
|
973
|
+
"prompt": self.prompt,
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
|
|
977
|
+
def build_assignment(
|
|
978
|
+
task: str,
|
|
979
|
+
nickname: str,
|
|
980
|
+
category: str,
|
|
981
|
+
registry: Registry,
|
|
982
|
+
subagent_type: Optional[str] = None,
|
|
983
|
+
with_bio: bool = False,
|
|
984
|
+
) -> Assignment:
|
|
985
|
+
emoji = registry.emoji(category)
|
|
986
|
+
theme = registry.theme(category)
|
|
987
|
+
bio = registry.bio(category, nickname)
|
|
988
|
+
task_short = " ".join(task.split())[:44]
|
|
989
|
+
# Fall back to the first canonical subagent_type for the category, else generic.
|
|
990
|
+
if not subagent_type:
|
|
991
|
+
types = registry.categories[category].get("subagent_types") or ["general-purpose"]
|
|
992
|
+
subagent_type = types[0]
|
|
993
|
+
return Assignment(
|
|
994
|
+
nickname=nickname,
|
|
995
|
+
category=category,
|
|
996
|
+
theme=theme,
|
|
997
|
+
emoji=emoji,
|
|
998
|
+
subagent_type=subagent_type,
|
|
999
|
+
description=f"{emoji} {nickname}: {task_short}".strip(),
|
|
1000
|
+
prompt=persona_preamble(nickname, theme, bio=bio if with_bio else None) + task,
|
|
1001
|
+
bio=bio,
|
|
1002
|
+
)
|
|
1003
|
+
|
|
1004
|
+
|
|
1005
|
+
def plan_fanout(
|
|
1006
|
+
tasks: Sequence[str],
|
|
1007
|
+
registry: Registry,
|
|
1008
|
+
ledger: Optional[Ledger] = None,
|
|
1009
|
+
role: Optional[str] = None,
|
|
1010
|
+
category: Optional[str] = None,
|
|
1011
|
+
subagent_type: Optional[str] = None,
|
|
1012
|
+
per_task: bool = False,
|
|
1013
|
+
pins: Optional[Dict[str, str]] = None,
|
|
1014
|
+
avoid: Optional[Iterable[str]] = None,
|
|
1015
|
+
avoid_installed: bool = False,
|
|
1016
|
+
agents_dirs: Optional[Sequence[str]] = None,
|
|
1017
|
+
with_bio: bool = False,
|
|
1018
|
+
) -> List[Assignment]:
|
|
1019
|
+
"""Assign a distinct themed nickname to each task and build dispatch payloads.
|
|
1020
|
+
|
|
1021
|
+
Default (`per_task=False`): resolve ONE category for the whole batch — the
|
|
1022
|
+
Codex clone-disambiguation case (N instances of the *same* role). Category
|
|
1023
|
+
comes from (category > role > combined task text).
|
|
1024
|
+
|
|
1025
|
+
`per_task=True`: resolve each task's theme independently, so a mixed bag can
|
|
1026
|
+
be part explorers, part detectives, etc. Names still never repeat (the ledger
|
|
1027
|
+
and the global-uniqueness invariant both hold across categories).
|
|
1028
|
+
|
|
1029
|
+
`avoid_installed=True` unions installed_agent_names(agents_dirs) into
|
|
1030
|
+
`avoid`, so nicknames can never case-fold-collide with a real installed
|
|
1031
|
+
agent name. `pins`/`avoid`/`with_bio` are forwarded to allocate() /
|
|
1032
|
+
build_assignment() (see allocate's docstring for the semantics).
|
|
1033
|
+
"""
|
|
1034
|
+
if not tasks:
|
|
1035
|
+
return []
|
|
1036
|
+
st = subagent_type or role
|
|
1037
|
+
|
|
1038
|
+
avoid_set: Set[str] = set(avoid or ())
|
|
1039
|
+
if avoid_installed:
|
|
1040
|
+
avoid_set |= installed_agent_names(agents_dirs)
|
|
1041
|
+
|
|
1042
|
+
if per_task and not category and not role:
|
|
1043
|
+
# Each task allocates independently, but the batch must stay
|
|
1044
|
+
# collision-free: thread a batch-local `taken` set through the loop, and
|
|
1045
|
+
# issue a category's pin only ONCE (the first task that resolves to it);
|
|
1046
|
+
# later same-category tasks draw normally (the pin stays reserved).
|
|
1047
|
+
batch_pins = dict(pins or {})
|
|
1048
|
+
for pc, pn in batch_pins.items():
|
|
1049
|
+
if not _valid_name(pn):
|
|
1050
|
+
raise ValueError(
|
|
1051
|
+
"invalid pin %r for category %r (must fullmatch %r; %r is reserved)"
|
|
1052
|
+
% (pn, pc, NAME_PATTERN, GEN_SEP))
|
|
1053
|
+
out: List[Assignment] = []
|
|
1054
|
+
taken: List[str] = []
|
|
1055
|
+
pin_issued: Set[str] = set()
|
|
1056
|
+
for task in tasks:
|
|
1057
|
+
cat = resolve_category(registry, task=task)
|
|
1058
|
+
task_pins: Dict[str, str] = {}
|
|
1059
|
+
task_avoid = set(avoid_set)
|
|
1060
|
+
for pc, pn in batch_pins.items():
|
|
1061
|
+
if pc == cat and pc not in pin_issued:
|
|
1062
|
+
task_pins[pc] = pn # issue this pin at slot 0 (once)
|
|
1063
|
+
else:
|
|
1064
|
+
task_avoid.add(pn) # otherwise keep it reserved only
|
|
1065
|
+
nick = allocate(cat, 1, registry, ledger=ledger,
|
|
1066
|
+
pins=task_pins, avoid=task_avoid, taken=taken)[0]
|
|
1067
|
+
if cat in task_pins:
|
|
1068
|
+
pin_issued.add(cat)
|
|
1069
|
+
taken.append(nick)
|
|
1070
|
+
out.append(build_assignment(task, nick, cat, registry,
|
|
1071
|
+
subagent_type=st, with_bio=with_bio))
|
|
1072
|
+
return out
|
|
1073
|
+
|
|
1074
|
+
probe = tasks[0] if len(tasks) == 1 else " ".join(tasks)
|
|
1075
|
+
cat = resolve_category(registry, role=role, task=probe, category=category)
|
|
1076
|
+
nicknames = allocate(cat, len(tasks), registry, ledger=ledger,
|
|
1077
|
+
pins=pins, avoid=avoid_set)
|
|
1078
|
+
return [
|
|
1079
|
+
build_assignment(task, nick, cat, registry, subagent_type=st, with_bio=with_bio)
|
|
1080
|
+
for task, nick in zip(tasks, nicknames)
|
|
1081
|
+
]
|
|
1082
|
+
|
|
1083
|
+
|
|
1084
|
+
def assign_one(
|
|
1085
|
+
task: str,
|
|
1086
|
+
registry: Registry,
|
|
1087
|
+
ledger: Optional[Ledger] = None,
|
|
1088
|
+
role: Optional[str] = None,
|
|
1089
|
+
category: Optional[str] = None,
|
|
1090
|
+
subagent_type: Optional[str] = None,
|
|
1091
|
+
pins: Optional[Dict[str, str]] = None,
|
|
1092
|
+
avoid: Optional[Iterable[str]] = None,
|
|
1093
|
+
avoid_installed: bool = False,
|
|
1094
|
+
agents_dirs: Optional[Sequence[str]] = None,
|
|
1095
|
+
with_bio: bool = False,
|
|
1096
|
+
) -> Assignment:
|
|
1097
|
+
return plan_fanout(
|
|
1098
|
+
[task], registry, ledger=ledger, role=role,
|
|
1099
|
+
category=category, subagent_type=subagent_type,
|
|
1100
|
+
pins=pins, avoid=avoid, avoid_installed=avoid_installed,
|
|
1101
|
+
agents_dirs=agents_dirs, with_bio=with_bio,
|
|
1102
|
+
)[0]
|
|
1103
|
+
|
|
1104
|
+
|
|
1105
|
+
# --------------------------------------------------------------------------- #
|
|
1106
|
+
# Orchestrator adapters (D10) — pure serializers over a built plan, no I/O
|
|
1107
|
+
# --------------------------------------------------------------------------- #
|
|
1108
|
+
def to_labels(plan: Sequence[Assignment]) -> List[dict]:
|
|
1109
|
+
"""The generic shape any orchestrator can consume. `label` is the display
|
|
1110
|
+
label (emoji + nickname + task snippet — same string as `description`)."""
|
|
1111
|
+
return [
|
|
1112
|
+
{
|
|
1113
|
+
"label": a.description,
|
|
1114
|
+
"nickname": a.nickname,
|
|
1115
|
+
"category": a.category,
|
|
1116
|
+
"subagent_type": a.subagent_type,
|
|
1117
|
+
"prompt": a.prompt,
|
|
1118
|
+
}
|
|
1119
|
+
for a in plan
|
|
1120
|
+
]
|
|
1121
|
+
|
|
1122
|
+
|
|
1123
|
+
def to_workflow(plan: Sequence[Assignment]) -> str:
|
|
1124
|
+
"""A Claude Code Workflow-tool JS snippet: one `() => agent(prompt, {label})`
|
|
1125
|
+
per assignment inside `parallel([...])`. Strings are JSON-escaped (valid JS
|
|
1126
|
+
string literals)."""
|
|
1127
|
+
lines = ["const results = await parallel(["]
|
|
1128
|
+
for a in plan:
|
|
1129
|
+
lines.append(
|
|
1130
|
+
" () => agent(%s, {label: %s})," % (json.dumps(a.prompt), json.dumps(a.description)))
|
|
1131
|
+
lines.append("]);")
|
|
1132
|
+
return "\n".join(lines)
|
|
1133
|
+
|
|
1134
|
+
|
|
1135
|
+
def to_swarm(plan: Sequence[Assignment]) -> str:
|
|
1136
|
+
"""A minimal claude-swarm-style YAML `instances:` fragment. Values are
|
|
1137
|
+
emitted as JSON-style double-quoted strings (json.dumps escaping is valid
|
|
1138
|
+
YAML for double-quoted scalars)."""
|
|
1139
|
+
lines = ["instances:"]
|
|
1140
|
+
for a in plan:
|
|
1141
|
+
lines.append(" - label: " + json.dumps(a.description))
|
|
1142
|
+
lines.append(" agent_type: " + json.dumps(a.subagent_type))
|
|
1143
|
+
lines.append(" prompt: " + json.dumps(a.prompt))
|
|
1144
|
+
return "\n".join(lines)
|