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
@@ -0,0 +1,583 @@
1
+ """The roadmap's types, the frontmatter reader, and readiness computation.
2
+
3
+ US1's grammar is the deriver's discipline applied one level up. Every spec
4
+ declares its intent — `draft`, `ready`, `deferred`, or attested `landed` — in a
5
+ frontmatter block on the spec itself, and the reader turns a `specs/` corpus
6
+ into a graph of `SpecEntry`s with `depends_on_landed` edges. Three properties
7
+ carry the weight, and each is a rule the existing workgraph deriver already
8
+ enforces one level down:
9
+
10
+ - **The field is additive (FR-002).** A spec with no frontmatter reads `draft`,
11
+ so adopting the grammar never invalidates or alters the meaning of any
12
+ existing spec. Frontmatter is the new field only; the `**Status**:` prose lines
13
+ that specs already carry are dead text the reader never consults (plan § US1
14
+ trap — 006's is a four-sentence paragraph that *looks* like state and is not).
15
+ - **Rejection names the offender and the file (FR-001).** Unknown keys and
16
+ unknown state values are refused rather than silently dropped — silently
17
+ dropping a key an author wrote is how a roadmap comes to mean something other
18
+ than it says. Every finding names the offending key or value *and* the file
19
+ it came from, the `unknown_key` rule one level up.
20
+ - **The reader is pure over the corpus.** `read_roadmap` walks the `specs/`
21
+ root it is handed and reads each `spec.md`, and nothing else — no registry,
22
+ no `personas.yaml`, no Temporal. Purity is what makes the grammar unit-testable
23
+ without infrastructure, the `test_derivation_opens_no_file` pattern.
24
+
25
+ Readiness (FR-003) is computed, never declared: a spec is dispatchable only when
26
+ `state: ready` *and* every `depends_on_landed` edge is satisfied. Satisfaction
27
+ has two kinds, and they MUST be distinguishable in reporting:
28
+
29
+ - **attested** — the dependency's own frontmatter carries `state: landed`, the
30
+ operator's attestation for work that predates the roadmap (001/002/005 were
31
+ never epics; nothing will ever observe them landing).
32
+ - **observed** — a child epic returned COMPLETED with every landing MERGED,
33
+ derived from Temporal and git. This kind arrives in US2; the seam for it (the
34
+ `landed_for` resolver) exists now so US2 does not have to restructure the graph
35
+ to add it.
36
+
37
+ The states are `StrEnum` for the same reason `workgraph/models.py` spells them
38
+ that way: Temporal's JSON converter's *deserializer* rebuilds a field annotated
39
+ with any other str-subclass enum as a list of one-character strings. The
40
+ roadmap workflow (US2/US3) will carry these across activity boundaries, so the
41
+ spelling that round-trips is the one to start with.
42
+ """
43
+
44
+ from __future__ import annotations
45
+
46
+ from dataclasses import dataclass, field
47
+ from enum import StrEnum
48
+ from pathlib import Path
49
+ from typing import Callable, Mapping, Sequence
50
+
51
+ import yaml
52
+
53
+ from factory.workgraph.models import find_cycle
54
+
55
+ #: The spec file every epic is compiled from — the same convention as
56
+ #: `factory/workgraph/cli.py`'s `SPEC_NAME`. The roadmap reads `specs/<dir>/spec.md`.
57
+ SPEC_NAME = "spec.md"
58
+
59
+ #: The frontmatter fence: a leading pair of `---` lines, YAML between. This is
60
+ #: the Spec Kit / Jekyll frontmatter convention, distinct from the fenced code
61
+ #: blocks the criteria parser masks (`mask_fences` treats only backtick/tilde
62
+ #: as fences, so `---` is inert to that scanner — verified in T002).
63
+ _FRONTMATTER_FENCE = "---"
64
+
65
+
66
+ class SpecState(StrEnum):
67
+ """The intent a spec's frontmatter declares (FR-001).
68
+
69
+ `draft` is the default for a spec with no frontmatter (FR-002) — the field
70
+ is additive. `ready` is the operator's declaration that the spec may
71
+ dispatch once its edges are satisfied. `deferred` parks a spec out of the
72
+ build order by operator choice. `landed` is an *attestation* for work that
73
+ predates the roadmap: 001/002/005 were never epics, so nothing will ever
74
+ observe them landing, and the operator may write `state: landed` to close
75
+ their edges (the one deliberate exception the story calls out).
76
+
77
+ `building` is deliberately absent: only the system may say `building`, and
78
+ an author who writes `state: building` is rejected (`unknown_state`) rather
79
+ than honoured — intent is declared, progress is observed.
80
+ """
81
+
82
+ DRAFT = "draft"
83
+ READY = "ready"
84
+ DEFERRED = "deferred"
85
+ LANDED = "landed"
86
+
87
+
88
+ #: Read-only computed state rendered by `compute_readiness` and the roadmap CLI.
89
+ #: Not a writable `SpecState` value — an author who writes `state: amended`
90
+ #: is rejected (`unknown_state`), because intent is declared and drift is observed.
91
+ RENDERED_AMENDED = "amended"
92
+
93
+
94
+ class LandedKind(StrEnum):
95
+ """The two kinds of dependency satisfaction (FR-003), distinguishable in reporting.
96
+
97
+ `ATTESTED` is a frontmatter `state: landed` — the operator's word. `OBSERVED`
98
+ is a child epic that returned COMPLETED with every landing MERGED — derived
99
+ from Temporal and git (US2's input). The two travel in `LandedStatus.kind` so
100
+ a report can say *why* an edge is satisfied, not just that it is.
101
+ """
102
+
103
+ ATTESTED = "attested"
104
+ OBSERVED = "observed"
105
+
106
+
107
+ #: Every key a spec's frontmatter may carry. Closed by design (FR-001): the
108
+ #: frontmatter rides `PromptSources.spec_text` whole into agent payloads, so a
109
+ #: closed key set is what keeps payload-borne text provably inert (FR-009, US2).
110
+ #: `state` is required-in-spirit (a spec with no frontmatter reads `draft`, but
111
+ #: a frontmatter block that is a mapping yet omits `state` is rejected rather
112
+ #: than guessed at — an author who wrote a block meant to say something).
113
+ _KNOWN_KEYS = ("state", "depends_on_landed")
114
+
115
+ #: The values `state` may take — the members of `SpecState`, spelled out so a
116
+ #: rejection can name the offender without a runtime enum walk producing
117
+ #: member names an operator never typed.
118
+ _STATE_VALUES = frozenset(member.value for member in SpecState)
119
+
120
+
121
+ @dataclass(frozen=True)
122
+ class SpecEntry:
123
+ """One spec's declared intent, parsed from its frontmatter (FR-001).
124
+
125
+ `spec_dir` is the directory name under `specs/` — the roadmap's identity
126
+ for a spec, and the value `depends_on_landed` names. `state` is the declared
127
+ intent (defaulting to `draft` when no frontmatter is present, FR-002).
128
+ `depends_on_landed` is the list of spec dirs this spec waits on as *landed*;
129
+ it is the spec-level analogue of the workgraph's node-level
130
+ `depends_on_merged`, the vocabulary precedent the plan names.
131
+ """
132
+
133
+ spec_dir: str
134
+ state: SpecState
135
+ depends_on_landed: list[str]
136
+ #: The file the entry was read from, for findings that name it. Empty for a
137
+ #: synthesized entry (none in US1; the reader always has a file).
138
+ source: str = ""
139
+
140
+
141
+ @dataclass(frozen=True)
142
+ class Roadmap:
143
+ """The whole corpus parsed: every spec, in sorted order of spec dir.
144
+
145
+ Sorted order is the deterministic order the render prints and the operator
146
+ reads — the same order a directory listing gives, so two operators see the
147
+ same roadmap. `entries` is the whole graph; `findings` is empty when every
148
+ spec's frontmatter is well-formed, and `read_roadmap` raises before
149
+ returning a roadmap that carries any finding (a broken corpus yields no
150
+ partial roadmap — the deriver's "emits nothing on failure" discipline).
151
+ """
152
+
153
+ specs_root: str
154
+ entries: list[SpecEntry]
155
+
156
+
157
+ @dataclass(frozen=True)
158
+ class RoadmapFinding:
159
+ """One refused spec: which rule, which spec, and what is wrong.
160
+
161
+ `rule` is the slug (`unknown_key`, `unknown_state`, `non_mapping`,
162
+ `dangling_dep`, `cycle`), `spec_dir` names the offending spec's directory,
163
+ and `problem` is the sentence an operator reads. Both `spec_dir` and the
164
+ offender appear in `str(finding)` so the render can name the file *and* the
165
+ key/value the author has to fix — never a bare "rejected".
166
+ """
167
+
168
+ rule: str
169
+ spec_dir: str
170
+ problem: str
171
+
172
+ def __str__(self) -> str:
173
+ return f"{self.spec_dir}: [{self.rule}] {self.problem}"
174
+
175
+
176
+ class RoadmapError(ValueError):
177
+ """A corpus that does not parse, and every reason why (FR-001).
178
+
179
+ Carries the whole list: emitting the well-formed specs of a broken corpus
180
+ would be the worst outcome available — a roadmap that looks usable while the
181
+ one broken spec is silent. The render refuses to print a partial roadmap
182
+ and reports every finding at once, so an author fixing one typo per run is
183
+ the failure mode collection exists to avoid.
184
+ """
185
+
186
+ def __init__(self, findings: Sequence[RoadmapFinding]) -> None:
187
+ self.findings = list(findings)
188
+ count = len(self.findings)
189
+ noun = "spec" if count == 1 else "specs"
190
+ detail = "\n".join(f" - {finding}" for finding in self.findings)
191
+ super().__init__(
192
+ f"the roadmap corpus does not parse ({count} {noun} rejected):\n{detail}"
193
+ )
194
+
195
+
196
+ # --- the staged collector (the deriver's `_Rejections` discipline) ------------
197
+
198
+
199
+ @dataclass
200
+ class _Findings:
201
+ """The collector, staged so a defect upstream is not reported twice.
202
+
203
+ A frontmatter block that is not a mapping has no `state` to type-check, and
204
+ a `depends_on_landed` naming no spec directory has no edge to cycle-check,
205
+ so each defect yields exactly one finding: shape before cross-validation,
206
+ cross-validation before the acyclic check — the same staging the deriver's
207
+ `_Rejections` makes readable.
208
+ """
209
+
210
+ collected: list[RoadmapFinding] = field(default_factory=list)
211
+
212
+ def add(self, rule: str, spec_dir: str, problem: str) -> None:
213
+ self.collected.append(
214
+ RoadmapFinding(rule=rule, spec_dir=spec_dir, problem=problem)
215
+ )
216
+
217
+ def raise_if_any(self) -> None:
218
+ if self.collected:
219
+ raise RoadmapError(self.collected)
220
+
221
+
222
+ # --- the pure frontmatter reader ----------------------------------------------
223
+
224
+
225
+ def _split_frontmatter(text: str) -> tuple[str | None, str]:
226
+ """The leading frontmatter block (YAML text, fences stripped), or None.
227
+
228
+ Frontmatter is a leading `---` fence pair at the very top of the file: the
229
+ first line must be exactly `---`, and the block closes at the next line
230
+ that is exactly `---`. Anything else — no leading fence, or a file that
231
+ starts with prose — means no frontmatter, and the spec reads `draft`
232
+ (FR-002). A single `---` with no closing fence is also no frontmatter:
233
+ the conservative direction is `draft`, not a parse error.
234
+ """
235
+ lines = text.splitlines()
236
+ if not lines or lines[0].strip() != _FRONTMATTER_FENCE:
237
+ return None, text
238
+ for index in range(1, len(lines)):
239
+ if lines[index].strip() == _FRONTMATTER_FENCE:
240
+ return "\n".join(lines[1:index]), "\n".join(lines[index + 1 :])
241
+ # Unclosed fence: treat as no frontmatter (FR-002's conservative direction).
242
+ return None, text
243
+
244
+
245
+ def _parse_frontmatter(
246
+ text: str, spec_dir: str, findings: _Findings
247
+ ) -> dict | None:
248
+ """Load one frontmatter block as a mapping, or record why it is not one.
249
+
250
+ Returns the loaded mapping on success, `None` on a shape failure (so the
251
+ caller knows there is nothing to cross-validate). YAML parse errors and
252
+ non-mapping values are rejected naming the file — the audience is an
253
+ author who has to go edit one block of one spec.
254
+ """
255
+ try:
256
+ loaded = yaml.safe_load(text)
257
+ except yaml.YAMLError as error:
258
+ findings.add(
259
+ "non_mapping",
260
+ spec_dir,
261
+ f"frontmatter is not valid YAML: {error}",
262
+ )
263
+ return None
264
+
265
+ if loaded is None:
266
+ # An empty frontmatter block (`---\n---`) is a mapping with no keys —
267
+ # treated as no declaration, which reads `draft` (FR-002). An author
268
+ # who wrote the fences but nothing inside meant nothing by it.
269
+ return {}
270
+ if not isinstance(loaded, dict):
271
+ findings.add(
272
+ "non_mapping",
273
+ spec_dir,
274
+ f"frontmatter must be a mapping of {', '.join(_KNOWN_KEYS)}, "
275
+ f"got {type(loaded).__name__}",
276
+ )
277
+ return None
278
+ return loaded
279
+
280
+
281
+ def _shape_entry(
282
+ block: Mapping[str, object], spec_dir: str, findings: _Findings
283
+ ) -> SpecEntry | None:
284
+ """Shape one frontmatter mapping into a `SpecEntry`, or record why it has none.
285
+
286
+ Shape errors stop this spec from being cross-validated at all: a block with
287
+ an unknown key has no grammar to honour, and reporting both "unknown key"
288
+ and "unknown state" for one typo would send the author to two lines to fix
289
+ one. The closed key set is `_KNOWN_KEYS`; `state` is required when a block
290
+ is present (an empty block reads `draft`, but a non-empty block that omits
291
+ `state` is rejected — an author who wrote something meant to say something).
292
+ """
293
+ unknown = [key for key in block if key not in _KNOWN_KEYS]
294
+ if unknown:
295
+ findings.add(
296
+ "unknown_key",
297
+ spec_dir,
298
+ f"frontmatter carries unknown key(s) {_quoted(unknown)}; "
299
+ f"the grammar is {_quoted(list(_KNOWN_KEYS))}",
300
+ )
301
+ return None
302
+
303
+ state_value = block.get("state")
304
+ if state_value is None:
305
+ findings.add(
306
+ "unknown_state",
307
+ spec_dir,
308
+ f"frontmatter omits 'state'; it is one of "
309
+ f"{_quoted(sorted(_STATE_VALUES))} (a spec with no frontmatter "
310
+ "reads 'draft', but a block that says nothing else is not a "
311
+ "declaration)",
312
+ )
313
+ return None
314
+
315
+ if not isinstance(state_value, str) or state_value not in _STATE_VALUES:
316
+ findings.add(
317
+ "unknown_state",
318
+ spec_dir,
319
+ f"'state' must be one of {_quoted(sorted(_STATE_VALUES))}, "
320
+ f"got {state_value!r} — only the system may say 'building'; the "
321
+ "author's vocabulary is the four intent states",
322
+ )
323
+ return None
324
+
325
+ deps = block.get("depends_on_landed", [])
326
+ if deps is None:
327
+ deps = []
328
+ if not isinstance(deps, list) or not all(isinstance(item, str) for item in deps):
329
+ findings.add(
330
+ "unknown_key",
331
+ spec_dir,
332
+ f"'depends_on_landed' must be a list of spec dirs, got {deps!r}",
333
+ )
334
+ return None
335
+
336
+ return SpecEntry(
337
+ spec_dir=spec_dir,
338
+ state=SpecState(state_value),
339
+ depends_on_landed=list(deps),
340
+ )
341
+
342
+
343
+ def _cross_validate(
344
+ entries: Mapping[str, SpecEntry], findings: _Findings
345
+ ) -> None:
346
+ """Every rule that needs the whole corpus: dangling edges, then cycles.
347
+
348
+ A `depends_on_landed` entry naming no spec directory in the corpus is
349
+ rejected — an edge to nothing can never be satisfied, and silently ignoring
350
+ it would let a typo pass as "blocked" forever (the deriver's discipline,
351
+ one level up). The acyclic check runs only against a graph whose edges all
352
+ resolve, so a dangling edge is not also reported as a cycle it cannot be on.
353
+ """
354
+ declared = set(entries)
355
+ for entry in entries.values():
356
+ unknown = [dep for dep in entry.depends_on_landed if dep not in declared]
357
+ if unknown:
358
+ findings.add(
359
+ "dangling_dep",
360
+ entry.spec_dir,
361
+ f"depends on {_quoted(unknown)}, which no spec directory in "
362
+ "this corpus names — an edge to nothing can never be satisfied",
363
+ )
364
+ findings.raise_if_any()
365
+
366
+ adjacency = {
367
+ entry.spec_dir: list(entry.depends_on_landed) for entry in entries.values()
368
+ }
369
+ cycle = find_cycle(adjacency)
370
+ if cycle is not None:
371
+ findings.add(
372
+ "cycle",
373
+ cycle[0],
374
+ "these specs wait on each other and none can ever dispatch: "
375
+ + " -> ".join(cycle),
376
+ )
377
+ findings.raise_if_any()
378
+
379
+
380
+ def read_roadmap(specs_root: str | Path) -> Roadmap:
381
+ """Read a `specs/` corpus into a roadmap graph, or raise naming every fault.
382
+
383
+ Walks `<specs_root>/<spec-dir>/spec.md` for every direct child directory,
384
+ parses each spec's leading frontmatter (or reads `draft` when none is
385
+ present, FR-002), shapes every entry, then cross-validates the corpus
386
+ (dangling edges, then cycles). A corpus with any finding raises
387
+ `RoadmapError` and yields no partial roadmap — the same "emits nothing on
388
+ failure" discipline as the deriver.
389
+
390
+ Entries are returned in sorted order of `spec_dir`, the deterministic order
391
+ the render prints.
392
+ """
393
+ root = Path(specs_root)
394
+ findings = _Findings()
395
+ entries: dict[str, SpecEntry] = {}
396
+
397
+ spec_dirs = sorted(
398
+ path for path in root.iterdir() if path.is_dir() and not path.name.startswith(".")
399
+ )
400
+ for spec_dir_path in spec_dirs:
401
+ spec_dir = spec_dir_path.name
402
+ spec_path = spec_dir_path / SPEC_NAME
403
+ if not spec_path.is_file():
404
+ # A directory with no spec.md is not a spec the roadmap tracks;
405
+ # skip it rather than rejecting — the corpus is what has specs.
406
+ continue
407
+ text = spec_path.read_text(encoding="utf-8")
408
+ block_text, _body = _split_frontmatter(text)
409
+ if block_text is None:
410
+ # No frontmatter: reads `draft` (FR-002). No edges to wait on.
411
+ entries[spec_dir] = SpecEntry(
412
+ spec_dir=spec_dir,
413
+ state=SpecState.DRAFT,
414
+ depends_on_landed=[],
415
+ source=str(spec_path),
416
+ )
417
+ continue
418
+
419
+ block = _parse_frontmatter(block_text, spec_dir, findings)
420
+ if block is None:
421
+ continue
422
+ entry = _shape_entry(block, spec_dir, findings)
423
+ if entry is not None:
424
+ entries[spec_dir] = SpecEntry(
425
+ spec_dir=entry.spec_dir,
426
+ state=entry.state,
427
+ depends_on_landed=entry.depends_on_landed,
428
+ source=str(spec_path),
429
+ )
430
+
431
+ findings.raise_if_any()
432
+ _cross_validate(entries, findings)
433
+
434
+ return Roadmap(
435
+ specs_root=str(root),
436
+ entries=[entries[name] for name in sorted(entries)],
437
+ )
438
+
439
+
440
+ # --- readiness computation (FR-003) ------------------------------------------
441
+
442
+
443
+ @dataclass(frozen=True)
444
+ class LandedStatus:
445
+ """Whether a spec is landed, and how it is known to be (FR-003).
446
+
447
+ The seam US2 fills: `landed_for` returns a `LandedStatus` for a spec dir the
448
+ live record (Temporal + git) reports as landed, and `None` when it does not.
449
+ US1's default resolver consults only the frontmatter (attestation), so this
450
+ type's `observed` path is exercised by the resolver US2 injects — the seam
451
+ exists now so US2 does not restructure the graph to add it.
452
+ """
453
+
454
+ landed: bool
455
+ kind: LandedKind
456
+
457
+
458
+ #: The default resolver: a spec is landed iff its own frontmatter attests it.
459
+ #: Observed-landed (US2) is supplied by overriding `landed_for` in
460
+ #: `compute_readiness`; at US1 the frontmatter is the only source of truth.
461
+ def _attested_resolver(roadmap: Roadmap) -> Callable[[str], LandedStatus | None]:
462
+ states = {entry.spec_dir: entry.state for entry in roadmap.entries}
463
+
464
+ def resolve(spec_dir: str) -> LandedStatus | None:
465
+ if states.get(spec_dir) is SpecState.LANDED:
466
+ return LandedStatus(landed=True, kind=LandedKind.ATTESTED)
467
+ return None
468
+
469
+ return resolve
470
+
471
+
472
+ @dataclass(frozen=True)
473
+ class SpecReadiness:
474
+ """One spec's computed readiness: dispatchable, blockers, drift, and why satisfied.
475
+
476
+ `dispatchable` is `True` only when `state == ready` and every
477
+ `depends_on_landed` edge is satisfied (FR-003). `blockers` names the
478
+ unsatisfied edges — never a bare "blocked" (acceptance scenario 5).
479
+ `satisfied_as` maps each satisfied dependency to its `LandedKind`, so a
480
+ report can say *why* an edge is satisfied (attested vs observed), the two
481
+ kinds FR-003 requires to be distinguishable. `blockers` and
482
+ `satisfied_as` are disjoint: an edge is either satisfied (named in
483
+ `satisfied_as`) or a blocker (named in `blockers`), never both.
484
+
485
+ `drifted` is US4's read-only signal: the frontmatter says `landed` but the
486
+ injected resolver reports the spec's fingerprints differ from their landing
487
+ baseline. An amended spec is not dispatchable until the operator flips it to
488
+ `ready`, and the render shows `amended` rather than `landed` (FR-009).
489
+ `rendered_state` is the state an operator sees: `amended` when `drifted` and
490
+ the declared state is `landed`, otherwise the declared `state` value.
491
+ """
492
+
493
+ spec_dir: str
494
+ state: SpecState
495
+ dispatchable: bool
496
+ blockers: list[str]
497
+ satisfied_as: dict[str, LandedKind]
498
+ drifted: bool = False
499
+
500
+ @property
501
+ def rendered_state(self) -> str:
502
+ """The state the render prints: `amended` overrides a drifted `landed`."""
503
+ if self.drifted and self.state is SpecState.LANDED:
504
+ return RENDERED_AMENDED
505
+ return self.state.value
506
+
507
+
508
+ @dataclass(frozen=True)
509
+ class Readiness:
510
+ """The whole roadmap's computed readiness, indexed by spec dir.
511
+
512
+ `compute_readiness` is pure over the roadmap and the `landed_for` resolver:
513
+ same roadmap + same resolver → same readiness, the determinism the render
514
+ relies on. `spec` looks up one entry; `specs` iterates the whole corpus in
515
+ sorted order.
516
+ """
517
+
518
+ specs: list[SpecReadiness]
519
+
520
+ def spec(self, spec_dir: str) -> SpecReadiness:
521
+ for readiness in self.specs:
522
+ if readiness.spec_dir == spec_dir:
523
+ return readiness
524
+ raise KeyError(spec_dir)
525
+
526
+
527
+ def compute_readiness(
528
+ roadmap: Roadmap,
529
+ *,
530
+ landed_for: Callable[[str], LandedStatus | None] | None = None,
531
+ drifted_for: Callable[[str], bool] | None = None,
532
+ ) -> Readiness:
533
+ """Compute dispatchability and drift for every spec (FR-003, FR-009).
534
+
535
+ A spec is dispatchable iff `state == ready` and every `depends_on_landed`
536
+ entry is satisfied — satisfied means observed-landed (US2's resolver) or
537
+ attested (`state: landed` in that spec's own frontmatter). The two kinds are
538
+ reported distinctly in `SpecReadiness.satisfied_as`.
539
+
540
+ `landed_for` is the seam for dependency satisfaction. `drifted_for` is the
541
+ US4 seam for drift: it returns `True` when the frontmatter says `landed` but
542
+ the spec's current fingerprints differ from their landing baseline. Drift is
543
+ read-only: a drifted spec renders as `amended` and is not dispatchable until
544
+ the operator flips `state` to `ready`. Both resolvers are injected so git
545
+ reads stay out of workflow code (constitution IV).
546
+ """
547
+ attested = _attested_resolver(roadmap)
548
+ observed = landed_for if landed_for is not None else (lambda spec_dir: None)
549
+ drift_resolver = drifted_for if drifted_for is not None else (lambda spec_dir: False)
550
+
551
+ specs: list[SpecReadiness] = []
552
+ for entry in roadmap.entries:
553
+ blockers: list[str] = []
554
+ satisfied_as: dict[str, LandedKind] = {}
555
+ for dependency in entry.depends_on_landed:
556
+ status = observed(dependency)
557
+ if status is None or not status.landed:
558
+ status = attested(dependency)
559
+ if status is not None and status.landed:
560
+ satisfied_as[dependency] = status.kind
561
+ else:
562
+ blockers.append(dependency)
563
+
564
+ dispatchable = entry.state is SpecState.READY and not blockers
565
+ drifted = drift_resolver(entry.spec_dir) if entry.state is SpecState.LANDED else False
566
+ specs.append(
567
+ SpecReadiness(
568
+ spec_dir=entry.spec_dir,
569
+ state=entry.state,
570
+ dispatchable=dispatchable,
571
+ blockers=blockers,
572
+ satisfied_as=satisfied_as,
573
+ drifted=drifted,
574
+ )
575
+ )
576
+ return Readiness(specs=specs)
577
+
578
+
579
+ # --- helpers -----------------------------------------------------------------
580
+
581
+
582
+ def _quoted(values: Sequence[str]) -> str:
583
+ return ", ".join(f"'{value}'" for value in values)