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.
- ergane_cli-0.1.0.dist-info/METADATA +148 -0
- ergane_cli-0.1.0.dist-info/RECORD +121 -0
- ergane_cli-0.1.0.dist-info/WHEEL +4 -0
- ergane_cli-0.1.0.dist-info/entry_points.txt +2 -0
- ergane_cli-0.1.0.dist-info/licenses/LICENSE +202 -0
- ergane_cli-0.1.0.dist-info/licenses/NOTICE +27 -0
- factory/__init__.py +0 -0
- factory/activities/__init__.py +0 -0
- factory/activities/agent_activities.py +745 -0
- factory/activities/merge_activities.py +654 -0
- factory/activities/notify_activities.py +1078 -0
- factory/activities/roadmap_activities.py +577 -0
- factory/activities/usage_activities.py +593 -0
- factory/activities/verify_activities.py +617 -0
- factory/cli/__init__.py +0 -0
- factory/cli/completion.py +150 -0
- factory/cli/doctor.py +484 -0
- factory/cli/env.py +247 -0
- factory/cli/errors.py +77 -0
- factory/cli/init.py +1057 -0
- factory/cli/install.py +679 -0
- factory/cli/main.py +212 -0
- factory/cli/nouns/__init__.py +59 -0
- factory/cli/nouns/answer.py +134 -0
- factory/cli/nouns/build.py +1222 -0
- factory/cli/nouns/completion.py +13 -0
- factory/cli/nouns/doctor.py +13 -0
- factory/cli/nouns/env.py +13 -0
- factory/cli/nouns/escalations.py +93 -0
- factory/cli/nouns/findings.py +13 -0
- factory/cli/nouns/init.py +13 -0
- factory/cli/nouns/install.py +99 -0
- factory/cli/nouns/repo.py +13 -0
- factory/cli/nouns/roadmap.py +13 -0
- factory/cli/nouns/spec.py +511 -0
- factory/cli/nouns/status.py +18 -0
- factory/cli/nouns/usage.py +13 -0
- factory/cli/nouns/worker.py +69 -0
- factory/cli/repo.py +569 -0
- factory/cli/repo_export.py +282 -0
- factory/cli/roadmap.py +434 -0
- factory/cli/status.py +844 -0
- factory/cli/usage.py +129 -0
- factory/config.py +262 -0
- factory/controlplane/__init__.py +0 -0
- factory/controlplane/config.py +863 -0
- factory/controlplane/resolve.py +434 -0
- factory/controlplane/verify.py +827 -0
- factory/discovery/__init__.py +3 -0
- factory/discovery/llm_scanner.py +176 -0
- factory/doctor/__init__.py +0 -0
- factory/doctor/cli.py +392 -0
- factory/doctor/models.py +165 -0
- factory/doctor/probes.py +522 -0
- factory/doctor/scaffold.py +184 -0
- factory/doctor/store.py +449 -0
- factory/env.py +131 -0
- factory/escalation/__init__.py +0 -0
- factory/escalation/client.py +168 -0
- factory/escalation/question.py +161 -0
- factory/escalation/workflow.py +470 -0
- factory/locking.py +85 -0
- factory/mergequeue/__init__.py +21 -0
- factory/mergequeue/classify.py +133 -0
- factory/mergequeue/forge.py +401 -0
- factory/mergequeue/gh.py +484 -0
- factory/mergequeue/github_forge.py +349 -0
- factory/mergequeue/merge_queue_ruleset.json +32 -0
- factory/mergequeue/messages.py +141 -0
- factory/mergequeue/models.py +324 -0
- factory/mergequeue/onboard.py +537 -0
- factory/mergequeue/wiring.py +466 -0
- factory/notify/__init__.py +0 -0
- factory/notify/adapter.py +263 -0
- factory/notify/messages.py +474 -0
- factory/notify/service.py +809 -0
- factory/notify/webhook.py +151 -0
- factory/personas.yaml +161 -0
- factory/registry.py +583 -0
- factory/roadmap/__init__.py +8 -0
- factory/roadmap/cli.py +125 -0
- factory/roadmap/discovery.py +286 -0
- factory/roadmap/models.py +583 -0
- factory/roadmap/schedule.py +377 -0
- factory/roadmap/workflow.py +1364 -0
- factory/supervision/__init__.py +0 -0
- factory/supervision/alert.py +239 -0
- factory/supervision/probe.py +467 -0
- factory/supervision/temporal_server.py +121 -0
- factory/supervision/units.py +649 -0
- factory/usage/__init__.py +0 -0
- factory/usage/aggregate.py +113 -0
- factory/usage/cli.py +223 -0
- factory/usage/ledger.py +295 -0
- factory/usage/litellm_client.py +486 -0
- factory/usage/models.py +149 -0
- factory/verify/__init__.py +0 -0
- factory/verify/criteria.py +488 -0
- factory/verify/diffbounds.py +159 -0
- factory/verify/diffcheck.py +506 -0
- factory/verify/factory_yaml.py +842 -0
- factory/verify/gates.py +1341 -0
- factory/verify/judge.py +775 -0
- factory/verify/ladder.py +145 -0
- factory/verify/models.py +745 -0
- factory/verify/question.py +167 -0
- factory/verify/store.py +1366 -0
- factory/verify/toolchain.py +370 -0
- factory/worker.py +296 -0
- factory/workgraph/__init__.py +0 -0
- factory/workgraph/adapter.py +1333 -0
- factory/workgraph/cli.py +801 -0
- factory/workgraph/delta.py +278 -0
- factory/workgraph/derive.py +607 -0
- factory/workgraph/detector.py +509 -0
- factory/workgraph/landed.py +496 -0
- factory/workgraph/models.py +494 -0
- factory/workgraph/preflight.py +651 -0
- factory/workgraph/prompt.py +796 -0
- factory/workgraph/workflow.py +2733 -0
- 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)
|