sidegraph 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.
sidegraph/profiles.py ADDED
@@ -0,0 +1,459 @@
1
+ """Flow-profiles: per-flow reader configuration as data, not code (portable core).
2
+
3
+ A FlowProfile declares how to read one external spec/requirements flow's markdown
4
+ artifacts into decisions: the heading vocabulary (ReaderDialect) fed to doc_import's gate
5
+ and field extraction, the repo-relative ingest globs, and a marker path used for
6
+ auto-detection. Imports nothing from engine/ or host/ — this is portable core. See
7
+ design/superpowers/specs/2026-07-23-flow-profile-reader-design.md.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import re
13
+ from pathlib import Path
14
+
15
+ from pydantic import BaseModel, ConfigDict, model_validator
16
+
17
+
18
+ class ProfileDetection(BaseModel):
19
+ """What `detect_profile` concluded: the profile to use, if exactly one wins, plus every
20
+ profile whose marker or globs matched. Frozen because Bootstrap treats it as plan input."""
21
+
22
+ model_config = ConfigDict(frozen=True)
23
+
24
+ selected: str | None
25
+ matches: tuple[str, ...] = ()
26
+
27
+
28
+ class ReaderDialect(BaseModel):
29
+ """Per-flow heading vocabulary. Each tuple is matched case-insensitively as a PREFIX of
30
+ a heading (doc_import._heading_has_keyword); order within a tuple is priority order
31
+ (most-specific first), exactly as the current module constants rely on."""
32
+
33
+ context: tuple[str, ...]
34
+ choice: tuple[str, ...]
35
+ rejected: tuple[str, ...]
36
+ consequences: tuple[str, ...]
37
+ gate_only: tuple[str, ...]
38
+ # Oneshot+granularity spec D2: heading keywords naming a section whose H3 children
39
+ # become one Decision each (per-section multi-decision import). UNLIKE the other
40
+ # tuples, selection is document-order-first — a split target is a location, not a
41
+ # ranked field source. Default () = single-record parsing, byte-identical to before
42
+ # the field existed.
43
+ split_choice: tuple[str, ...] = ()
44
+
45
+ def all_headings(self) -> tuple[str, ...]:
46
+ """Every known heading — the decision-shaped gate passes when a doc has at least one.
47
+ Replaces doc_import._KNOWN_SECTION_HEADINGS. ``split_choice`` is deliberately NOT
48
+ included (spec review F12): a split target must already be choice material to gate
49
+ a doc in; widening the decision-shaped gate is not the split feature's business."""
50
+ return self.context + self.choice + self.rejected + self.consequences + self.gate_only
51
+
52
+
53
+ class FlowProfile(BaseModel):
54
+ """One external flow's reader configuration and auto-detection marker."""
55
+
56
+ name: str
57
+ dialect: ReaderDialect
58
+ ingest_globs: tuple[str, ...]
59
+ marker: str | None = None
60
+ # E1 (openspec profile design, design/superpowers/specs/2026-08-06-openspec-profile-
61
+ # design.md §5): a regex with named groups `name`/`doc`, matched against a doc's
62
+ # repo-relative path when it has no H1 at all. A match synthesizes the title
63
+ # `"{name} ({doc})"` in place of the missing H1; no match (or `None`, the default)
64
+ # rejects the doc exactly as every profile behaves today — this field is purely
65
+ # additive and changes nothing for a profile that never sets it.
66
+ title_pattern: str | None = None
67
+ # E2 (design note §6): `(pattern, replacement)` applied once by `normalized_rel_path`
68
+ # to collapse a flow's own archive-move layout onto its live-layout ref, so an
69
+ # unedited doc re-imported after the move dedupes instead of duplicating. `None` (the
70
+ # default) is the identity transform — every profile without this field is unaffected.
71
+ ref_normalize: tuple[str, str] | None = None
72
+ # I2 (R1 improvement wave §2): optional text appended to `context` for every record
73
+ # parsed from this profile's LIVE tree (see doc_import._is_live_tree_path for the
74
+ # trigger predicate both write paths share) — signals a retrieval-time reader that the
75
+ # record describes an in-flight change, not settled implementation reality. `None`
76
+ # (the default) is a no-op for every profile that never sets it.
77
+ in_flight_note: str | None = None
78
+
79
+ @model_validator(mode="after")
80
+ def _in_flight_note_requires_ref_normalize(self) -> FlowProfile:
81
+ """Setting `in_flight_note` without `ref_normalize` is a config error at
82
+ profile-construction time (review Minor 6 — silent no-op forbidden): the
83
+ live-vs-archived distinction the note depends on is DEFINED by `ref_normalize` — a
84
+ profile with a note but no way to ever tell live from archived would either never
85
+ stamp (silent no-op) or always stamp (defeats the "in flight" claim)."""
86
+ if self.in_flight_note is not None and self.ref_normalize is None:
87
+ raise ValueError(
88
+ "in_flight_note requires ref_normalize to be set -- otherwise there is no "
89
+ "way to distinguish a live-tree doc from an archived one"
90
+ )
91
+ return self
92
+
93
+ def normalized_rel_path(self, path: str) -> str:
94
+ """`path` with `ref_normalize`'s (pattern, replacement) applied once, or `path`
95
+ unchanged when `ref_normalize` is unset (design note §6, E2). Byte-identical
96
+ passthrough for every profile that never sets `ref_normalize`."""
97
+ if self.ref_normalize is None:
98
+ return path
99
+ pattern, replacement = self.ref_normalize
100
+ return re.sub(pattern, replacement, path)
101
+
102
+
103
+ # generic-adr = today's doc_import module constants, verbatim. The DEFAULT profile — kept
104
+ # conservative so it never over-matches (the prior-art false-positive discipline).
105
+ GENERIC_ADR_DIALECT = ReaderDialect(
106
+ context=("context", "trigger", "root cause"),
107
+ choice=("decision outcome", "decisions", "decision", "design"),
108
+ rejected=(
109
+ "rejected",
110
+ "alternatives",
111
+ "considered alternatives",
112
+ "considered options",
113
+ "options considered",
114
+ # Measured on two real Nygard-style corpora (2026-08-03, rancher/turtles +
115
+ # alphagov/govuk-infrastructure, 41 ADRs): structured option comparisons sit under
116
+ # "## Proposed alternatives" or a bare "## Options" — prefix matching means none of
117
+ # the keywords above can reach either. Together these two rescue every structured-
118
+ # alternatives document in both corpora (3/41); the rest of the empty-`rejected`
119
+ # mass is prose-only or simply never written down (see
120
+ # design/testing/2026-08-02-bootstrap-foreign-corpus-evidence.md §7).
121
+ "proposed alternatives",
122
+ "options",
123
+ ),
124
+ consequences=("consequences",),
125
+ gate_only=("status", "residuals", "scope notes", "user decisions"),
126
+ )
127
+
128
+ # superpowers = a LIBERAL dialect, safe ONLY because it is opt-in AND scope-limited to the
129
+ # specs dir (a random README can never reach it). Calibrated against the golden fixtures.
130
+ # "testing" is deliberately NOT mapped to consequences (spec §13, resolved): a test plan is
131
+ # not a consequence; a real "Risks" section is.
132
+ SUPERPOWERS_DIALECT = ReaderDialect(
133
+ context=("goal", "context", "problem", "motivation"),
134
+ # "approach" deliberately excluded: it is a PREFIX of the `rejected` keyword "approaches
135
+ # considered" below, so a spec whose only choice-ish heading is "## Approaches
136
+ # considered" would otherwise have that section assigned to BOTH `choice` and `rejected`
137
+ # (duplicated text).
138
+ choice=("architecture", "design", "components", "solution"),
139
+ rejected=(
140
+ "trade-off",
141
+ "trade-offs",
142
+ "alternatives",
143
+ "alternatives considered",
144
+ "approaches considered",
145
+ "rejected",
146
+ ),
147
+ consequences=("consequences", "risks"),
148
+ gate_only=("status",),
149
+ )
150
+
151
+ # genkovich-sdd — derived from upstream templates at github.com/genkovich/sdd (HEAD, checked
152
+ # 2026-07-27), not from resemblance to generic-adr. Three artifacts per feature, but only
153
+ # adr/*.md and sad.md are ingested (spec.md is a requirements doc, not a decision — see the
154
+ # registry entry below):
155
+ # docs/features/<slug>/spec.md requirements, NOT ingested: 1. Context / 2. Goals /
156
+ # 3. Non-goals / ... — no heading here can justify a `choice`
157
+ # docs/features/<slug>/adr/*.md Context / Decision drivers / Considered options /
158
+ # Decision outcome / Consequences / Links
159
+ # docs/features/<slug>/sad.md arc42-shaped, numbered: 4. Solution strategy,
160
+ # 9. Architecture decisions, 11. Risks and technical debt
161
+ # `choice` deliberately carries NO bare "decision": the ADR template has BOTH "Decision drivers"
162
+ # (context) and "Decision outcome" (the choice), and a bare prefix would take the drivers — the
163
+ # same trap GENERIC_ADR_DIALECT documents above. `context` deliberately carries NO "goals"
164
+ # (dropped 2026-07-27): its only reachable heading was spec.md's "2. Goals", and spec.md is not
165
+ # ingested — a keyword no ingested document can ever match is unsourced surface, the same
166
+ # defect this whole wave exists to remove. sad.md's "1. Introduction and goals" is unaffected:
167
+ # it already matches via "introduction", which comes first in this tuple.
168
+ #
169
+ # The same rule, applied the rest of the way (2026-07-27 doc reconciliation): `rejected` carried
170
+ # three more keywords with the identical unsourced property — "alternatives considered",
171
+ # "alternatives", "rejected" — and `gate_only` carried "status". None of the four is a heading in
172
+ # either upstream template (both put status in frontmatter, not prose); "considered options" is
173
+ # the only `rejected` keyword either template can ever match, so it is the only one that stays,
174
+ # and `gate_only` ships empty rather than carry a keyword nothing can match. None of the four was
175
+ # load-bearing for any golden fixture, so dropping them moves no test (verified: the full suite is
176
+ # unchanged). `spec-kit`, calibrated the same way two tasks later, shipped with zero unsourced
177
+ # keywords from the start; this profile now matches that standard instead of contradicting it.
178
+ GENKOVICH_SDD_DIALECT = ReaderDialect(
179
+ context=("context", "decision drivers", "introduction"),
180
+ choice=("decision outcome", "solution strategy", "architecture decisions"),
181
+ rejected=("considered options",),
182
+ consequences=("consequences", "risks and technical debt", "risks"),
183
+ gate_only=(),
184
+ )
185
+
186
+ # spec-kit — derived from github/spec-kit's actual templates (`templates/plan-template.md`,
187
+ # `templates/spec-template.md`; verified 2026-07-27), not from plausible vocabulary. Only
188
+ # plan.md is ingested (spec.md is a requirements doc — see the registry entry below); its
189
+ # real headings, in order, are exactly:
190
+ # Summary / Technical Context / Constitution Check / Project Structure / Complexity
191
+ # Tracking (**Branch**, **Date**, **Spec**, **Input**, **Note** are bold metadata lines
192
+ # right after the H1, not headings).
193
+ #
194
+ # `context` carries ONLY "technical context". spec-template.md's "User Scenarios & Testing"
195
+ # is a real heading too, but spec.md is not ingested (R3) — a keyword no ingested document
196
+ # can ever match is unsourced surface, the same reason genkovich-sdd's `context` dropped
197
+ # "goals". Bare "context"/"problem" (the brief's original guesses) match no heading in
198
+ # EITHER template at all — not even as a prefix match, since "Technical Context" does not
199
+ # start with "context" — so they are dropped outright, not just deprioritized.
200
+ #
201
+ # `choice` = "summary": plan-template's `## Summary` is filled with "the primary requirement
202
+ # plus the technical approach" upstream — the nearest real candidate to a chosen option. None
203
+ # of the brief's original guesses ("implementation", "architecture", "solution", "design")
204
+ # match any real heading in either template.
205
+ #
206
+ # `rejected` = "complexity tracking": upstream marks this section "Fill ONLY if Constitution
207
+ # Check has violations", so it is often absent — that is the flow's honest shape, not a
208
+ # calibration failure.
209
+ #
210
+ # `consequences=()`: neither template has a consequences section at all — the brief's
211
+ # "review"/"risks"/"consequences" guesses matched nothing (`## Review` does not exist).
212
+ # Verified-empty, same stance as bmad's verified-empty `rejected`: an honest empty tuple
213
+ # beats three keywords that silently match nothing.
214
+ #
215
+ # `gate_only=("constitution check",)`: `## Constitution Check` is a real plan.md heading,
216
+ # gate-only because it is a gating trigger note, not itself context/choice/rejected/
217
+ # consequences. "status" is deliberately NOT here (unlike GENERIC_ADR_DIALECT's ADR corpus,
218
+ # which has a literal "## Status" heading): spec-kit's status lives in spec.md's bold
219
+ # `**Status**` metadata line (spec.md not ingested), and plan.md has no "Status" heading of
220
+ # any kind — a keyword with nothing to match in the one document this profile reads is
221
+ # unsourced, the same standard applied to `context`/`choice` above.
222
+ SPEC_KIT_DIALECT = ReaderDialect(
223
+ context=("technical context",),
224
+ choice=("summary",),
225
+ rejected=("complexity tracking",),
226
+ consequences=(),
227
+ gate_only=("constitution check",),
228
+ )
229
+
230
+ # bmad — derived from BMAD-METHOD's actual spine template
231
+ # (`src/bmm-skills/3-solutioning/bmad-architecture/assets/spine-template.md`, measured
232
+ # 2026-07-30 at bmad-code-org/BMAD-METHOD 9b672e1e), not from plausible vocabulary. Only
233
+ # ARCHITECTURE-SPINE.md is ingested; its real headings, in order, are exactly:
234
+ # Design Paradigm / Inherited Invariants / Invariants & Rules (### AD-n — {decision}
235
+ # blocks) / Consistency Conventions / Stack / Structural Seed /
236
+ # Capability → Architecture Map / Deferred.
237
+ #
238
+ # `choice` = "invariants & rules" first: the AD blocks are "the durable heart" in the
239
+ # template's own words, and _parse_structure folds the ### AD-n children into the H2's
240
+ # body, so the whole run of decisions comes back as one section. Keyword priority (not doc
241
+ # order) is what keeps this safe: `## Design Paradigm` appears EARLIER in the document, and
242
+ # the generic dialect's bare "design" would have taken it — the same drivers-vs-outcome
243
+ # trap GENERIC_ADR_DIALECT documents. "consistency conventions" is second priority: real
244
+ # heading, decision content ("defaults that bind where independent builders would drift"),
245
+ # reachable only when a spine carries no Invariants & Rules section at all.
246
+ #
247
+ # `context` = "design paradigm": the frame the ADs bind within — the only context-shaped
248
+ # heading the template has.
249
+ #
250
+ # `rejected=()` and `consequences=()`: verified-empty (same stance as spec-kit's
251
+ # `consequences=()`). BMAD writes rationale and rejected alternatives ONLY into the run's
252
+ # flat, headingless `.memlog.md` ("Decisions, not rationale (rationale lives in the
253
+ # memlog)" — the spine template's own guide comment). Owner ruling 2026-07-30 (variant A):
254
+ # the memlog is NOT ingested — a heading-dialect reader can only degrade it into a
255
+ # degenerate record — and whether the live session channel covers what the memlog holds is
256
+ # measured in E10 rather than guessed here. A line-based memlog reader is a possible
257
+ # future wave, gated on that measurement.
258
+ #
259
+ # `gate_only=()`: an inherited-epic spine that is "mostly Inherited Invariants + a thin
260
+ # Deferred" records no NEW decisions (parent AD ids are read-only references), so failing
261
+ # the known-section gate and skipping it is the correct reading, not a calibration gap.
262
+ #
263
+ # PRD deliberately NOT ingested (R3): BMAD's PRD template is a requirements document
264
+ # (Vision / Target User / Features / Non-Goals / MVP Scope / Success Metrics / Open
265
+ # Questions) — no heading any `choice` keyword can match, same exclusion as genkovich's
266
+ # and spec-kit's spec.md.
267
+ # Oneshot+granularity spec D3 (E10 measured): `consequences=("deferred",)` — the spine's
268
+ # Deferred section (deferred decisions WITH reasons, verified negatives, open questions)
269
+ # was 5 of E10's 14 memlog-coverage losses and maps consequences-shaped; `split_choice` —
270
+ # one Decision per ### AD-n block (one-record-per-document with the 2000-char cap kept
271
+ # 3 of 9 invariants in E10's real spine). `rejected=()` stays: BMAD writes rejected
272
+ # alternatives only to the memlog (owner ruling variant A).
273
+ BMAD_DIALECT = ReaderDialect(
274
+ context=("design paradigm",),
275
+ choice=("invariants & rules", "consistency conventions"),
276
+ rejected=(),
277
+ consequences=("deferred",),
278
+ gate_only=(),
279
+ split_choice=("invariants & rules",),
280
+ )
281
+
282
+ # openspec — calibrated against the upstream templates shipped in the npm package
283
+ # (@fission-ai/openspec 1.8.0, schemas/spec-driven/templates/{proposal,design}.md, repo
284
+ # commit d5788966, checked 2026-08-06; the schemas/ dir ships in the package `files`, so
285
+ # the calibration source is re-checkable from node_modules without a clone), PLUS
286
+ # corpus-justified keywords under ruling R1 — each carries its citation below (D5
287
+ # deviation stated in the design note §3). Two doc kinds share one dialect: proposal.md
288
+ # (Why / What Changes / Impact) and design.md (Context / Decisions / Risks / Trade-offs).
289
+ # Delta specs and main capability specs are requirements documents and are NOT ingested
290
+ # (ruling R3); tasks.md is a checklist.
291
+ OPENSPEC_DIALECT = ReaderDialect(
292
+ context=("context", "why"),
293
+ # Most-specific first (prefix matching). Template: "decisions" (design),
294
+ # "what changes" (proposal). Corpus-justified variants (design note §3 C7):
295
+ # "architecture decisions" (4/38 archived, pre-2025-12 heading),
296
+ # "architecture decision" (add-verify-skill design :3),
297
+ # "key design decisions" (add-init-command design :17),
298
+ # "key design decision" (unify-change-state-model design :10),
299
+ # "design decisions" (project-config + project-local-schemas proposals :18),
300
+ # "decision" singular (make-apply-instructions proposal :85 "Decision: Add …";
301
+ # no "Decision drivers"-style heading exists in the corpus to collide with).
302
+ # "design decision" SINGULAR is deliberately ABSENT (review N6): its sole corpus
303
+ # citation ("## Design Decision: When is a change implementable?",
304
+ # make-apply-instructions proposal :18) holds the OPTIONS COMPARISON, and the
305
+ # file's real decision sits at :85 under "## Decision: Add `apply` block …" —
306
+ # with the longer keyword outranking bare "decision", the record stored the
307
+ # rejected options and dropped the decision. Without it, bare "decision"
308
+ # captures the real one.
309
+ # "Trade-offs and Decisions" (1 file) is deliberately NOT here: "trade-offs" must
310
+ # live in consequences, and a longer choice keyword sharing that prefix would
311
+ # duplicate the same section into both fields (the superpowers "approach" lesson).
312
+ # That one file's decisions land in consequences — measured miss, accepted.
313
+ choice=(
314
+ "architecture decisions",
315
+ "architecture decision",
316
+ "key design decisions",
317
+ "key design decision",
318
+ "design decisions",
319
+ "decisions",
320
+ "decision",
321
+ "what changes",
322
+ ),
323
+ # NOT verified-empty: three real headings across the dogfood corpus (design note §3
324
+ # R1 check). "alternatives considered" before "alternatives" — most specific first.
325
+ rejected=("rejected", "alternatives considered", "alternatives"),
326
+ # "risks / trade-offs" is the design.md template heading; "risks" rescues spacing
327
+ # variants via the prefix rule; "trade-offs" rescues the bare form
328
+ # (update-agent-instructions design :109); "impact" is the proposal template heading.
329
+ consequences=("risks / trade-offs", "risks", "trade-offs", "impact"),
330
+ # Template headings that qualify a doc as decision-shaped but map to no field.
331
+ gate_only=("goals / non-goals", "capabilities", "migration plan", "open questions"),
332
+ # PLURAL FORMS ONLY (review N2/N3): a plural heading names a CONTAINER of decisions
333
+ # (its ### children are one record each — 24/25 archived and 11/11 live "## Decisions"
334
+ # sections, design note §3 C2); a SINGULAR heading ("Architecture Decision: …",
335
+ # "Decision: Add …") names ONE decision whose ### children are its own sections
336
+ # (Context / Rationale / Alternatives Considered / …) — splitting there shredded one
337
+ # real decision into 7 section-records (review N3, add-verify-skill). Singular
338
+ # keywords stay in `choice` above; they must never be split targets. Split selection
339
+ # is document-order-first; a matching heading with no H3 children is a verified
340
+ # byte-identical no-op (review, "A3 died"); targets are level ≤ 2 only (E4).
341
+ split_choice=(
342
+ "architecture decisions",
343
+ "key design decisions",
344
+ "design decisions",
345
+ "decisions",
346
+ ),
347
+ )
348
+
349
+ PROFILES: dict[str, FlowProfile] = {
350
+ "generic-adr": FlowProfile(
351
+ name="generic-adr",
352
+ dialect=GENERIC_ADR_DIALECT,
353
+ ingest_globs=("docs/adr/*.md", "docs/decisions/*.md"),
354
+ marker="docs/adr",
355
+ ),
356
+ "superpowers": FlowProfile(
357
+ name="superpowers",
358
+ dialect=SUPERPOWERS_DIALECT,
359
+ ingest_globs=("docs/superpowers/specs/*.md",),
360
+ marker="docs/superpowers",
361
+ ),
362
+ "genkovich-sdd": FlowProfile(
363
+ name="genkovich-sdd",
364
+ dialect=GENKOVICH_SDD_DIALECT,
365
+ # spec.md deliberately excluded (owner ruling, 2026-07-27: do not ingest requirements
366
+ # documents — genkovich itself separates requirements from decisions, and this
367
+ # profile ingests decisions). Upstream keeps requirements in spec.md and the actual
368
+ # decisions in adr/ and sad.md; spec.md has no heading any `choice` keyword can match,
369
+ # so ingesting it produced a record whose `choice` restated its own `context`
370
+ # (test_genkovich_globs_exclude_spec_by_design pins the exclusion).
371
+ ingest_globs=(
372
+ "docs/features/*/adr/*.md",
373
+ "docs/features/*/sad.md",
374
+ ),
375
+ marker="docs/features",
376
+ ),
377
+ "spec-kit": FlowProfile(
378
+ name="spec-kit",
379
+ dialect=SPEC_KIT_DIALECT,
380
+ # spec.md deliberately excluded (R3, owner ruling 2026-07-27: do not ingest
381
+ # requirements documents). Upstream's spec.md states user scenarios / requirements /
382
+ # success criteria / assumptions — no heading there can justify a `choice`; the only
383
+ # heading in the whole flow that can is plan.md's own `## Summary`.
384
+ ingest_globs=("specs/*/plan.md",),
385
+ marker=".specify",
386
+ ),
387
+ "bmad": FlowProfile(
388
+ name="bmad",
389
+ dialect=BMAD_DIALECT,
390
+ # Measured layout (upstream defaults): output_folder = "_bmad-output",
391
+ # planning_artifacts = "{output_folder}/planning-artifacts", spine_output_path =
392
+ # "{planning_artifacts}/architecture", run_folder_pattern =
393
+ # "architecture-{project_name}-{date}". prd.md excluded (R3) and the run folder's
394
+ # sibling .memlog.md excluded (owner ruling 2026-07-30, variant A) — see the
395
+ # BMAD_DIALECT comment above for both rationales.
396
+ ingest_globs=("_bmad-output/planning-artifacts/architecture/*/ARCHITECTURE-SPINE.md",),
397
+ # The installer's scaffold dir ({project-root}/_bmad — memlog.py lives under
398
+ # _bmad/scripts/), same role as spec-kit's ".specify".
399
+ marker="_bmad",
400
+ ),
401
+ # openspec — D1: proposal.md IS ingested (not a requirements doc; R3 does not apply).
402
+ # D2: marker is `openspec/changes`, not `openspec/config.yaml` (2026-02+ only) or the
403
+ # legacy `openspec/project.md` — `openspec init` creates `changes/`+`changes/archive/`
404
+ # unconditionally. D3/D4: known accepted-noise/plural-only-split judgment calls — see
405
+ # design/superpowers/specs/2026-08-06-openspec-profile-design.md §4 for the full
406
+ # rationale each decision carries.
407
+ "openspec": FlowProfile(
408
+ name="openspec",
409
+ dialect=OPENSPEC_DIALECT,
410
+ ingest_globs=(
411
+ "openspec/changes/*/proposal.md",
412
+ "openspec/changes/*/design.md",
413
+ "openspec/changes/archive/*/proposal.md",
414
+ "openspec/changes/archive/*/design.md",
415
+ ),
416
+ marker="openspec/changes",
417
+ title_pattern=(
418
+ r"openspec/changes/(?:archive/\d{4}-\d{2}-\d{2}-)?"
419
+ r"(?P<name>[^/]+)/(?P<doc>proposal|design)\.md$"
420
+ ),
421
+ ref_normalize=(r"openspec/changes/archive/\d{4}-\d{2}-\d{2}-", "openspec/changes/"),
422
+ # I2 (R1 improvement wave §2, R1 finding P5): battery q11 + s5/s6 measured live-tree
423
+ # records read as settled implementation reality when they're actually still in
424
+ # flight (an "unimplemented" record whose change had already merged; an "open" bug
425
+ # already fixed). Stamped onto every record parsed from the LIVE tree only.
426
+ in_flight_note=(
427
+ "Change in flight (not yet archived) at import time — verify implementation "
428
+ "state against the code before trusting task/spec claims."
429
+ ),
430
+ ),
431
+ }
432
+
433
+
434
+ def get_profile(name: str) -> FlowProfile:
435
+ """The FlowProfile registered under `name`, or a ValueError listing the valid names."""
436
+ try:
437
+ return PROFILES[name]
438
+ except KeyError:
439
+ valid = ", ".join(sorted(PROFILES))
440
+ raise ValueError(f"unknown flow profile {name!r} (valid: {valid})") from None
441
+
442
+
443
+ def detect_profile(root: Path, explicit: str | None = None) -> ProfileDetection:
444
+ if explicit is not None:
445
+ get_profile(explicit)
446
+ return ProfileDetection(selected=explicit, matches=(explicit,))
447
+ specific = sorted(
448
+ name
449
+ for name, profile in PROFILES.items()
450
+ if name != "generic-adr" and profile.marker and (root / profile.marker).exists()
451
+ )
452
+ if len(specific) == 1:
453
+ return ProfileDetection(selected=specific[0], matches=tuple(specific))
454
+ if specific:
455
+ return ProfileDetection(selected=None, matches=tuple(specific))
456
+ generic = PROFILES["generic-adr"]
457
+ if any(any(root.glob(pattern)) for pattern in generic.ingest_globs):
458
+ return ProfileDetection(selected="generic-adr", matches=("generic-adr",))
459
+ return ProfileDetection(selected=None, matches=())