ergane-cli 0.1.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.
Files changed (121) hide show
  1. ergane_cli-0.1.0.dist-info/METADATA +148 -0
  2. ergane_cli-0.1.0.dist-info/RECORD +121 -0
  3. ergane_cli-0.1.0.dist-info/WHEEL +4 -0
  4. ergane_cli-0.1.0.dist-info/entry_points.txt +2 -0
  5. ergane_cli-0.1.0.dist-info/licenses/LICENSE +202 -0
  6. ergane_cli-0.1.0.dist-info/licenses/NOTICE +27 -0
  7. factory/__init__.py +0 -0
  8. factory/activities/__init__.py +0 -0
  9. factory/activities/agent_activities.py +745 -0
  10. factory/activities/merge_activities.py +654 -0
  11. factory/activities/notify_activities.py +1078 -0
  12. factory/activities/roadmap_activities.py +577 -0
  13. factory/activities/usage_activities.py +593 -0
  14. factory/activities/verify_activities.py +617 -0
  15. factory/cli/__init__.py +0 -0
  16. factory/cli/completion.py +150 -0
  17. factory/cli/doctor.py +484 -0
  18. factory/cli/env.py +247 -0
  19. factory/cli/errors.py +77 -0
  20. factory/cli/init.py +1057 -0
  21. factory/cli/install.py +679 -0
  22. factory/cli/main.py +212 -0
  23. factory/cli/nouns/__init__.py +59 -0
  24. factory/cli/nouns/answer.py +134 -0
  25. factory/cli/nouns/build.py +1222 -0
  26. factory/cli/nouns/completion.py +13 -0
  27. factory/cli/nouns/doctor.py +13 -0
  28. factory/cli/nouns/env.py +13 -0
  29. factory/cli/nouns/escalations.py +93 -0
  30. factory/cli/nouns/findings.py +13 -0
  31. factory/cli/nouns/init.py +13 -0
  32. factory/cli/nouns/install.py +99 -0
  33. factory/cli/nouns/repo.py +13 -0
  34. factory/cli/nouns/roadmap.py +13 -0
  35. factory/cli/nouns/spec.py +511 -0
  36. factory/cli/nouns/status.py +18 -0
  37. factory/cli/nouns/usage.py +13 -0
  38. factory/cli/nouns/worker.py +69 -0
  39. factory/cli/repo.py +569 -0
  40. factory/cli/repo_export.py +282 -0
  41. factory/cli/roadmap.py +434 -0
  42. factory/cli/status.py +844 -0
  43. factory/cli/usage.py +129 -0
  44. factory/config.py +262 -0
  45. factory/controlplane/__init__.py +0 -0
  46. factory/controlplane/config.py +863 -0
  47. factory/controlplane/resolve.py +434 -0
  48. factory/controlplane/verify.py +827 -0
  49. factory/discovery/__init__.py +3 -0
  50. factory/discovery/llm_scanner.py +176 -0
  51. factory/doctor/__init__.py +0 -0
  52. factory/doctor/cli.py +392 -0
  53. factory/doctor/models.py +165 -0
  54. factory/doctor/probes.py +522 -0
  55. factory/doctor/scaffold.py +184 -0
  56. factory/doctor/store.py +449 -0
  57. factory/env.py +131 -0
  58. factory/escalation/__init__.py +0 -0
  59. factory/escalation/client.py +168 -0
  60. factory/escalation/question.py +161 -0
  61. factory/escalation/workflow.py +470 -0
  62. factory/locking.py +85 -0
  63. factory/mergequeue/__init__.py +21 -0
  64. factory/mergequeue/classify.py +133 -0
  65. factory/mergequeue/forge.py +401 -0
  66. factory/mergequeue/gh.py +484 -0
  67. factory/mergequeue/github_forge.py +349 -0
  68. factory/mergequeue/merge_queue_ruleset.json +32 -0
  69. factory/mergequeue/messages.py +141 -0
  70. factory/mergequeue/models.py +324 -0
  71. factory/mergequeue/onboard.py +537 -0
  72. factory/mergequeue/wiring.py +466 -0
  73. factory/notify/__init__.py +0 -0
  74. factory/notify/adapter.py +263 -0
  75. factory/notify/messages.py +474 -0
  76. factory/notify/service.py +809 -0
  77. factory/notify/webhook.py +151 -0
  78. factory/personas.yaml +161 -0
  79. factory/registry.py +583 -0
  80. factory/roadmap/__init__.py +8 -0
  81. factory/roadmap/cli.py +125 -0
  82. factory/roadmap/discovery.py +286 -0
  83. factory/roadmap/models.py +583 -0
  84. factory/roadmap/schedule.py +377 -0
  85. factory/roadmap/workflow.py +1364 -0
  86. factory/supervision/__init__.py +0 -0
  87. factory/supervision/alert.py +239 -0
  88. factory/supervision/probe.py +467 -0
  89. factory/supervision/temporal_server.py +121 -0
  90. factory/supervision/units.py +649 -0
  91. factory/usage/__init__.py +0 -0
  92. factory/usage/aggregate.py +113 -0
  93. factory/usage/cli.py +223 -0
  94. factory/usage/ledger.py +295 -0
  95. factory/usage/litellm_client.py +486 -0
  96. factory/usage/models.py +149 -0
  97. factory/verify/__init__.py +0 -0
  98. factory/verify/criteria.py +488 -0
  99. factory/verify/diffbounds.py +159 -0
  100. factory/verify/diffcheck.py +506 -0
  101. factory/verify/factory_yaml.py +842 -0
  102. factory/verify/gates.py +1341 -0
  103. factory/verify/judge.py +775 -0
  104. factory/verify/ladder.py +145 -0
  105. factory/verify/models.py +745 -0
  106. factory/verify/question.py +167 -0
  107. factory/verify/store.py +1366 -0
  108. factory/verify/toolchain.py +370 -0
  109. factory/worker.py +296 -0
  110. factory/workgraph/__init__.py +0 -0
  111. factory/workgraph/adapter.py +1333 -0
  112. factory/workgraph/cli.py +801 -0
  113. factory/workgraph/delta.py +278 -0
  114. factory/workgraph/derive.py +607 -0
  115. factory/workgraph/detector.py +509 -0
  116. factory/workgraph/landed.py +496 -0
  117. factory/workgraph/models.py +494 -0
  118. factory/workgraph/preflight.py +651 -0
  119. factory/workgraph/prompt.py +796 -0
  120. factory/workgraph/workflow.py +2733 -0
  121. factory/workgraph/worktree.py +1322 -0
factory/registry.py ADDED
@@ -0,0 +1,583 @@
1
+ """The engine's registry of managed repositories (034 US2, FR-006 … FR-008).
2
+
3
+ A committed `ergane.yaml` is what makes a repository managed. That fact lives
4
+ in the repo and travels with it — but committed facts cannot be *enumerated*
5
+ without an index of where the repos are, and every fleet-level surface (the
6
+ scheduler, `repo list`, per-repo ledger and findings dimensions) needs to
7
+ enumerate. This module is that index, and nothing more:
8
+
9
+ - It lives under the engine's state home, outside every repository, so joining
10
+ a repo never changes what the repo *is*.
11
+ - It is a **cache**. `rebuild()` re-derives it from the committed manifests it
12
+ is pointed at, prunes entries whose repos are gone, and leaves live entries
13
+ alone. Losing the file is an inconvenience, not amnesia — which is the whole
14
+ defence against a derived index quietly becoming a second authority.
15
+ - The one thing it holds that no repo holds is the **slug**: the token woven
16
+ into workflow IDs, ledger rows, findings and the memory-bank identity. Slugs
17
+ are unique across the registry (FR-007) because two repos sharing one would
18
+ alias each other's workflows. A rebuild seeded from a bare path therefore
19
+ proposes the directory name, which is what the interview defaults to; a repo
20
+ that declared something else is re-declared by re-running `ergane init`.
21
+
22
+ **Locking (FR-008).** Every mutation runs inside `factory.locking`'s exclusive
23
+ lock — the same lock `ergane install` takes over the control-plane config, for
24
+ the same reason and by the same mechanism, rather than a second convention one
25
+ epic later. Two properties of that helper matter here and are easy to lose if
26
+ this is ever rewritten:
27
+
28
+ 1. The lock is a *sibling* `repos.json.lock`, not the registry file itself. So
29
+ the write below can replace `repos.json` atomically while the lock is held —
30
+ locking the target directly would leave the holder guarding an inode nobody
31
+ reads any more the instant the rename lands.
32
+ 2. `flock` is released by the kernel when the holder dies, so a killed `ergane
33
+ init` never leaves a stale lock for an operator to clear by hand.
34
+
35
+ Writes go through a temporary file and `os.replace`, so a reader either sees the
36
+ whole previous registry or the whole next one. The rendering is canonical
37
+ (sorted keys, two-space indent), which is what lets SC-005 compare bytes.
38
+ """
39
+
40
+ from __future__ import annotations
41
+
42
+ import dataclasses
43
+ import json
44
+ import os
45
+ import re
46
+ import unicodedata
47
+ from pathlib import Path
48
+ from typing import Any, Iterable, Mapping
49
+
50
+ from factory.controlplane.config import (
51
+ ControlPlaneConfigError,
52
+ load_controlplane_config,
53
+ )
54
+ from factory.env import (
55
+ ERGANE_STATE_HOME_ENV,
56
+ FACTORY_STATE_HOME_ENV,
57
+ resolve_env_path,
58
+ )
59
+ from factory.locking import exclusive_lock
60
+ from factory.verify.factory_yaml import (
61
+ MANIFEST_NAME,
62
+ FactoryConfigError,
63
+ load_factory_config,
64
+ resolve_manifest_path,
65
+ )
66
+
67
+ #: Where the registry sits under the state home.
68
+ DEFAULT_REGISTRY_REL = Path("ergane") / "repos.json"
69
+
70
+ #: The registry document's schema version.
71
+ REGISTRY_VERSION = 1
72
+
73
+ #: Manifest statuses `repo list` renders (FR-011). Drift is reported, never
74
+ #: hidden by dropping the entry.
75
+ MANIFEST_VALID = "valid"
76
+ MANIFEST_INVALID = "invalid"
77
+ MANIFEST_MISSING = "missing"
78
+
79
+ #: How long a mutation waits for another writer before refusing.
80
+ DEFAULT_LOCK_TIMEOUT_S = 30.0
81
+
82
+ #: The memory bank identity is derived from the slug, never declared separately:
83
+ #: two names for one repo is how a bank and a ledger start disagreeing.
84
+ MEMORY_BANK_PREFIX = "ergane-"
85
+
86
+ #: What a slug may be. It ends up in workflow IDs and search attributes, so it
87
+ #: is deliberately narrow: lowercase, starting alphanumeric, no spaces.
88
+ _SLUG_RE = re.compile(r"^[a-z0-9][a-z0-9._-]*$")
89
+
90
+
91
+ # ---------------------------------------------------------------------------
92
+ # Refusals
93
+ # ---------------------------------------------------------------------------
94
+
95
+
96
+ class RegistryError(Exception):
97
+ """Something an operator can fix, raised by the registry rather than the CLI.
98
+
99
+ The registry is a library: the CLI boundary turns these into `OperatorError`
100
+ so that `ergane init` and `ergane repo …` render one refusal grammar.
101
+ """
102
+
103
+
104
+ class SlugCollision(RegistryError):
105
+ """Two repos claimed one slug; the holder is named so the loser can move."""
106
+
107
+ def __init__(self, slug: str, holder: Path, *, claimant: Path | None = None) -> None:
108
+ self.slug = slug
109
+ self.holder = Path(holder)
110
+ self.claimant = Path(claimant) if claimant is not None else None
111
+ message = (
112
+ f"slug '{slug}' is already registered to {self.holder}; "
113
+ "slugs are unique because they name the repo in every workflow ID, "
114
+ "so declare a different slug for this repository"
115
+ )
116
+ super().__init__(message)
117
+
118
+
119
+ class RegistryCorrupt(RegistryError):
120
+ """The cache cannot be read, and rebuilding it is the safe answer."""
121
+
122
+ def __init__(self, path: Path, problem: str) -> None:
123
+ self.path = Path(path)
124
+ self.problem = problem
125
+ super().__init__(
126
+ f"{self.path} is not a readable registry ({problem}); it is a cache — "
127
+ "re-derive it with `ergane repo rebuild <repo path> …`"
128
+ )
129
+
130
+
131
+ class NotAManagedRepo(RegistryError):
132
+ """A path offered to `rebuild` carries no committed manifest."""
133
+
134
+ def __init__(self, path: Path) -> None:
135
+ self.path = Path(path)
136
+ super().__init__(
137
+ f"{self.path} declares no {MANIFEST_NAME}; the committed manifest is "
138
+ "what makes a repository managed — run `ergane init` there first"
139
+ )
140
+
141
+
142
+ class InvalidSlug(RegistryError):
143
+ """A slug that cannot be woven into a workflow ID, and what to use instead."""
144
+
145
+ def __init__(self, slug: str) -> None:
146
+ self.slug = slug
147
+ self.proposal = normalize_slug(slug)
148
+ super().__init__(
149
+ f"slug {slug!r} is not usable as a namespace token; "
150
+ f"use {self.proposal!r} (lowercase, no spaces, starting alphanumeric)"
151
+ )
152
+
153
+
154
+ # ---------------------------------------------------------------------------
155
+ # Where the registry lives
156
+ # ---------------------------------------------------------------------------
157
+
158
+
159
+ def resolve_state_home() -> Path:
160
+ """Return the engine's state home: env override, then XDG, then `~`.
161
+
162
+ Follows the 040 rename convention through `resolve_env_path` — the modern
163
+ `ERGANE_STATE_HOME` wins, the legacy `FACTORY_STATE_HOME` is honored with one
164
+ deprecation per process — rather than reading `os.environ` directly, so this
165
+ resolver and 033's config resolver cannot drift apart (034 plan, trap 4).
166
+ """
167
+ return resolve_env_path(
168
+ ERGANE_STATE_HOME_ENV,
169
+ FACTORY_STATE_HOME_ENV,
170
+ _xdg_state_home(),
171
+ )
172
+
173
+
174
+ def _xdg_state_home() -> Path:
175
+ xdg = os.environ.get("XDG_STATE_HOME")
176
+ if xdg:
177
+ return Path(xdg)
178
+ return Path.home() / ".local" / "state"
179
+
180
+
181
+ def resolve_registry_path() -> Path:
182
+ """Return the registry file's path under the state home."""
183
+ return resolve_state_home() / DEFAULT_REGISTRY_REL
184
+
185
+
186
+ # ---------------------------------------------------------------------------
187
+ # Slugs
188
+ # ---------------------------------------------------------------------------
189
+
190
+
191
+ def is_valid_slug(slug: str) -> bool:
192
+ """True when `slug` is usable as a namespace token."""
193
+ return bool(_SLUG_RE.match(slug))
194
+
195
+
196
+ def normalize_slug(text: str) -> str:
197
+ """Propose a valid slug for `text` — a directory name, usually.
198
+
199
+ Proposed, never applied silently: D-009 governs the interview, so the
200
+ operator confirms this the same way they confirm every other declaration.
201
+ """
202
+ folded = unicodedata.normalize("NFKD", text)
203
+ ascii_only = folded.encode("ascii", "ignore").decode("ascii").lower()
204
+ cleaned = re.sub(r"[^a-z0-9._-]+", "-", ascii_only)
205
+ cleaned = re.sub(r"-{2,}", "-", cleaned).strip("-._")
206
+ return cleaned or "repo"
207
+
208
+
209
+ def derive_memory_bank(slug: str) -> str:
210
+ """The repo's memory-bank identity, derived from its slug (FR-006, AS4)."""
211
+ return f"{MEMORY_BANK_PREFIX}{slug}"
212
+
213
+
214
+ def declared_memory_backend() -> str | None:
215
+ """The control plane's memory backend, or None when none is declared.
216
+
217
+ Read through 033's typed control-plane config rather than a second TOML
218
+ reader. A host with no control plane installed — or one whose config the
219
+ parser refuses — simply declares no backend here; registering a repo must
220
+ not depend on the engine being fully provisioned (FR-017's spirit).
221
+ """
222
+ try:
223
+ config = load_controlplane_config()
224
+ except ControlPlaneConfigError:
225
+ return None
226
+ backend = config.memory.backend
227
+ return None if backend == "none" else backend
228
+
229
+
230
+ def derive_scopes(slug: str) -> dict[str, str]:
231
+ """Scopes recorded on a registry entry, all derived from the slug."""
232
+ backend = declared_memory_backend()
233
+ if backend is None:
234
+ return {}
235
+ return {"memory_backend": backend, "memory_bank": derive_memory_bank(slug)}
236
+
237
+
238
+ # ---------------------------------------------------------------------------
239
+ # The entries
240
+ # ---------------------------------------------------------------------------
241
+
242
+
243
+ @dataclasses.dataclass(frozen=True)
244
+ class RegistryEntry:
245
+ """One managed repo: its slug, where it is, and what was derived from both."""
246
+
247
+ slug: str
248
+ path: Path
249
+ manifest: Path
250
+ scopes: Mapping[str, str] = dataclasses.field(default_factory=dict)
251
+
252
+
253
+ @dataclasses.dataclass(frozen=True)
254
+ class Registry:
255
+ """A snapshot of the cache, read at one moment."""
256
+
257
+ path: Path
258
+ entries: tuple[RegistryEntry, ...]
259
+
260
+ def get(self, slug: str) -> RegistryEntry | None:
261
+ for entry in self.entries:
262
+ if entry.slug == slug:
263
+ return entry
264
+ return None
265
+
266
+ def for_path(self, repo_path: str | Path) -> RegistryEntry | None:
267
+ target = Path(repo_path).resolve()
268
+ for entry in self.entries:
269
+ if entry.path == target:
270
+ return entry
271
+ return None
272
+
273
+
274
+ @dataclasses.dataclass(frozen=True)
275
+ class Registration:
276
+ """What `register` did, so the caller can report it honestly."""
277
+
278
+ entry: RegistryEntry
279
+ changed: bool
280
+ previous_slug: str | None = None
281
+
282
+
283
+ @dataclasses.dataclass(frozen=True)
284
+ class RebuildResult:
285
+ """What `rebuild` did: what it took in, what it dropped, what remains."""
286
+
287
+ adopted: tuple[RegistryEntry, ...]
288
+ pruned: tuple[tuple[str, Path], ...]
289
+ kept: tuple[RegistryEntry, ...]
290
+ recovered: bool = False
291
+
292
+
293
+ # ---------------------------------------------------------------------------
294
+ # Reading and writing the document
295
+ # ---------------------------------------------------------------------------
296
+
297
+
298
+ def _blank_document() -> dict[str, Any]:
299
+ return {"version": REGISTRY_VERSION, "repos": {}}
300
+
301
+
302
+ def _read_document(path: Path) -> dict[str, Any]:
303
+ """Return the registry document at `path`, or a blank one when absent."""
304
+ try:
305
+ raw = path.read_text(encoding="utf-8")
306
+ except FileNotFoundError:
307
+ return _blank_document()
308
+ except OSError as error:
309
+ raise RegistryCorrupt(path, error.strerror or str(error)) from None
310
+ except UnicodeDecodeError as error:
311
+ raise RegistryCorrupt(path, f"is not valid UTF-8 ({error.reason})") from None
312
+
313
+ if not raw.strip():
314
+ return _blank_document()
315
+
316
+ try:
317
+ document = json.loads(raw)
318
+ except json.JSONDecodeError as error:
319
+ raise RegistryCorrupt(path, f"is not parseable JSON: {error}") from None
320
+
321
+ if not isinstance(document, dict) or not isinstance(document.get("repos"), dict):
322
+ raise RegistryCorrupt(path, "does not hold a `repos` mapping")
323
+ return document
324
+
325
+
326
+ def _entries_of(document: Mapping[str, Any], path: Path) -> tuple[RegistryEntry, ...]:
327
+ entries: list[RegistryEntry] = []
328
+ for slug in sorted(document["repos"]):
329
+ record = document["repos"][slug]
330
+ if not isinstance(record, Mapping) or "path" not in record:
331
+ raise RegistryCorrupt(path, f"entry {slug!r} declares no `path`")
332
+ repo_path = Path(str(record["path"]))
333
+ manifest = Path(str(record.get("manifest") or (repo_path / MANIFEST_NAME)))
334
+ scopes = record.get("scopes") or {}
335
+ if not isinstance(scopes, Mapping):
336
+ raise RegistryCorrupt(path, f"entry {slug!r} has non-mapping `scopes`")
337
+ entries.append(
338
+ RegistryEntry(
339
+ slug=slug,
340
+ path=repo_path,
341
+ manifest=manifest,
342
+ scopes={str(key): str(value) for key, value in scopes.items()},
343
+ )
344
+ )
345
+ return tuple(entries)
346
+
347
+
348
+ def render_registry(document: Mapping[str, Any]) -> str:
349
+ """Render the document canonically, so two equal registries are equal bytes."""
350
+ return json.dumps(document, indent=2, sort_keys=True) + "\n"
351
+
352
+
353
+ def _write_document(path: Path, document: Mapping[str, Any]) -> None:
354
+ """Replace the registry atomically; a reader never sees a half-written file."""
355
+ path.parent.mkdir(parents=True, exist_ok=True)
356
+ temporary = path.with_name(path.name + ".tmp")
357
+ temporary.write_text(render_registry(document), encoding="utf-8")
358
+ temporary.chmod(0o600)
359
+ os.replace(temporary, path)
360
+
361
+
362
+ def _record_for(repo: Path, scopes: Mapping[str, str]) -> dict[str, Any]:
363
+ manifest, _ = resolve_manifest_path(repo)
364
+ record: dict[str, Any] = {"path": str(repo), "manifest": str(manifest)}
365
+ if scopes:
366
+ record["scopes"] = dict(scopes)
367
+ return record
368
+
369
+
370
+ # ---------------------------------------------------------------------------
371
+ # The operations
372
+ # ---------------------------------------------------------------------------
373
+
374
+
375
+ def load_registry(path: str | Path | None = None) -> Registry:
376
+ """Read the registry. An absent file is an empty registry, not an error."""
377
+ registry_path = Path(path) if path is not None else resolve_registry_path()
378
+ document = _read_document(registry_path)
379
+ return Registry(path=registry_path, entries=_entries_of(document, registry_path))
380
+
381
+
382
+ def manifest_status(entry: RegistryEntry) -> str:
383
+ """Judge the entry's manifest *now*, against the repo rather than the cache.
384
+
385
+ The committed manifest is the authority, so the status is resolved live: a
386
+ repo that has since renamed `factory.yaml` to `ergane.yaml` reads valid, and
387
+ one whose manifest was deleted reads `missing` rather than vanishing from the
388
+ listing (AS5).
389
+ """
390
+ manifest, _ = resolve_manifest_path(entry.path)
391
+ if not manifest.is_file():
392
+ return MANIFEST_MISSING
393
+ try:
394
+ load_factory_config(manifest)
395
+ except FactoryConfigError:
396
+ return MANIFEST_INVALID
397
+ return MANIFEST_VALID
398
+
399
+
400
+ def register(
401
+ slug: str,
402
+ repo_path: str | Path,
403
+ *,
404
+ path: str | Path | None = None,
405
+ timeout_s: float = DEFAULT_LOCK_TIMEOUT_S,
406
+ scopes: Mapping[str, str] | None = None,
407
+ ) -> Registration:
408
+ """Record `repo_path` under `slug`, refusing a collision (FR-007, FR-008).
409
+
410
+ Idempotent for the repo that already holds the slug: re-running `ergane init`
411
+ with unchanged answers leaves the file byte-identical (SC-003). A repo
412
+ re-declared under a *new* slug moves — one repo has one slug, or the two
413
+ entries alias each other in every ID the slug appears in.
414
+ """
415
+ registry_path = Path(path) if path is not None else resolve_registry_path()
416
+ repo = Path(repo_path).resolve()
417
+
418
+ if not is_valid_slug(slug):
419
+ raise InvalidSlug(slug)
420
+
421
+ entry_scopes = dict(scopes) if scopes is not None else derive_scopes(slug)
422
+ record = _record_for(repo, entry_scopes)
423
+
424
+ with exclusive_lock(registry_path, timeout_s=timeout_s):
425
+ document = _read_document(registry_path)
426
+ repos = document["repos"]
427
+
428
+ holder = repos.get(slug)
429
+ if holder is not None and Path(str(holder["path"])) != repo:
430
+ raise SlugCollision(slug, Path(str(holder["path"])), claimant=repo)
431
+
432
+ previous_slug: str | None = None
433
+ for other_slug, other in list(repos.items()):
434
+ if other_slug != slug and Path(str(other["path"])) == repo:
435
+ previous_slug = other_slug
436
+ del repos[other_slug]
437
+
438
+ changed = repos.get(slug) != record or previous_slug is not None
439
+ repos[slug] = record
440
+ if changed or not registry_path.exists():
441
+ _write_document(registry_path, document)
442
+
443
+ entries = _entries_of(document, registry_path)
444
+ return Registration(
445
+ entry=next(entry for entry in entries if entry.slug == slug),
446
+ changed=changed,
447
+ previous_slug=previous_slug,
448
+ )
449
+
450
+
451
+ def forget(
452
+ slug: str,
453
+ *,
454
+ path: str | Path | None = None,
455
+ timeout_s: float = DEFAULT_LOCK_TIMEOUT_S,
456
+ ) -> RegistryEntry | None:
457
+ """Remove one entry, returning it, or `None` when no entry held that slug.
458
+
459
+ Only the entry: the manifest, the gitignore line and `.ergane/` belong to
460
+ the repository, and removing them is the operator's own git work (FR-011).
461
+ Held under the same exclusive lock every other mutation takes, so a forget
462
+ racing an init cannot interleave with it.
463
+ """
464
+ registry_path = Path(path) if path is not None else resolve_registry_path()
465
+ with exclusive_lock(registry_path, timeout_s=timeout_s):
466
+ document = _read_document(registry_path)
467
+ repos = document["repos"]
468
+ if slug not in repos:
469
+ return None
470
+ entries = _entries_of(document, registry_path)
471
+ removed = None
472
+ for entry in entries:
473
+ if entry.slug == slug:
474
+ removed = entry
475
+ del repos[slug]
476
+ _write_document(registry_path, document)
477
+ return removed
478
+
479
+
480
+ def rebuild(
481
+ seed_paths: Iterable[str | Path] = (),
482
+ *,
483
+ path: str | Path | None = None,
484
+ timeout_s: float = DEFAULT_LOCK_TIMEOUT_S,
485
+ ) -> RebuildResult:
486
+ """Re-derive the cache: adopt the seeds, prune the dead, touch nothing live.
487
+
488
+ Rebuilding must always be the safe answer (FR-006), which has two
489
+ consequences worth stating. A seed that carries no committed manifest is
490
+ refused *before* the lock is taken, so a refusal changes nothing. And a
491
+ registry that cannot be parsed at all is rebuilt from the seeds rather than
492
+ refused — refusing there would leave the operator with a corrupt cache and
493
+ no verb that could repair it.
494
+ """
495
+ registry_path = Path(path) if path is not None else resolve_registry_path()
496
+
497
+ seeds: list[Path] = []
498
+ for seed in seed_paths:
499
+ resolved = Path(seed).resolve()
500
+ manifest, _ = resolve_manifest_path(resolved)
501
+ if not manifest.is_file():
502
+ raise NotAManagedRepo(resolved)
503
+ seeds.append(resolved)
504
+
505
+ with exclusive_lock(registry_path, timeout_s=timeout_s):
506
+ recovered = False
507
+ try:
508
+ document = _read_document(registry_path)
509
+ _entries_of(document, registry_path)
510
+ except RegistryCorrupt:
511
+ if not seeds:
512
+ raise
513
+ document = _blank_document()
514
+ recovered = True
515
+
516
+ repos = document["repos"]
517
+ known_paths = {Path(str(record["path"])): slug for slug, record in repos.items()}
518
+
519
+ adopted: list[str] = []
520
+ for seed in seeds:
521
+ if seed in known_paths:
522
+ # Already known: it is a live entry, and live entries are never
523
+ # touched — including their declared slug, which the repo itself
524
+ # does not carry.
525
+ continue
526
+ slug = normalize_slug(seed.name)
527
+ holder = repos.get(slug)
528
+ if holder is not None:
529
+ raise SlugCollision(slug, Path(str(holder["path"])), claimant=seed)
530
+ repos[slug] = _record_for(seed, derive_scopes(slug))
531
+ known_paths[seed] = slug
532
+ adopted.append(slug)
533
+
534
+ pruned: list[tuple[str, Path]] = []
535
+ for slug in sorted(repos):
536
+ repo = Path(str(repos[slug]["path"]))
537
+ if not repo.is_dir():
538
+ pruned.append((slug, repo))
539
+ for slug, _repo in pruned:
540
+ del repos[slug]
541
+
542
+ if adopted or pruned or recovered or not registry_path.exists():
543
+ _write_document(registry_path, document)
544
+ entries = _entries_of(document, registry_path)
545
+
546
+ return RebuildResult(
547
+ adopted=tuple(entry for entry in entries if entry.slug in set(adopted)),
548
+ pruned=tuple(pruned),
549
+ kept=entries,
550
+ recovered=recovered,
551
+ )
552
+
553
+
554
+ __all__ = [
555
+ "DEFAULT_LOCK_TIMEOUT_S",
556
+ "ERGANE_STATE_HOME_ENV",
557
+ "FACTORY_STATE_HOME_ENV",
558
+ "MANIFEST_INVALID",
559
+ "MANIFEST_MISSING",
560
+ "MANIFEST_VALID",
561
+ "InvalidSlug",
562
+ "NotAManagedRepo",
563
+ "RebuildResult",
564
+ "Registration",
565
+ "Registry",
566
+ "RegistryCorrupt",
567
+ "RegistryEntry",
568
+ "RegistryError",
569
+ "SlugCollision",
570
+ "declared_memory_backend",
571
+ "derive_memory_bank",
572
+ "derive_scopes",
573
+ "forget",
574
+ "is_valid_slug",
575
+ "load_registry",
576
+ "manifest_status",
577
+ "normalize_slug",
578
+ "rebuild",
579
+ "register",
580
+ "render_registry",
581
+ "resolve_registry_path",
582
+ "resolve_state_home",
583
+ ]
@@ -0,0 +1,8 @@
1
+ """The roadmap: spec frontmatter intent, computed readiness, and the render.
2
+
3
+ This package is the factory's second workflow surface's data layer (US1): a
4
+ pure reader turns a `specs/` corpus into a graph of `SpecEntry`s — each with a
5
+ declared intent state and `depends_on_landed` edges — and readiness computation
6
+ distinguishes attested from observed satisfaction. The workflow and CLI live
7
+ in sibling modules; the grammar and the graph are here.
8
+ """