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.
@@ -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)