surfaceplate 0.16.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 (96) hide show
  1. surfaceplate/MANIFEST.sha256 +230 -0
  2. surfaceplate/VERSION +1 -0
  3. surfaceplate/__init__.py +18 -0
  4. surfaceplate/about.py +48 -0
  5. surfaceplate/adapters/python.md +5 -0
  6. surfaceplate/adapters/r.md +3 -0
  7. surfaceplate/adapters/typescript.md +5 -0
  8. surfaceplate/adopt/__init__.py +15 -0
  9. surfaceplate/adopt/catalogue.py +78 -0
  10. surfaceplate/adopt/defaults.py +278 -0
  11. surfaceplate/adopt/detect.py +141 -0
  12. surfaceplate/adopt/discover.py +614 -0
  13. surfaceplate/adopt/example_answers.py +128 -0
  14. surfaceplate/adopt/explanations.py +551 -0
  15. surfaceplate/adopt/flow.py +559 -0
  16. surfaceplate/adopt/interview.py +237 -0
  17. surfaceplate/adopt/plan.py +1330 -0
  18. surfaceplate/adopt/provenance.py +300 -0
  19. surfaceplate/adopt/render.py +282 -0
  20. surfaceplate/adopt/scaffold.py +343 -0
  21. surfaceplate/adopt/sections.py +287 -0
  22. surfaceplate/adopt/tui/__init__.py +7 -0
  23. surfaceplate/adopt/tui/app.py +211 -0
  24. surfaceplate/adopt/tui/app.tcss +294 -0
  25. surfaceplate/adopt/tui/mark.py +77 -0
  26. surfaceplate/adopt/tui/screens.py +1772 -0
  27. surfaceplate/adopt/validators.py +224 -0
  28. surfaceplate/adopt/wizard.py +932 -0
  29. surfaceplate/check_conformance.py +3664 -0
  30. surfaceplate/cli.py +268 -0
  31. surfaceplate/core/AI_OPERATING_MODEL.md +45 -0
  32. surfaceplate/core/CONFORMANCE_LEVELS.md +299 -0
  33. surfaceplate/core/CONTROL_PRINCIPLES.md +14 -0
  34. surfaceplate/core/PREREQUISITE_GATES.md +322 -0
  35. surfaceplate/core/REVIEW_AND_EVIDENCE.md +55 -0
  36. surfaceplate/core/SECURITY_BASELINE.md +29 -0
  37. surfaceplate/doctor.py +249 -0
  38. surfaceplate/examples/application-profile.essential.example.yaml +126 -0
  39. surfaceplate/examples/application-profile.full.example.yaml +315 -0
  40. surfaceplate/examples/method-registry-entry.example.yaml +78 -0
  41. surfaceplate/examples/method-run-lineage.example.yaml +44 -0
  42. surfaceplate/examples/override-record.approved.example.yaml +39 -0
  43. surfaceplate/install_standard.py +880 -0
  44. surfaceplate/rules.py +148 -0
  45. surfaceplate/schemas/README.md +24 -0
  46. surfaceplate/schemas/application-profile.schema.yaml +320 -0
  47. surfaceplate/schemas/assurance-evidence.schema.yaml +33 -0
  48. surfaceplate/schemas/gate-exception.schema.yaml +47 -0
  49. surfaceplate/schemas/method-registry-entry.schema.yaml +132 -0
  50. surfaceplate/schemas/method-run-lineage.schema.yaml +72 -0
  51. surfaceplate/schemas/override-record.schema.yaml +76 -0
  52. surfaceplate/seeds/CHANGELOG.md +18 -0
  53. surfaceplate/seeds/activity-register.md +47 -0
  54. surfaceplate/seeds/adoption-decision-record.md +30 -0
  55. surfaceplate/seeds/data-source-register.md +18 -0
  56. surfaceplate/seeds/decision-log.md +28 -0
  57. surfaceplate/seeds/dependency-review-log.md +18 -0
  58. surfaceplate/seeds/findings-register.md +26 -0
  59. surfaceplate/seeds/method-registry-readme.md +7 -0
  60. surfaceplate/seeds/options-log.md +18 -0
  61. surfaceplate/seeds/output-validation-log.md +18 -0
  62. surfaceplate/seeds/overrides-readme.md +7 -0
  63. surfaceplate/seeds/release-checklist.md +19 -0
  64. surfaceplate/seeds/risk-classification.md +24 -0
  65. surfaceplate/seeds/run-lineage-readme.md +7 -0
  66. surfaceplate/seeds/source-of-truth-matrix.yaml +25 -0
  67. surfaceplate/seeds/test-conventions.md +20 -0
  68. surfaceplate/standard/.githooks/.gitattributes +1 -0
  69. surfaceplate/standard/.githooks/pre-commit +35 -0
  70. surfaceplate/standard/.github/skills/bug-fix/SKILL.md +57 -0
  71. surfaceplate/standard/.github/skills/change/SKILL.md +62 -0
  72. surfaceplate/standard/.github/skills/dependency-update/SKILL.md +61 -0
  73. surfaceplate/standard/.github/skills/fix-ci/SKILL.md +68 -0
  74. surfaceplate/standard/.github/skills/release/SKILL.md +71 -0
  75. surfaceplate/standard/.github/skills/review/SKILL.md +56 -0
  76. surfaceplate/standard/.github/skills/security-review/SKILL.md +61 -0
  77. surfaceplate/standard/.github/workflows/standards-conformance.yml +38 -0
  78. surfaceplate/standard/agent-instructions/activity.md +78 -0
  79. surfaceplate/standard/agent-instructions/ai-workflow.md +109 -0
  80. surfaceplate/standard/agent-instructions/authority.md +72 -0
  81. surfaceplate/standard/agent-instructions/provenance.md +93 -0
  82. surfaceplate/standard/agent-instructions/security.md +78 -0
  83. surfaceplate/standard/agent-instructions/tests.md +71 -0
  84. surfaceplate/standard/conformance-block.md +31 -0
  85. surfaceplate/templates/application-profile.yaml +113 -0
  86. surfaceplate/templates/decision-record.md +42 -0
  87. surfaceplate/templates/gate-exception.yaml +23 -0
  88. surfaceplate/templates/override-record.yaml +25 -0
  89. surfaceplate/templates/work-packet.md +33 -0
  90. surfaceplate-0.16.0.dist-info/METADATA +19 -0
  91. surfaceplate-0.16.0.dist-info/RECORD +96 -0
  92. surfaceplate-0.16.0.dist-info/WHEEL +4 -0
  93. surfaceplate-0.16.0.dist-info/entry_points.txt +2 -0
  94. surfaceplate-0.16.0.dist-info/licenses/LICENSE +201 -0
  95. surfaceplate-0.16.0.dist-info/licenses/LICENSE-DOCS +121 -0
  96. surfaceplate-0.16.0.dist-info/licenses/NOTICE +24 -0
@@ -0,0 +1,278 @@
1
+ """Proposed answers, each with the origin it will be recorded under.
2
+
3
+ `DR-40` created this module to propose what the adopter chose not to type; `DR-47` makes it the
4
+ rule for every value the wizard writes. This module **proposes**; it never decides. Every value it
5
+ produces is shown on the review with where it came from, the human can change any of them, and
6
+ the origin is recorded in `governance/application-profile.provenance.yaml` exactly as proposed -
7
+ never as typed (`provenance.py`).
8
+
9
+ **A proposal is only made where there is something honest to propose.** The sources `DR-47` (2)
10
+ names, and nothing else:
11
+
12
+ - **discovered** - a real path, directory or CI step read out of this repository (`discover.py`),
13
+ never one this framework installed (`F61`);
14
+ - **example** - the worked prose this framework already ships for that control or gate
15
+ (`example_answers.py`);
16
+ - **computed** - a value derived from a fact or from an answer already given: today's date, the
17
+ review horizon, a maintainer taken from the owner already given, a classification copied from
18
+ the one already chosen;
19
+ - **fact of record** and **scaffolded** are recorded by `provenance.py` and `flow.py` rather
20
+ than proposed here.
21
+
22
+ **What is never proposed.** A scope decision: a gate's status - `not_applicable` included - is a
23
+ key a human presses, singly or in one recorded bulk act (`DR-47` (4)). `F62` is what proposing
24
+ those cost: fifteen example rationales made true by assertion under a header claiming human
25
+ authorship. A field with no honest source is **left unanswered and still asked**.
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ import datetime as _dt
31
+ import re
32
+ from dataclasses import dataclass
33
+ from pathlib import Path
34
+
35
+ from surfaceplate.adopt import catalogue, detect, discover, example_answers, plan, provenance
36
+
37
+ # The framework's own sentence for a prose field the human left blank on the decisions form. It
38
+ # is written as computed - from the fact that nothing was stated - and is one edit on the review.
39
+ # `DR-47` accepted the prototype that wrote exactly this (report Part II §II.1).
40
+ NOT_STATED = "Not stated at adoption."
41
+
42
+
43
+ @dataclass(frozen=True)
44
+ class Proposal:
45
+ """One proposed answer, and where it came from - the origin is shown, never just the value."""
46
+
47
+ field: str # "<section>.<field id>"
48
+ value: object
49
+ origin: str # one of provenance.KINDS other than "typed"
50
+ detail: str # the human-readable reason, shown on the review
51
+
52
+ def describe(self) -> str:
53
+ return f"{self.field} = {self.value!r} ({self.detail})"
54
+
55
+ def as_origin(self) -> provenance.Origin:
56
+ return provenance.Origin(self.origin, self.detail)
57
+
58
+
59
+ def slug(name: str) -> str:
60
+ """A directory name as an `application_id` the schema accepts, or `""` if nothing survives."""
61
+ text = re.sub(r"[^a-z0-9_-]+", "-", name.strip().lower()).strip("-")
62
+ text = re.sub(r"-{2,}", "-", text)
63
+ if len(text) < 2 or not re.match(r"^[a-z0-9]", text):
64
+ return ""
65
+ return text
66
+
67
+
68
+ # ---------------------------------------------------------------------------------------------
69
+ # Before the decisions form: what the form can show pre-filled
70
+ # ---------------------------------------------------------------------------------------------
71
+
72
+
73
+ def propose_identity(repo: Path) -> list[Proposal]:
74
+ """The directory's own name, as the id and the display name. Both are one edit."""
75
+ out: list[Proposal] = []
76
+ name = repo.resolve().name
77
+ ident = slug(name)
78
+ if ident:
79
+ out.append(
80
+ Proposal("identity.application_id", ident, provenance.COMPUTED, "= the directory name")
81
+ )
82
+ if name.strip():
83
+ out.append(
84
+ Proposal("identity.display_name", name, provenance.COMPUTED, "= the directory name")
85
+ )
86
+ return out
87
+
88
+
89
+ def propose_stack(repo: Path) -> list[Proposal]:
90
+ languages = detect.detect_languages(repo)
91
+ if not languages:
92
+ return []
93
+ return [
94
+ Proposal(
95
+ "stack.language",
96
+ ", ".join(languages),
97
+ provenance.DISCOVERED,
98
+ "language markers found in this repository",
99
+ )
100
+ ]
101
+
102
+
103
+ # ---------------------------------------------------------------------------------------------
104
+ # After the decisions form and the level: everything else
105
+ # ---------------------------------------------------------------------------------------------
106
+
107
+
108
+ def propose_risk(risk_answers: dict) -> list[Proposal]:
109
+ """The two prose fields the decisions form leaves optional. `NOT_STATED` where blank."""
110
+ out: list[Proposal] = []
111
+ if not str(risk_answers.get("risk_profile") or "").strip():
112
+ out.append(
113
+ Proposal(
114
+ "risk.risk_profile", NOT_STATED, provenance.COMPUTED, "left blank on the decisions form"
115
+ )
116
+ )
117
+ if not str(risk_answers.get("materiality_definition") or "").strip():
118
+ out.append(
119
+ Proposal(
120
+ "risk.materiality_definition",
121
+ NOT_STATED,
122
+ provenance.COMPUTED,
123
+ "not asked before the review; one edit here",
124
+ )
125
+ )
126
+ return out
127
+
128
+
129
+ def propose_controls(*, level: str, mode: str, found: discover.Discovered) -> list[Proposal]:
130
+ """Rationales from the worked examples; references from what is really in the repository."""
131
+ out: list[Proposal] = []
132
+ section = plan.controls_plan(level=level, mode=mode, found=found)
133
+ floor = catalogue.CONFORMANCE_LEVELS[level]
134
+
135
+ for spec in section.fields:
136
+ key = f"controls.{spec.id}"
137
+ if spec.id == "above_floor":
138
+ # Nothing above the floor unless a human ticks it: a level is a floor, and quietly
139
+ # opting an adopter into controls they did not ask for would be the tool choosing
140
+ # their scope. Presented on the remainder form, so this is the value when left unticked.
141
+ out.append(
142
+ Proposal(key, [], provenance.COMPUTED, "nothing beyond this level's floor is declared")
143
+ )
144
+ continue
145
+ if spec.id.endswith(".rationale"):
146
+ control_id = spec.id.rsplit(".", 1)[0]
147
+ example = example_answers.rationale_example(control_id)
148
+ if example and (control_id in floor or control_id in plan.BASELINE_CONTROL_IDS):
149
+ out.append(
150
+ Proposal(key, example, provenance.EXAMPLE, "this framework's own worked example")
151
+ )
152
+ continue
153
+ if spec.id.endswith(".implementation_reference"):
154
+ # `DR-54` (1): the first row may be the seed's "create it"; a proposal comes only from
155
+ # something discovery found. With nothing found, the field is asked and the human
156
+ # may choose the row - the tool never proposes to create a file.
157
+ found_only = [v for v, _label in spec.choices if v != spec.seed and v not in found.rejected and not discover.is_archived(v)]
158
+ control_id = spec.id.rsplit(".", 1)[0]
159
+ if control_id in catalogue.PATTERN_A_CONTROLS and control_id != "dependency_lock":
160
+ # `F40`, `F84`: the offer ranks every artefact, but a proposal needs a match.
161
+ found_only = [v for v in found_only if any(w in v.lower() for w in plan.FINDINGS_WORDS)]
162
+ elif control_id in catalogue.PATTERN_C_CONTROLS:
163
+ # `F93`: a record directory is proposed only where its name matches the control
164
+ # and its records pass the control's schema - never merely because it holds YAML.
165
+ found_only = [v for v in found_only if v in found.register_fit.get(control_id, ())]
166
+ if found_only:
167
+ out.append(Proposal(key, found_only[0], provenance.DISCOVERED, f"found: {found_only[0]}"))
168
+ continue
169
+ if spec.id == "scanner.name":
170
+ out.append(Proposal(key, spec.default, provenance.EXAMPLE, "the scanner the examples name"))
171
+ elif spec.id == "scanner.wired_in" and spec.choices:
172
+ found_only = [v for v, _label in spec.choices if v != spec.seed]
173
+ if found_only:
174
+ out.append(Proposal(key, found_only[0], provenance.DISCOVERED, f"found: {found_only[0]}"))
175
+ return out
176
+
177
+
178
+ def propose_gates(
179
+ *, level: str, builds_ui: bool, mode: str, found: discover.Discovered, adoption_date: str
180
+ ) -> list[Proposal]:
181
+ """A precondition for every gate that could be `required`, and nothing for any status.
182
+
183
+ `DR-47` (4): the tool never supplies a scope decision, `not_applicable` included. `DR-47` (5):
184
+ `effective_from` is proposed as the adoption date and recorded as computed unless changed - the
185
+ value is shown, and a human can only widen the audit window from it (`SP033`, `SP034`).
186
+ """
187
+ out: list[Proposal] = []
188
+ for spec in plan.gate_plan(level=level, builds_ui=builds_ui, mode=mode, found=found):
189
+ prefix = f"gates.{spec.id}"
190
+ # A gate settled `not_applicable` by an earlier answer needs no precondition.
191
+ if spec.auto_status:
192
+ continue
193
+ # `F40`: MATCHED, not merely ranked - a proposal comes only from a candidate that matched
194
+ # the gate. No match -> no proposal, and the field is asked.
195
+ # `F84` / `DR-51` (5): and never one the checker's content rules would reject; `F94`:
196
+ # nor an archived document, whatever its name matched.
197
+ matched = [c for c in discover.matched_for_gate(found.artefacts, spec.id) if c not in found.rejected and not discover.is_archived(c)]
198
+ if matched:
199
+ out.append(
200
+ Proposal(
201
+ f"{prefix}.artefact",
202
+ matched[0],
203
+ provenance.DISCOVERED,
204
+ f"the closest match in this repository: {matched[0]}",
205
+ )
206
+ )
207
+ # `F61`: only when discovery found a directory of the adopter's own.
208
+ if found.paths:
209
+ out.append(
210
+ Proposal(
211
+ f"{prefix}.paths",
212
+ found.paths[0],
213
+ provenance.DISCOVERED,
214
+ "this repository's main source directory",
215
+ )
216
+ )
217
+ out.append(
218
+ Proposal(
219
+ f"{prefix}.effective_from",
220
+ adoption_date,
221
+ provenance.COMPUTED,
222
+ "= the adoption date; an earlier date audits more history",
223
+ )
224
+ )
225
+ return out
226
+
227
+
228
+ def propose_adoption(*, owner: str, data_classification: str) -> list[Proposal]:
229
+ """Only what can be derived from a fact or an answer already given."""
230
+ return [
231
+ Proposal(
232
+ "adoption.review_by",
233
+ (_dt.date.today() + _dt.timedelta(days=180)).isoformat(),
234
+ provenance.COMPUTED,
235
+ "180 days from today, the interval this framework suggests",
236
+ ),
237
+ Proposal("adoption.framework_maintainer", owner, provenance.COMPUTED, "= owner"),
238
+ Proposal(
239
+ "adoption.repository_classification",
240
+ data_classification,
241
+ provenance.COMPUTED,
242
+ "= data_classification",
243
+ ),
244
+ Proposal(
245
+ "adoption.adoption_status",
246
+ "in_progress",
247
+ provenance.COMPUTED,
248
+ "adopt has just written the profile; the checker has not yet passed against it",
249
+ ),
250
+ Proposal(
251
+ "adoption.needs_validator", False, provenance.COMPUTED, "no independent review declared"
252
+ ),
253
+ ]
254
+
255
+
256
+ def propose_wrap() -> list[Proposal]:
257
+ return [
258
+ Proposal("wrap.human_roles", "", provenance.COMPUTED, "none stated; one edit here"),
259
+ ]
260
+
261
+
262
+ def propose_after_level(
263
+ state: dict, *, found: discover.Discovered, adoption_date: str
264
+ ) -> list[Proposal]:
265
+ """Everything proposable once the level is known."""
266
+ level = state["level"]["conformance_level"]
267
+ builds_ui = bool(state["stack"]["builds_user_interface"])
268
+ mode = state["mode"]["mode"]
269
+ owner = str(state["identity"].get("owner") or "")
270
+ classification = str(state["risk"].get("data_classification") or "")
271
+ return [
272
+ *propose_controls(level=level, mode=mode, found=found),
273
+ *propose_gates(
274
+ level=level, builds_ui=builds_ui, mode=mode, found=found, adoption_date=adoption_date
275
+ ),
276
+ *propose_adoption(owner=owner, data_classification=classification),
277
+ *propose_wrap(),
278
+ ]
@@ -0,0 +1,141 @@
1
+ """What can be seen without asking - and only that.
2
+
3
+ Detection exists to save typing, never to answer for the human. Every value this module returns is
4
+ shown back to them in section 2 as something to *confirm or correct*, never written silently. In
5
+ particular `builds_user_interface` is never set from here: `core/CONFORMANCE_LEVELS.md` states
6
+ plainly that it "is NOT descriptive... a reviewer can falsify a wrong answer in seconds," and a
7
+ detector that got it wrong would be exactly that wrong answer, arrived at automatically instead of
8
+ by a human's mistake.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from pathlib import Path
14
+
15
+ # (label, marker files) - a files-exist check, not a dependency graph walk. Enough to save someone
16
+ # typing "Python" when a pyproject.toml is sitting right there; not a claim to be exhaustive.
17
+ _LANGUAGE_MARKERS: list[tuple[str, tuple[str, ...]]] = [
18
+ ("Python", ("pyproject.toml", "setup.py", "requirements.txt")),
19
+ ("Node.js / TypeScript", ("package.json",)),
20
+ ("Rust", ("Cargo.toml",)),
21
+ ("Go", ("go.mod",)),
22
+ ("Java", ("pom.xml", "build.gradle", "build.gradle.kts")),
23
+ ("Ruby", ("Gemfile",)),
24
+ ]
25
+
26
+ # UI-framework dependency names worth a glance inside package.json, if one exists. Presence only -
27
+ # not a claim about how the framework is used.
28
+ _UI_DEPENDENCY_HINTS = ("react", "vue", "svelte", "@angular/core", "next", "nuxt", "solid-js")
29
+
30
+
31
+ # R7: widen what discovery looks for, because each hit removes a question. A repository with no
32
+ # manifest file but source files at its root is still recognisably one language.
33
+ _LANGUAGE_SUFFIXES: list[tuple[str, tuple[str, ...]]] = [
34
+ ("Python", (".py",)),
35
+ ("Node.js / TypeScript", (".ts", ".js", ".tsx")),
36
+ ("Rust", (".rs",)),
37
+ ("Go", (".go",)),
38
+ ("Java", (".java", ".kt")),
39
+ ("Ruby", (".rb",)),
40
+ ]
41
+
42
+
43
+ def detect_languages(repo: Path) -> list[str]:
44
+ found = [label for label, markers in _LANGUAGE_MARKERS if any((repo / m).is_file() for m in markers)]
45
+ try:
46
+ suffixes = {p.suffix for p in repo.iterdir() if p.is_file()}
47
+ except OSError:
48
+ suffixes = set()
49
+ for label, exts in _LANGUAGE_SUFFIXES:
50
+ if label not in found and any(ext in suffixes for ext in exts):
51
+ found.append(label)
52
+ return found
53
+
54
+
55
+ def detect_ui_hint(repo: Path) -> str | None:
56
+ """A package.json dependency that suggests a UI framework, or None. A hint, not a verdict."""
57
+ package_json = repo / "package.json"
58
+ if not package_json.is_file():
59
+ return None
60
+ try:
61
+ import json
62
+
63
+ data = json.loads(package_json.read_text(encoding="utf-8"))
64
+ except (OSError, ValueError):
65
+ return None
66
+ deps = {**data.get("dependencies", {}), **data.get("devDependencies", {})}
67
+ for hint in _UI_DEPENDENCY_HINTS:
68
+ if hint in deps:
69
+ return hint
70
+ return None
71
+
72
+
73
+ # Marker paths for the level-choice screen's detected signals (DR-35). Files-exist checks, in the
74
+ # same spirit as _LANGUAGE_MARKERS above: enough to show the honest starting cost of a level before
75
+ # it's chosen, never a claim to detect every possible shape a repository might already have. Shown,
76
+ # never acted on - the level choice itself stays a human decision either way (DR-35: "they are
77
+ # tool/repo dependent", the maintainer's own words for why this framework does not steer it).
78
+ _CI_WORKFLOW_DIRS = (".github/workflows", ".gitlab-ci.d")
79
+ _DECISIONS_FOLDER_MARKERS = ("docs/decisions", "docs/adr", "decisions", "adr")
80
+ _CHANGELOG_MARKERS = ("CHANGELOG.md", "CHANGELOG.rst", "CHANGELOG")
81
+
82
+
83
+ def detect_ci_workflows(repo: Path) -> list[str]:
84
+ """Existing CI workflow files, repository-relative - a candidate home for a control's CI-step
85
+ `implementation_reference` (`deterministic_tests`, `contract_tests`) or a gate's `enforcement:
86
+ [ci]`. Returns paths, not a verdict: this module never tells whether a step in one of them
87
+ actually fails the build, which only `check_conformance.py`'s own pattern-B check can."""
88
+ from surfaceplate.adopt import discover
89
+
90
+ installed, _steps = discover.framework_paths(repo)
91
+ found: list[str] = []
92
+ for directory in _CI_WORKFLOW_DIRS:
93
+ d = repo / directory
94
+ if not d.is_dir():
95
+ continue
96
+ found.extend(
97
+ p.relative_to(repo).as_posix()
98
+ for p in sorted(d.glob("*.y*ml"))
99
+ # `F61`: the workflow this framework installed is not one the adopter "appears to have".
100
+ if p.is_file() and p.relative_to(repo).as_posix() not in installed
101
+ )
102
+ return found
103
+
104
+
105
+ def detect_decisions_folder(repo: Path) -> str | None:
106
+ """A decisions/ADR-shaped folder that already exists, or None. A candidate precondition
107
+ artefact for `decision_before_implementation` - never asserted to already satisfy that gate,
108
+ since satisfying it also depends on real content and, for the gate itself, on git history."""
109
+ for marker in _DECISIONS_FOLDER_MARKERS:
110
+ d = repo / marker
111
+ if d.is_dir():
112
+ return marker
113
+ return None
114
+
115
+
116
+ def detect_changelog(repo: Path) -> str | None:
117
+ """An existing CHANGELOG file, or None - a candidate precondition artefact for
118
+ `change_record_before_completion`."""
119
+ for marker in _CHANGELOG_MARKERS:
120
+ f = repo / marker
121
+ if f.is_file():
122
+ return marker
123
+ return None
124
+
125
+
126
+ def detect_git_state(repo: Path) -> tuple[str | None, bool]:
127
+ """(current branch, working tree is clean) - best-effort, never raises."""
128
+ import subprocess
129
+
130
+ def run(*args: str) -> tuple[int, str]:
131
+ try:
132
+ result = subprocess.run(
133
+ ["git", "-C", str(repo), *args], capture_output=True, text=True, timeout=5
134
+ )
135
+ return result.returncode, result.stdout.strip()
136
+ except (OSError, subprocess.TimeoutExpired):
137
+ return 1, ""
138
+
139
+ code, branch = run("branch", "--show-current")
140
+ code2, status = run("status", "--porcelain")
141
+ return (branch if code == 0 and branch else None, code2 == 0 and status == "")