agent-bios 0.9.5 → 0.9.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/config/domains.json +1 -0
- package/config/promotions.json +3 -1
- package/package.json +2 -1
- package/scripts/build-promotions.py +29 -2
- package/scripts/check-domains.py +14 -0
- package/scripts/migrate-learnings.py +129 -12
- package/scripts/pkgid.py +45 -0
package/config/domains.json
CHANGED
package/config/promotions.json
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-bios",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.6",
|
|
4
4
|
"releaseDate": "2026-07-26",
|
|
5
5
|
"description": "A thin, low-level instruction layer for LLM CLI agents: one set of principles and behavior whichever model you run. Deploys into $HOME by copy via an explicit `agent-bios install`.",
|
|
6
6
|
"bin": {
|
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
"config/promotions.json",
|
|
23
23
|
"shell/agent-launch.zsh",
|
|
24
24
|
"scripts/agent-launch.py",
|
|
25
|
+
"scripts/pkgid.py",
|
|
25
26
|
"scripts/assemble.py",
|
|
26
27
|
"scripts/check-domains.py",
|
|
27
28
|
"scripts/canary.sh",
|
|
@@ -29,12 +29,15 @@ import json
|
|
|
29
29
|
import pathlib
|
|
30
30
|
import sys
|
|
31
31
|
|
|
32
|
+
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent))
|
|
33
|
+
import pkgid # noqa: E402 (sibling module; scripts/ is a flat toolbox)
|
|
34
|
+
|
|
32
35
|
REPO = pathlib.Path(__file__).resolve().parent.parent
|
|
33
36
|
LEDGER = REPO / "design" / "session-distill" / "ledger.json"
|
|
34
37
|
DOMAINS = REPO / "config" / "domains.json"
|
|
35
38
|
MANIFEST = REPO / "config" / "promotions.json"
|
|
36
39
|
FIXTURE = REPO / "design" / "collection-loop" / "fixtures" / "ledger-promote-sample.json"
|
|
37
|
-
MANIFEST_VERSION =
|
|
40
|
+
MANIFEST_VERSION = 2
|
|
38
41
|
|
|
39
42
|
|
|
40
43
|
class ManifestError(Exception):
|
|
@@ -80,7 +83,16 @@ def build_manifest(ledger, domains_manifest):
|
|
|
80
83
|
f"placed_anchor {anchor!r} (learning {lid}) does not resolve in "
|
|
81
84
|
"config/domains.json — the promotion has no verifiable placement")
|
|
82
85
|
tier, domains = aud
|
|
83
|
-
|
|
86
|
+
pid = pkgid.resolve(e, "placed_package_id")
|
|
87
|
+
if not pkgid.is_valid(pid):
|
|
88
|
+
raise ManifestError(
|
|
89
|
+
f"placed_package_id {pid!r} (learning {lid}) is not @scope/name")
|
|
90
|
+
# `anchor` ships alongside the derived audience because the consumer
|
|
91
|
+
# (migrate-learnings) must verify the bullet is REALLY in the user's
|
|
92
|
+
# deployed corpus before deleting their personal copy. Audience metadata
|
|
93
|
+
# alone cannot answer that — it is a build-time projection that drifts.
|
|
94
|
+
promotions.append({"learning_id": lid.lower(), "package_id": pid,
|
|
95
|
+
"anchor": anchor, "tier": tier, "domains": domains})
|
|
84
96
|
promotions.sort(key=lambda p: p["learning_id"])
|
|
85
97
|
return {"version": MANIFEST_VERSION, "promotions": promotions}
|
|
86
98
|
|
|
@@ -133,6 +145,21 @@ def _self_test():
|
|
|
133
145
|
checks.append(("unresolvable placed_anchor fails",
|
|
134
146
|
raises(lambda l: l["entries"][0].__setitem__("placed_anchor", "no-such-anchor"))))
|
|
135
147
|
|
|
148
|
+
# v2 identity + locator. Without these the manifest looks fine while the
|
|
149
|
+
# consumer that must verify placement has nothing to verify against.
|
|
150
|
+
checks.append(("manifest is v2", m["version"] == 2))
|
|
151
|
+
checks.append(("every promotion carries its anchor",
|
|
152
|
+
all(p.get("anchor") for p in m["promotions"])))
|
|
153
|
+
checks.append(("absent placed_package_id resolves to core",
|
|
154
|
+
all(p.get("package_id") == pkgid.CORE for p in m["promotions"])))
|
|
155
|
+
led2 = json.loads(FIXTURE.read_text(encoding="utf-8"))
|
|
156
|
+
led2["entries"][0]["placed_package_id"] = "@acme/security"
|
|
157
|
+
checks.append(("declared placed_package_id is carried through",
|
|
158
|
+
any(p["package_id"] == "@acme/security"
|
|
159
|
+
for p in build_manifest(led2, domains)["promotions"])))
|
|
160
|
+
checks.append(("malformed placed_package_id fails",
|
|
161
|
+
raises(lambda l: l["entries"][0].__setitem__("placed_package_id", "Acme/Sec"))))
|
|
162
|
+
|
|
136
163
|
failed = [n for n, ok in checks if not ok]
|
|
137
164
|
if failed:
|
|
138
165
|
for n in failed:
|
package/scripts/check-domains.py
CHANGED
|
@@ -32,6 +32,9 @@ import pathlib
|
|
|
32
32
|
import re
|
|
33
33
|
import sys
|
|
34
34
|
|
|
35
|
+
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent))
|
|
36
|
+
import pkgid # noqa: E402 (sibling module; scripts/ is a flat toolbox, not a package)
|
|
37
|
+
|
|
35
38
|
REPO = pathlib.Path(__file__).resolve().parent.parent
|
|
36
39
|
MANIFEST = REPO / "config" / "domains.json"
|
|
37
40
|
MONOLITH = REPO / "claude" / "CLAUDE.md"
|
|
@@ -87,6 +90,14 @@ def run_gate(manifest, bullets, repo=REPO):
|
|
|
87
90
|
if not tiers or not domains_reg:
|
|
88
91
|
errors.append("registry: tiers/domains registry empty")
|
|
89
92
|
|
|
93
|
+
# -- 0. package identity ------------------------------------------------
|
|
94
|
+
# Absent means core (the reservation that keeps pre-v2 artifacts valid), but
|
|
95
|
+
# a PRESENT malformed id must fail rather than fall back — silently treating
|
|
96
|
+
# a typo as core would hand it the engine's unconditional-injection audience.
|
|
97
|
+
pid = pkgid.resolve(manifest)
|
|
98
|
+
if not pkgid.is_valid(pid):
|
|
99
|
+
errors.append(f"package_id: {pid!r} is not @scope/name (lowercase, hyphen-separated)")
|
|
100
|
+
|
|
90
101
|
def check_registry(entry, ctx):
|
|
91
102
|
if entry.get("tier") not in tiers:
|
|
92
103
|
errors.append(f"{ctx}: tier {entry.get('tier')!r} not in registry")
|
|
@@ -205,6 +216,9 @@ def self_test(manifest, bullets):
|
|
|
205
216
|
muts.append((f"unclaimed guide {first_guide}", m4, bullets))
|
|
206
217
|
b5 = bullets + ["- a brand new bullet the manifest never heard of"]
|
|
207
218
|
muts.append(("bullet added without manifest entry", copy.deepcopy(manifest), b5))
|
|
219
|
+
m6 = copy.deepcopy(manifest)
|
|
220
|
+
m6["package_id"] = "@Acme/Builder" # uppercase is not a legal segment
|
|
221
|
+
muts.append(("malformed package_id", m6, bullets))
|
|
208
222
|
|
|
209
223
|
failed = []
|
|
210
224
|
for name, mm, bb in muts:
|
|
@@ -13,7 +13,15 @@ bundle. Per-domain opt-in means a promotion into a package the user did NOT
|
|
|
13
13
|
install must NOT trigger removal — that would silently lose the learning. When
|
|
14
14
|
unsure, KEEP (a kept duplicate is redundant; a wrong removal is data loss).
|
|
15
15
|
|
|
16
|
-
|
|
16
|
+
HOW THAT RULE IS ENFORCED (contract v2): audience metadata SELECTS candidates,
|
|
17
|
+
presence in the deployed corpus AUTHORIZES the delete. Metadata is a build-time
|
|
18
|
+
projection — it cannot see that this user runs a package or version whose bundle
|
|
19
|
+
never received the promoted bullet — so it is never the authority for an
|
|
20
|
+
irreversible act. Every uncertainty resolves to KEEP: a foreign package we
|
|
21
|
+
cannot confirm, an audience miss, an anchor absent from the corpus, a v1 record
|
|
22
|
+
with no anchor to verify.
|
|
23
|
+
|
|
24
|
+
Candidate selection (mirrors scripts/assemble.py `kept`): full install (original
|
|
17
25
|
single-zone / no selection) -> everything installed; universal tier (core/infra)
|
|
18
26
|
-> always installed; a domain key -> installed iff in the user's selection;
|
|
19
27
|
anything else (env-personal / unclassified / unknown) -> KEEP.
|
|
@@ -31,6 +39,9 @@ import shutil
|
|
|
31
39
|
import sys
|
|
32
40
|
import time
|
|
33
41
|
|
|
42
|
+
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent))
|
|
43
|
+
import pkgid # noqa: E402 (sibling module; scripts/ is a flat toolbox)
|
|
44
|
+
|
|
34
45
|
REPO = pathlib.Path(__file__).resolve().parent.parent
|
|
35
46
|
MANIFEST = REPO / "config" / "promotions.json"
|
|
36
47
|
UNIVERSAL_TIERS = frozenset({"core", "infra"}) # == assemble.audience UNIVERSAL
|
|
@@ -86,6 +97,59 @@ def make_in_bundle(full, selection):
|
|
|
86
97
|
return in_bundle
|
|
87
98
|
|
|
88
99
|
|
|
100
|
+
def corpus_surfaces(home):
|
|
101
|
+
"""Files and dirs that hold the DEPLOYED corpus for this host.
|
|
102
|
+
|
|
103
|
+
Deliberately excludes `personal/` — the user's own copy of a promoted
|
|
104
|
+
learning quotes the same lesson, so searching it would find the very thing
|
|
105
|
+
we are deciding whether to delete and always answer yes.
|
|
106
|
+
"""
|
|
107
|
+
texts = [home / "central" / "bundle.md", # packaged install
|
|
108
|
+
home / "CLAUDE.md", home / "AGENTS.md"] # full install (entry is the corpus)
|
|
109
|
+
dirs = [home / "central" / d for d in ("guides", "hooks", "agents")]
|
|
110
|
+
dirs += [home / d for d in ("guides", "hooks", "agents")]
|
|
111
|
+
return texts, dirs
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def corpus_text(path, collect=None):
|
|
115
|
+
"""A corpus file's text with the user's personal region removed.
|
|
116
|
+
|
|
117
|
+
On codex the personal copy lives INSIDE AGENTS.md, and a promoted bullet is
|
|
118
|
+
written FROM the user's lesson — so its anchor can legitimately appear in
|
|
119
|
+
their own bullet. Searching the region would let a personal copy authorize
|
|
120
|
+
its own deletion. Strip it before matching.
|
|
121
|
+
"""
|
|
122
|
+
body = path.read_text(encoding="utf-8", errors="replace")
|
|
123
|
+
if collect is None:
|
|
124
|
+
return body
|
|
125
|
+
start, end = getattr(collect, "PERSONAL_START", None), getattr(collect, "PERSONAL_END", None)
|
|
126
|
+
if start and end and start in body:
|
|
127
|
+
pre, rest = body.split(start, 1)
|
|
128
|
+
return pre + (rest.split(end, 1)[1] if end in rest else "")
|
|
129
|
+
return body
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def placed_here(home, anchor, collect=None):
|
|
133
|
+
"""Is the promoted item REALLY in this user's deployed corpus?
|
|
134
|
+
|
|
135
|
+
This is the authorization for an irreversible delete, so it asks the
|
|
136
|
+
filesystem rather than trusting build-time audience metadata, which is a
|
|
137
|
+
projection that goes stale the moment packages or versions diverge. A whole
|
|
138
|
+
guide/hook/agent placement is a deployed file; a bullet placement is its
|
|
139
|
+
anchor appearing in the deployed corpus text.
|
|
140
|
+
"""
|
|
141
|
+
if not isinstance(anchor, str) or not anchor:
|
|
142
|
+
return False
|
|
143
|
+
texts, dirs = corpus_surfaces(home)
|
|
144
|
+
for d in dirs:
|
|
145
|
+
if (d / anchor).is_file():
|
|
146
|
+
return True
|
|
147
|
+
for f in texts:
|
|
148
|
+
if f.is_file() and anchor in corpus_text(f, collect):
|
|
149
|
+
return True
|
|
150
|
+
return False
|
|
151
|
+
|
|
152
|
+
|
|
89
153
|
def backup(path):
|
|
90
154
|
shutil.copy2(path, path.with_suffix(path.suffix + f".bak-migrate-{time.strftime('%Y%m%d-%H%M%S')}"))
|
|
91
155
|
|
|
@@ -202,15 +266,29 @@ def migrate(home, host, promotions, in_bundle, collect, dry=False, corpus_loaded
|
|
|
202
266
|
if isinstance(lid, str):
|
|
203
267
|
local_ids.add(lid)
|
|
204
268
|
|
|
205
|
-
|
|
269
|
+
# Metadata SELECTS candidates; presence in the deployed corpus AUTHORIZES the
|
|
270
|
+
# delete (contract v2 §4). Audience metadata is a build-time projection: it
|
|
271
|
+
# cannot see that this user is on a package or version whose bundle never
|
|
272
|
+
# received the promoted bullet, and acting on it alone loses the learning.
|
|
273
|
+
# Every uncertainty below resolves to KEEP.
|
|
274
|
+
remove_ids, kept_not_in_bundle, kept_not_placed, kept_foreign_pkg = set(), 0, 0, 0
|
|
206
275
|
for p in promotions:
|
|
207
276
|
lid = p["learning_id"]
|
|
208
277
|
if lid not in local_ids:
|
|
209
278
|
continue # not held locally (never captured here, or already migrated)
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
279
|
+
pid = pkgid.resolve(p)
|
|
280
|
+
if pid != pkgid.CORE:
|
|
281
|
+
# Stage 3 resolves a package selection; until then the only package
|
|
282
|
+
# whose composition we can confirm is core.
|
|
283
|
+
kept_foreign_pkg += 1
|
|
284
|
+
continue
|
|
285
|
+
if not in_bundle(p.get("tier"), p.get("domains", [])):
|
|
286
|
+
kept_not_in_bundle += 1 # promoted but not in THIS user's audience
|
|
287
|
+
continue
|
|
288
|
+
if not placed_here(home, p.get("anchor"), collect):
|
|
289
|
+
kept_not_placed += 1 # audience says yes, the corpus does not have it
|
|
290
|
+
continue
|
|
291
|
+
remove_ids.add(lid)
|
|
214
292
|
|
|
215
293
|
# Prune PROSE first, jsonl second: the gate keys off ids still in the jsonl
|
|
216
294
|
# (line ~"if lid not in local_ids: continue"), so if a prose write fails, the
|
|
@@ -223,7 +301,8 @@ def migrate(home, host, promotions, in_bundle, collect, dry=False, corpus_loaded
|
|
|
223
301
|
jsonl_removed = prune_jsonl(jsonl, remove_ids, dry)
|
|
224
302
|
|
|
225
303
|
return {"removed": len(remove_ids), "jsonl_removed": jsonl_removed,
|
|
226
|
-
"prose_removed": prose_removed, "kept_not_in_bundle": kept_not_in_bundle
|
|
304
|
+
"prose_removed": prose_removed, "kept_not_in_bundle": kept_not_in_bundle,
|
|
305
|
+
"kept_not_placed": kept_not_placed, "kept_foreign_package": kept_foreign_pkg}
|
|
227
306
|
|
|
228
307
|
|
|
229
308
|
def main():
|
|
@@ -308,13 +387,21 @@ def _self_test():
|
|
|
308
387
|
L = {"core": "0f8c1c2a-4d1e-4abc-9def-0000000000a1",
|
|
309
388
|
"bb": "0f8c1c2a-4d1e-4abc-9def-0000000000b2",
|
|
310
389
|
"off": "0f8c1c2a-4d1e-4abc-9def-0000000000c3"}
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
390
|
+
A = {"core": "universal corpus rule", "bb": "builder corpus rule",
|
|
391
|
+
"off": "office corpus rule"}
|
|
392
|
+
promos = [{"learning_id": L["core"], "anchor": A["core"], "tier": "core", "domains": []},
|
|
393
|
+
{"learning_id": L["bb"], "anchor": A["bb"], "tier": "domain", "domains": ["builder-base"]},
|
|
394
|
+
{"learning_id": L["off"], "anchor": A["off"], "tier": "domain", "domains": ["office-work"]}]
|
|
395
|
+
|
|
396
|
+
def seed_claude(placed=("core", "bb", "off")):
|
|
397
|
+
"""`placed` = which promoted anchors this user's DEPLOYED corpus actually
|
|
398
|
+
carries. Deletion is authorized by that, not by the audience metadata, so
|
|
399
|
+
a fixture without a corpus would let a metadata-only bug pass."""
|
|
316
400
|
home = pathlib.Path(tempfile.mkdtemp(prefix="migrate-selftest-"))
|
|
317
401
|
(home / "personal").mkdir(parents=True)
|
|
402
|
+
(home / "central").mkdir(parents=True)
|
|
403
|
+
(home / "central" / "bundle.md").write_text(
|
|
404
|
+
"# bundle\n" + "".join(f"- {A[k]}\n" for k in placed), encoding="utf-8")
|
|
318
405
|
jsonl = home / "personal" / "learnings.jsonl"
|
|
319
406
|
with open(jsonl, "w", encoding="utf-8") as f:
|
|
320
407
|
for k, dom in (("core", "core"), ("bb", "builder-base"), ("off", "office-work")):
|
|
@@ -350,6 +437,35 @@ def _self_test():
|
|
|
350
437
|
s2 = migrate(home, "claude", promos, in_bundle, collect)
|
|
351
438
|
checks.append(("idempotent re-run", s2["removed"] == 0))
|
|
352
439
|
|
|
440
|
+
# 1b) CONTRAST CONTROL for the v2 authorization. Same audience metadata as
|
|
441
|
+
# above — builder-base IS selected — but this user's deployed corpus does
|
|
442
|
+
# NOT carry the bullet (a package/version whose bundle never got it). The
|
|
443
|
+
# metadata-only rule deletes here and loses the learning; presence keeps.
|
|
444
|
+
# If placed_here() ever returns True unconditionally, this check fails.
|
|
445
|
+
home = seed_claude(placed=("core",))
|
|
446
|
+
s = migrate(home, "claude", promos, make_in_bundle(False, ["builder-base"]), collect)
|
|
447
|
+
checks.append(("audience says yes but corpus lacks it -> KEEP",
|
|
448
|
+
s["removed"] == 1 and s["kept_not_placed"] == 1
|
|
449
|
+
and L["bb"] in local_ids(home) and md_has(home, L["bb"])))
|
|
450
|
+
|
|
451
|
+
# 1c) A promotion from a package whose composition cannot be confirmed is
|
|
452
|
+
# never acted on (stage 3 resolves package selections).
|
|
453
|
+
home = seed_claude()
|
|
454
|
+
foreign = [dict(promos[1], package_id="@acme/security")]
|
|
455
|
+
s = migrate(home, "claude", foreign, make_in_bundle(True, ()), collect)
|
|
456
|
+
checks.append(("foreign package -> KEEP",
|
|
457
|
+
s["removed"] == 0 and s["kept_foreign_package"] == 1))
|
|
458
|
+
|
|
459
|
+
# 1d) The personal copy must not authorize its own deletion: on codex the
|
|
460
|
+
# copy lives inside AGENTS.md, so the anchor can appear there legitimately.
|
|
461
|
+
ahome = pathlib.Path(tempfile.mkdtemp(prefix="migrate-selfauth-"))
|
|
462
|
+
(ahome / "AGENTS.md").write_text(
|
|
463
|
+
f"# AGENTS.md\n{collect.PERSONAL_START}\n- {A['bb']}\n{collect.PERSONAL_END}\n",
|
|
464
|
+
encoding="utf-8")
|
|
465
|
+
checks.append(("personal region cannot authorize its own delete",
|
|
466
|
+
placed_here(ahome, A["bb"], collect) is False
|
|
467
|
+
and placed_here(ahome, A["bb"], None) is True))
|
|
468
|
+
|
|
353
469
|
# 2) Full (non-packaged) install: everything in bundle -> all removed.
|
|
354
470
|
home = seed_claude()
|
|
355
471
|
s = migrate(home, "claude", promos, make_in_bundle(True, ()), collect)
|
|
@@ -365,6 +481,7 @@ def _self_test():
|
|
|
365
481
|
# 4) codex host: seed the AGENTS.md region, migrate prunes it there.
|
|
366
482
|
chome = pathlib.Path(tempfile.mkdtemp(prefix="migrate-codex-"))
|
|
367
483
|
(chome / "personal").mkdir(parents=True)
|
|
484
|
+
(chome / "AGENTS.md").write_text(f"# AGENTS.md\n- {A['bb']}\n", encoding="utf-8")
|
|
368
485
|
with open(chome / "personal" / "learnings.jsonl", "w", encoding="utf-8") as f:
|
|
369
486
|
r = rec(L["bb"], "builder-base")
|
|
370
487
|
f.write(json.dumps(r, ensure_ascii=False) + "\n")
|
package/scripts/pkgid.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Canonical package identity for the domain-package ecosystem (contract v2).
|
|
3
|
+
|
|
4
|
+
A package is `@scope/name` — **unversioned**, and independent of the domains it
|
|
5
|
+
declares. A domain's canonical identity is the pair `(package_id, domain)`, so a
|
|
6
|
+
bare domain string is only meaningful relative to a package.
|
|
7
|
+
|
|
8
|
+
`CORE` is the built-in engine manifest. A manifest or record that carries no
|
|
9
|
+
package id means `CORE`, permanently: that reservation is what keeps every
|
|
10
|
+
artifact written before v2 — selections, promotions, ledger placements — valid
|
|
11
|
+
with no migration.
|
|
12
|
+
|
|
13
|
+
Spec: design/adapter-split/ECOSYSTEM-ARCHITECTURE.md, "Foundational contract v2".
|
|
14
|
+
"""
|
|
15
|
+
import re
|
|
16
|
+
|
|
17
|
+
CORE = "@agent-bios/core"
|
|
18
|
+
|
|
19
|
+
_SEG = r"[a-z0-9]+(?:-[a-z0-9]+)*"
|
|
20
|
+
PATTERN = re.compile(rf"^@{_SEG}/{_SEG}$")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def is_valid(pid):
|
|
24
|
+
"""True for a well-formed package id. Callers validate before deriving paths."""
|
|
25
|
+
return isinstance(pid, str) and PATTERN.match(pid) is not None
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def resolve(obj, key="package_id"):
|
|
29
|
+
"""The package id an object declares, or CORE when it declares none.
|
|
30
|
+
|
|
31
|
+
Absent means CORE — never guess from context, and never treat a present but
|
|
32
|
+
malformed id as absent: that would silently promote a typo to core's
|
|
33
|
+
authority. Validate with is_valid() where the value is first accepted.
|
|
34
|
+
"""
|
|
35
|
+
if not isinstance(obj, dict):
|
|
36
|
+
return CORE
|
|
37
|
+
pid = obj.get(key)
|
|
38
|
+
return CORE if pid is None else pid
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def segments(pid):
|
|
42
|
+
"""('scope', 'name') for deriving deploy paths. Only call on a valid id."""
|
|
43
|
+
if not is_valid(pid):
|
|
44
|
+
raise ValueError(f"not a package id: {pid!r}")
|
|
45
|
+
return tuple(pid[1:].split("/", 1))
|