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/__init__.py +37 -0
- sidegraph/anchoring.py +246 -0
- sidegraph/bootstrap/__init__.py +49 -0
- sidegraph/bootstrap/apply.py +603 -0
- sidegraph/bootstrap/catalog.py +92 -0
- sidegraph/bootstrap/cli.py +827 -0
- sidegraph/bootstrap/integrations.py +184 -0
- sidegraph/bootstrap/model.py +277 -0
- sidegraph/bootstrap/planner.py +400 -0
- sidegraph/bootstrap/proof.py +103 -0
- sidegraph/bootstrap/review.py +331 -0
- sidegraph/bootstrap/scan.py +289 -0
- sidegraph/capture.py +1794 -0
- sidegraph/cli.py +1902 -0
- sidegraph/config.py +148 -0
- sidegraph/doc_import.py +2099 -0
- sidegraph/doctor.py +1429 -0
- sidegraph/domains.py +902 -0
- sidegraph/engine/__init__.py +7 -0
- sidegraph/engine/reader.py +353 -0
- sidegraph/gitio.py +572 -0
- sidegraph/host/__init__.py +7 -0
- sidegraph/host/hooks.py +770 -0
- sidegraph/importer.py +239 -0
- sidegraph/okf.py +471 -0
- sidegraph/profiles.py +459 -0
- sidegraph/retrieval.py +1657 -0
- sidegraph/schema.py +386 -0
- sidegraph/server.py +2608 -0
- sidegraph/store.py +3363 -0
- sidegraph/sync.py +885 -0
- sidegraph/verify.py +1040 -0
- sidegraph/viz/__init__.py +4 -0
- sidegraph/viz/assets/vis-network.min.js +33 -0
- sidegraph/viz/model.py +248 -0
- sidegraph/viz/render.py +110 -0
- sidegraph/viz/template.html +131 -0
- sidegraph-0.1.0.dist-info/METADATA +392 -0
- sidegraph-0.1.0.dist-info/RECORD +42 -0
- sidegraph-0.1.0.dist-info/WHEEL +4 -0
- sidegraph-0.1.0.dist-info/entry_points.txt +18 -0
- sidegraph-0.1.0.dist-info/licenses/LICENSE +201 -0
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=())
|