specsmither 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (117) hide show
  1. specsmither/__init__.py +17 -0
  2. specsmither/adapters/__init__.py +5 -0
  3. specsmither/adapters/config.py +134 -0
  4. specsmither/adapters/crucible_validator.py +279 -0
  5. specsmither/adapters/in_memory_operations.py +450 -0
  6. specsmither/adapters/lifecycle_ports.py +527 -0
  7. specsmither/adapters/lifecycle_runner.py +63 -0
  8. specsmither/adapters/write_plan_executor.py +664 -0
  9. specsmither/dag/__init__.py +7 -0
  10. specsmither/dag/cascade.py +216 -0
  11. specsmither/dag/critical_path.py +391 -0
  12. specsmither/dag/status_calculator.py +174 -0
  13. specsmither/dag/tree.py +411 -0
  14. specsmither/dag/types.py +57 -0
  15. specsmither/db/__init__.py +6 -0
  16. specsmither/db/base.py +192 -0
  17. specsmither/db/migrations.py +165 -0
  18. specsmither/db/models/__init__.py +90 -0
  19. specsmither/db/models/config.py +53 -0
  20. specsmither/db/models/core.py +347 -0
  21. specsmither/db/models/planning.py +215 -0
  22. specsmither/db/models/ticket_children.py +133 -0
  23. specsmither/db/models/work.py +153 -0
  24. specsmither/db/repositories/__init__.py +67 -0
  25. specsmither/db/repositories/all_stores.py +108 -0
  26. specsmither/db/repositories/base.py +47 -0
  27. specsmither/db/repositories/config_store.py +144 -0
  28. specsmither/db/repositories/core_stores.py +757 -0
  29. specsmither/db/repositories/planning_stores.py +293 -0
  30. specsmither/db/repositories/ticket_store.py +377 -0
  31. specsmither/db/repositories/work_stores.py +362 -0
  32. specsmither/dispatch/__init__.py +9 -0
  33. specsmither/dispatch/envelopes.py +338 -0
  34. specsmither/dispatch/error_guidance.py +449 -0
  35. specsmither/dispatch/facade.py +564 -0
  36. specsmither/domain/__init__.py +6 -0
  37. specsmither/domain/enums.py +266 -0
  38. specsmither/domain/records.py +489 -0
  39. specsmither/domain/status.py +102 -0
  40. specsmither/lifecycle/__init__.py +6 -0
  41. specsmither/lifecycle/audit/__init__.py +26 -0
  42. specsmither/lifecycle/audit/action_builder.py +219 -0
  43. specsmither/lifecycle/audit/operation_mapper.py +128 -0
  44. specsmither/lifecycle/audit/transition_builder.py +69 -0
  45. specsmither/lifecycle/dispatch.py +129 -0
  46. specsmither/lifecycle/gate.py +354 -0
  47. specsmither/lifecycle/guidance/__init__.py +9 -0
  48. specsmither/lifecycle/guidance/compose.py +588 -0
  49. specsmither/lifecycle/guidance/types.py +130 -0
  50. specsmither/lifecycle/operations_registry.py +471 -0
  51. specsmither/lifecycle/ports.py +379 -0
  52. specsmither/lifecycle/prechecks/__init__.py +72 -0
  53. specsmither/lifecycle/prechecks/blueprint_epic_ratio.py +70 -0
  54. specsmither/lifecycle/prechecks/cascade_rules.py +70 -0
  55. specsmither/lifecycle/prechecks/count_bounds.py +134 -0
  56. specsmither/lifecycle/prechecks/cross_cut_references.py +62 -0
  57. specsmither/lifecycle/prechecks/dependencies_batch.py +223 -0
  58. specsmither/lifecycle/prechecks/gate_currently_passing.py +72 -0
  59. specsmither/lifecycle/prechecks/operation_allowed.py +87 -0
  60. specsmither/lifecycle/prechecks/result.py +75 -0
  61. specsmither/lifecycle/prechecks/schema_validate.py +164 -0
  62. specsmither/lifecycle/prechecks/spec_status_check.py +89 -0
  63. specsmither/lifecycle/state_machine.py +90 -0
  64. specsmither/lifecycle/verbs/__init__.py +13 -0
  65. specsmither/lifecycle/verbs/_common.py +74 -0
  66. specsmither/lifecycle/verbs/action.py +412 -0
  67. specsmither/lifecycle/verbs/approve.py +191 -0
  68. specsmither/lifecycle/verbs/complete.py +215 -0
  69. specsmither/lifecycle/verbs/inspect.py +106 -0
  70. specsmither/lifecycle/verbs/reject.py +88 -0
  71. specsmither/lifecycle/verbs/reject_with_feedback.py +102 -0
  72. specsmither/lifecycle/verbs/start.py +271 -0
  73. specsmither/lifecycle/verbs/support.py +208 -0
  74. specsmither/lifecycle/verbs/types.py +118 -0
  75. specsmither/lifecycle/write_plan.py +1009 -0
  76. specsmither/mcp/__init__.py +14 -0
  77. specsmither/mcp/server.py +395 -0
  78. specsmither/operations/__init__.py +4 -0
  79. specsmither/operations/crud.py +863 -0
  80. specsmither/operations/errors.py +246 -0
  81. specsmither/operations/lookup.py +203 -0
  82. specsmither/operations/project.py +88 -0
  83. specsmither/operations/pull_request.py +109 -0
  84. specsmither/operations/queries.py +713 -0
  85. specsmither/operations/reopen.py +65 -0
  86. specsmither/operations/reports.py +1558 -0
  87. specsmither/operations/search.py +623 -0
  88. specsmither/operations/workspace.py +212 -0
  89. specsmither/py.typed +0 -0
  90. specsmither/rollups/__init__.py +3 -0
  91. specsmither/rollups/aggregate.py +795 -0
  92. specsmither/rollups/counts.py +527 -0
  93. specsmither/rollups/recompute.py +441 -0
  94. specsmither/toon/__init__.py +16 -0
  95. specsmither/toon/encoder.py +664 -0
  96. specsmither/tui/__init__.py +22 -0
  97. specsmither/tui/__main__.py +8 -0
  98. specsmither/tui/app.py +752 -0
  99. specsmither/tui/app.tcss +158 -0
  100. specsmither/tui/data.py +762 -0
  101. specsmither/tui/panels.py +553 -0
  102. specsmither/tui/scaffold/__init__.py +21 -0
  103. specsmither/tui/scaffold/emitter.py +195 -0
  104. specsmither/tui/scaffold/templates.py +221 -0
  105. specsmither/tui/screens/__init__.py +15 -0
  106. specsmither/tui/screens/dag_view.py +44 -0
  107. specsmither/tui/screens/planning_monitor.py +56 -0
  108. specsmither/tui/screens/search.py +75 -0
  109. specsmither/tui/screens/spec_browser.py +185 -0
  110. specsmither/tui/screens/work_progress.py +43 -0
  111. specsmither/tui/ui_common.py +94 -0
  112. specsmither/tui/widgets.py +319 -0
  113. specsmither-0.1.0.dist-info/METADATA +112 -0
  114. specsmither-0.1.0.dist-info/RECORD +117 -0
  115. specsmither-0.1.0.dist-info/WHEEL +4 -0
  116. specsmither-0.1.0.dist-info/entry_points.txt +3 -0
  117. specsmither-0.1.0.dist-info/licenses/LICENSE +201 -0
@@ -0,0 +1,17 @@
1
+ """specsmither — local-first, NO-LLM reimplementation of the SpecForge engine.
2
+
3
+ Drives a software scope through ``Specification → Epic → Ticket → DAG`` over a
4
+ single SQLite file. Imports the two pure gates side-by-side: ``crucible`` (the
5
+ planning gate) and ``assay`` (the work gate, 0.2.0). The engine is fully
6
+ deterministic and synchronous; only the MCP server and the TUI are async
7
+ presentation adapters over it.
8
+
9
+ M0 (this layer): ``domain/``, ``db/``, ``dag/``, ``rollups/``, ``operations/``,
10
+ ``adapters/`` — the deterministic substrate. No lifecycle, MCP, or CLI yet.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ __version__ = "0.1.0"
16
+
17
+ __all__ = ["__version__"]
@@ -0,0 +1,5 @@
1
+ """Adapters: the WritePlan executor (apply ``WritePlanItem[]`` in one
2
+ ``Session.begin()`` then run the recompute worklist) and the in-memory
3
+ operations projector (project a post-mutation ``SpecFull`` without persisting,
4
+ so the gate can score as-if-applied).
5
+ """
@@ -0,0 +1,134 @@
1
+ """Config resolution — the pure merge layer over the :class:`ConfigStore` seam.
2
+
3
+ Faithful port of ``lifecycle/config.ts`` (work item #6, A1 §6). Two config domains
4
+ feed the lifecycle; both resolve the same way — baseline defaults, then the project
5
+ override, then (if a spec is in play) the frozen spec snapshot — but they merge with
6
+ different machinery:
7
+
8
+ * **``planning``** — the crucible ``ValidatorConfig`` (thresholds, tiers, topology,
9
+ cross-validation). Owned by crucible; merged by :func:`crucible.merge_config`
10
+ (which deep-merges *and re-validates* the result). :func:`resolve_validator_config`
11
+ layers ``crucible.load_defaults()`` ← project overrides ← spec snapshot.
12
+
13
+ * **``planning-lifecycle``** — the lifecycle guidance knobs. After the ME.10.5 trim
14
+ (and dropping ``observatoryBaseUrl`` — observatory links are cosmetic prose — and
15
+ ``gateResultCacheTtlMs`` — the CPS stale-cache TTL is baked locally), this is just
16
+ :data:`PLANNING_LIFECYCLE_DEFAULTS`: ``guidance.{maxNextEntitiesToShow,
17
+ fieldRenderDetail}``. :func:`resolve_lifecycle_config` layers it via the pure
18
+ :func:`deep_merge` (no schema, no validation — the shape is tiny and fixed).
19
+
20
+ Both resolvers read from a :class:`specsmither.lifecycle.ports.ConfigStore`. The async
21
+ ``PlanningLifecycleConfigResolver`` / ``PlanningConfigResolver`` classes collapse to
22
+ these two sync functions — SpecSmither has no async I/O and assembles the effective
23
+ config straight from the store (A1 §5, ports.py ``LifecyclePorts.config_store``).
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import copy
29
+ from typing import TYPE_CHECKING, Any
30
+
31
+ import crucible
32
+
33
+ if TYPE_CHECKING:
34
+ from specsmither.lifecycle.ports import ConfigStore
35
+
36
+ __all__ = [
37
+ "PLANNING_DOMAIN",
38
+ "PLANNING_LIFECYCLE_CONFIG_SCHEMA_VERSION",
39
+ "PLANNING_LIFECYCLE_DEFAULTS",
40
+ "PLANNING_LIFECYCLE_DOMAIN",
41
+ "deep_merge",
42
+ "resolve_lifecycle_config",
43
+ "resolve_validator_config",
44
+ ]
45
+
46
+ #: The crucible ``ValidatorConfig`` namespace (mirrors ``crucible.PLANNING_CONFIG_DOMAIN``).
47
+ PLANNING_DOMAIN = "planning"
48
+ #: The lifecycle-guidance namespace (TS ``PLANNING_LIFECYCLE_CONFIG_DOMAIN``).
49
+ PLANNING_LIFECYCLE_DOMAIN = "planning-lifecycle"
50
+ #: The schema version stamped on a ``planning-lifecycle`` write (TS bumped 1→2 at the
51
+ #: ME.10.5 clean-break trim). Surfaced for the spec-create snapshot freeze (L4).
52
+ PLANNING_LIFECYCLE_CONFIG_SCHEMA_VERSION = 2
53
+
54
+ #: Baseline ``planning-lifecycle`` config — merged under project/spec overrides.
55
+ #: Trimmed to the two surviving guidance knobs (see module docstring).
56
+ PLANNING_LIFECYCLE_DEFAULTS: dict[str, Any] = {
57
+ "guidance": {
58
+ # Max entities shown in the expansion-phase "next to fill" block. Caps size.
59
+ "maxNextEntitiesToShow": 3,
60
+ # Phase-entry field-block fidelity: 'rich' (full per-field render) | 'concise'.
61
+ "fieldRenderDetail": "rich",
62
+ },
63
+ }
64
+
65
+
66
+ def deep_merge(base: dict[str, Any], override: Any) -> dict[str, Any]:
67
+ """Recursively merge *override* onto *base* (the PURE resolver merge, config.ts:32-53).
68
+
69
+ Rules (identical for both config domains):
70
+
71
+ * a non-mapping *override* (``None``, a list, a primitive) leaves *base* unchanged;
72
+ * a ``None`` value at any key is skipped (an override never *unsets* a default);
73
+ * a list or primitive value **replaces** the corresponding key wholesale;
74
+ * two mappings at the same key **merge recursively**.
75
+
76
+ Never mutates either argument — *base* is shallow-copied per level (the resolvers
77
+ feed a deep copy of the defaults, so nested defaults are never aliased into a
78
+ returned result that a caller might mutate).
79
+ """
80
+ if not isinstance(override, dict):
81
+ return dict(base)
82
+ result: dict[str, Any] = dict(base)
83
+ for key, value in override.items():
84
+ if value is None:
85
+ continue
86
+ existing = result.get(key)
87
+ if isinstance(value, dict) and isinstance(existing, dict):
88
+ # Two mappings → merge recursively (lists/primitives fall through, replacing).
89
+ result[key] = deep_merge(existing, value)
90
+ else:
91
+ result[key] = value
92
+ return result
93
+
94
+
95
+ def resolve_validator_config(
96
+ config_store: ConfigStore, project_id: str, spec_id: str | None = None
97
+ ) -> dict[str, Any]:
98
+ """Resolve the effective crucible ``ValidatorConfig`` (domain ``planning``).
99
+
100
+ Layers ``crucible.load_defaults()`` ← the project's overrides ← (if *spec_id* is
101
+ given) the spec's frozen snapshot, via :func:`crucible.merge_config` (deep-merge +
102
+ re-validate). With no stored overrides this returns the crucible defaults verbatim.
103
+ """
104
+ overrides: list[dict[str, Any]] = []
105
+ project_overrides = config_store.get_project_overrides(project_id, PLANNING_DOMAIN)
106
+ if project_overrides is not None:
107
+ overrides.append(project_overrides)
108
+ if spec_id is not None:
109
+ snapshot = config_store.get_spec_snapshot(spec_id, PLANNING_DOMAIN)
110
+ if snapshot is not None:
111
+ overrides.append(snapshot)
112
+ return crucible.merge_config(crucible.load_defaults(), *overrides)
113
+
114
+
115
+ def resolve_lifecycle_config(
116
+ config_store: ConfigStore, project_id: str, spec_id: str | None = None
117
+ ) -> dict[str, Any]:
118
+ """Resolve the effective ``PlanningLifecycleConfig`` (domain ``planning-lifecycle``).
119
+
120
+ Layers :data:`PLANNING_LIFECYCLE_DEFAULTS` ← the project's overrides ← (if
121
+ *spec_id* is given) the spec's frozen snapshot, via the pure :func:`deep_merge`.
122
+ With no stored overrides this returns the defaults. The defaults are deep-copied
123
+ up front so the module-level constant is never aliased into the result.
124
+ """
125
+ result = deep_merge(
126
+ copy.deepcopy(PLANNING_LIFECYCLE_DEFAULTS),
127
+ config_store.get_project_overrides(project_id, PLANNING_LIFECYCLE_DOMAIN),
128
+ )
129
+ if spec_id is not None:
130
+ result = deep_merge(
131
+ result,
132
+ config_store.get_spec_snapshot(spec_id, PLANNING_LIFECYCLE_DOMAIN),
133
+ )
134
+ return result
@@ -0,0 +1,279 @@
1
+ """Crucible-backed :class:`~specsmither.lifecycle.ports.Validator` (work item #5).
2
+
3
+ Faithful port of ``lifecycle/adapters/validator-adapter.ts`` (A1 §3.2). This is
4
+ the one *coupled* seam between the pure planning lifecycle and crucible — the
5
+ deterministic OpenSpec validation engine. The lifecycle only ever reads the flat
6
+ :class:`~specsmither.lifecycle.ports.ValidatorOutput` contract; this adapter maps
7
+ crucible's richer four-layer result down to it.
8
+
9
+ The mapping in one paragraph
10
+ ----------------------------
11
+
12
+ :meth:`CrucibleValidatorAdapter.validate` short-circuits the terminal ``planned``
13
+ sentinel (and any non-validator phase) to a trivial pass *before* touching
14
+ crucible. For the six real phases it dumps the nested
15
+ :class:`~crucible.models.Specification` to crucible's camelCase wire dict, scopes
16
+ ``activeEntityId`` (the two ``*_expansion`` phases score per entity, so they pass
17
+ the full id list; everything else — including ``ticket_decomposition``, which is
18
+ spec-wide — passes ``None``), and calls :func:`crucible.validate` for the
19
+ ``structural`` / ``scoring`` / ``guidance`` layers. ``gate_result`` / ``local_score``
20
+ come from the active ``scoring`` layer (a *skipped* or *absent* scoring layer maps
21
+ to ``'fail'`` / ``0.0``, mirroring the TS ``scoring && !scoring.skipped`` guard);
22
+ the per-entity scores route to ``per_epic_score`` on ``epic_expansion`` and
23
+ ``per_ticket_score`` on ``ticket_expansion`` (``{}`` on every other phase); and
24
+ ``findings`` flattens crucible's structural findings + per-entity guidance entries
25
+ into the lifecycle's :class:`~specsmither.lifecycle.ports.ValidatorFinding` list.
26
+
27
+ Faithful-port notes
28
+ --------------------
29
+
30
+ * crucible's ``validate`` is **synchronous** (the engine has no async I/O) — the
31
+ TS ``Promise`` wrapper drops, exactly as the rest of the SpecSmither seam went
32
+ sync.
33
+ * :data:`_VALIDATOR_OP_TO_PATH` keys are crucible's ``OperationName`` vocabulary
34
+ (``add_dependencies`` / ``remove_dependency`` / ``link_blueprint`` …), *not* the
35
+ lifecycle's 15-op planning names — cross-validation guidance entries carry the
36
+ validator-side op names, so the table is ported verbatim from the TS adapter.
37
+ * Every emitted finding carries ``severity='finding'`` (advisory); crucible's own
38
+ ``error``/``warning`` severities are not surfaced — the TS adapter hardcodes
39
+ ``'finding'`` because the gate, not the finding, is the hard signal.
40
+ """
41
+
42
+ from __future__ import annotations
43
+
44
+ from typing import TYPE_CHECKING, Any, Literal, cast
45
+
46
+ from crucible import validate as crucible_validate
47
+ from crucible.types import (
48
+ GuidanceCrossValidationEntry,
49
+ GuidanceMessage,
50
+ ScoringResultActive,
51
+ StructuralFinding,
52
+ ValidationResult,
53
+ )
54
+
55
+ from specsmither.domain.enums import FindingCategory, PlanningPhase
56
+ from specsmither.lifecycle.ports import SpecFull, ValidatorFinding, ValidatorOutput
57
+
58
+ if TYPE_CHECKING:
59
+ from collections.abc import Mapping
60
+
61
+ __all__ = ["CrucibleValidatorAdapter"]
62
+
63
+
64
+ # --------------------------------------------------------------------------- #
65
+ # Phase set + op→path table (ported verbatim from validator-adapter.ts) #
66
+ # --------------------------------------------------------------------------- #
67
+
68
+ #: Phases crucible recognises — the six real phases, excluding the ``planned``
69
+ #: terminal sentinel the validator does not know about.
70
+ _VALIDATOR_PHASES: frozenset[str] = frozenset(
71
+ {
72
+ PlanningPhase.PLANNING_SPEC,
73
+ PlanningPhase.EPIC_DECOMPOSITION,
74
+ PlanningPhase.EPIC_EXPANSION,
75
+ PlanningPhase.TICKET_DECOMPOSITION,
76
+ PlanningPhase.TICKET_EXPANSION,
77
+ PlanningPhase.CROSS_VALIDATION,
78
+ }
79
+ )
80
+
81
+ #: crucible validator op name → lifecycle cross-validation path prefix. Keys are
82
+ #: crucible's ``OperationName`` vocabulary, surfaced on cross-validation guidance
83
+ #: entries' ``operations``.
84
+ _VALIDATOR_OP_TO_PATH: dict[str, str] = {
85
+ "set_metadata": "/cross-validation/by-op/set_metadata",
86
+ "create_epic": "/cross-validation/by-op/create_epic",
87
+ "update_epic": "/cross-validation/by-op/update_epic",
88
+ "delete_epic": "/cross-validation/by-op/delete_epic",
89
+ "create_ticket": "/cross-validation/by-op/create_ticket",
90
+ "update_ticket": "/cross-validation/by-op/update_ticket",
91
+ "delete_ticket": "/cross-validation/by-op/delete_ticket",
92
+ "add_dependencies": "/cross-validation/by-op/add_dependencies",
93
+ "remove_dependency": "/cross-validation/by-op/remove_dependency",
94
+ "create_blueprint": "/cross-validation/by-op/create_blueprint",
95
+ "update_blueprint": "/cross-validation/by-op/update_blueprint",
96
+ "delete_blueprint": "/cross-validation/by-op/delete_blueprint",
97
+ "link_blueprint": "/cross-validation/by-op/link_blueprint",
98
+ "unlink_blueprint": "/cross-validation/by-op/unlink_blueprint",
99
+ }
100
+
101
+
102
+ # --------------------------------------------------------------------------- #
103
+ # Pure mapping helpers #
104
+ # --------------------------------------------------------------------------- #
105
+
106
+
107
+ def _entity_path(entity_type: str, entity_id: str) -> str:
108
+ """``entityType``/``entityId`` → the lifecycle locator path (``entityPath``)."""
109
+ if entity_type == "specification":
110
+ return "/spec"
111
+ if entity_type == "epic":
112
+ return f"/epics/{entity_id}"
113
+ return f"/tickets/{entity_id}" # ticket
114
+
115
+
116
+ def _structural_finding_to_finding(finding: StructuralFinding) -> ValidatorFinding:
117
+ """Map a crucible structural finding → a ``schema`` / ``count`` finding."""
118
+ category = finding.category
119
+ is_count = "count" in category or category == "entity-count"
120
+ return ValidatorFinding(
121
+ category=FindingCategory.COUNT if is_count else FindingCategory.SCHEMA,
122
+ message=finding.message,
123
+ severity="finding",
124
+ entity_id=None,
125
+ entity_type=None,
126
+ path=f"/structural/{finding.field}",
127
+ points_lost=0.0,
128
+ global_impact_on_fix=0.0,
129
+ )
130
+
131
+
132
+ def _guidance_message_to_finding(entry: GuidanceMessage) -> ValidatorFinding:
133
+ """Map a crucible per-entity guidance message → a ``rubric`` finding."""
134
+ return ValidatorFinding(
135
+ category=FindingCategory.RUBRIC,
136
+ message=entry.message,
137
+ severity="finding",
138
+ entity_id=entry.entity_id,
139
+ entity_type=entry.entity_type,
140
+ path=_entity_path(entry.entity_type, entry.entity_id),
141
+ points_lost=entry.points_lost,
142
+ global_impact_on_fix=entry.global_impact_on_fix,
143
+ )
144
+
145
+
146
+ def _cross_validation_entry_to_finding(
147
+ entry: GuidanceCrossValidationEntry,
148
+ ) -> ValidatorFinding:
149
+ """Map a crucible cross-validation entry → a ``cross-validation`` finding."""
150
+ first_op = entry.operations[0] if entry.operations else None
151
+ if first_op is not None:
152
+ path = _VALIDATOR_OP_TO_PATH.get(first_op, f"/cross-validation/{first_op}")
153
+ else:
154
+ path = "/cross-validation/unknown"
155
+ return ValidatorFinding(
156
+ category=FindingCategory.CROSS_VALIDATION,
157
+ message=entry.message,
158
+ severity="finding",
159
+ entity_id=entry.primary_entity_id,
160
+ entity_type=None,
161
+ path=path,
162
+ points_lost=0.0,
163
+ global_impact_on_fix=0.0,
164
+ )
165
+
166
+
167
+ def _collect_findings(result: ValidationResult) -> list[ValidatorFinding]:
168
+ """Flatten structural findings + per-entity guidance into the flat list."""
169
+ findings: list[ValidatorFinding] = []
170
+
171
+ # Structural findings (schema / count checks).
172
+ if result.structural is not None:
173
+ for structural in result.structural.findings:
174
+ findings.append(_structural_finding_to_finding(structural))
175
+
176
+ # Guidance findings (rubric / cross-validation), keyed per entity.
177
+ if result.guidance is not None:
178
+ for entries in result.guidance.per_entity.values():
179
+ for entry in entries:
180
+ if isinstance(entry, GuidanceCrossValidationEntry):
181
+ findings.append(_cross_validation_entry_to_finding(entry))
182
+ else:
183
+ findings.append(_guidance_message_to_finding(entry))
184
+
185
+ return findings
186
+
187
+
188
+ def _get_active_entity_id(
189
+ spec_full: SpecFull, phase: PlanningPhase
190
+ ) -> str | list[str] | None:
191
+ """Scope crucible's ``activeEntityId`` (``getActiveEntityId``).
192
+
193
+ Only the per-entity SCORE phases carry an active entity: ``epic_expansion``
194
+ scopes to the epic ids being scored, ``ticket_expansion`` to the ticket ids
195
+ (flattened across epics). ``ticket_decomposition`` is spec-wide (its ratio +
196
+ structural checks span ALL tickets of ALL epics), so — like every other phase
197
+ — it returns ``None``, which crucible resolves to the whole spec.
198
+ """
199
+ if phase == PlanningPhase.EPIC_EXPANSION:
200
+ return [epic.id for epic in spec_full.epics]
201
+ if phase == PlanningPhase.TICKET_EXPANSION:
202
+ return [ticket.id for epic in spec_full.epics for ticket in epic.tickets]
203
+ return None
204
+
205
+
206
+ # --------------------------------------------------------------------------- #
207
+ # The adapter #
208
+ # --------------------------------------------------------------------------- #
209
+
210
+
211
+ class CrucibleValidatorAdapter:
212
+ """Implements :class:`~specsmither.lifecycle.ports.Validator` over crucible.
213
+
214
+ Stateless — a single instance is safe to share across the lifecycle. The
215
+ ``config`` argument is the already-merged effective ``ValidatorConfig`` dict
216
+ (defaults + project overrides + frozen spec snapshot); this adapter passes it
217
+ straight through to :func:`crucible.validate`.
218
+ """
219
+
220
+ def validate(
221
+ self, spec_full: SpecFull, phase: PlanningPhase, config: Mapping[str, Any]
222
+ ) -> ValidatorOutput:
223
+ """Score ``spec_full`` for ``phase`` → a flat :class:`ValidatorOutput`."""
224
+ # 'planned' (and any non-validator phase) → trivial pass; never call
225
+ # crucible, which does not recognise the terminal sentinel.
226
+ if phase not in _VALIDATOR_PHASES:
227
+ return ValidatorOutput(
228
+ gate_result="pass",
229
+ local_score=1.0,
230
+ per_epic_score={},
231
+ per_ticket_score={},
232
+ findings=[],
233
+ validated_phase=phase,
234
+ )
235
+
236
+ active_entity_id = _get_active_entity_id(spec_full, phase)
237
+ spec_dict = spec_full.spec.model_dump(by_alias=True, exclude_none=True)
238
+
239
+ context: dict[str, Any] = {
240
+ "phase": phase,
241
+ "config": config,
242
+ "activeEntityId": active_entity_id,
243
+ "returns": ["structural", "scoring", "guidance"],
244
+ }
245
+ result = cast(ValidationResult, crucible_validate(spec_dict, context))
246
+
247
+ # gate_result is the AUTHORITATIVE per-phase verdict crucible composes
248
+ # across all layers (structural counts for the decomposition phases,
249
+ # the cross-validation layer for cross_validation, scoring-threshold for
250
+ # the rubric phases) — exposed as the top-level ``result.passed``. Reading
251
+ # only ``scoring.gate_result`` (as the TS adapter did) wrongly fails every
252
+ # non-scoring phase, making the loop unable to reach ``ready``.
253
+ # local_score / per-entity scores still come from the active scoring layer
254
+ # (a skipped/absent layer has no rubric score → 0.0).
255
+ scoring = result.scoring
256
+ gate_result: Literal["pass", "fail"] = "pass" if result.passed else "fail"
257
+ if isinstance(scoring, ScoringResultActive):
258
+ local_score = scoring.local_score
259
+ per_entity_score = scoring.per_entity_score or {}
260
+ else:
261
+ local_score = 0.0
262
+ per_entity_score = {}
263
+
264
+ # epic_expansion populates epic ids; ticket_expansion populates ticket
265
+ # ids; every other phase leaves both empty (the validator only scores
266
+ # per-entity on the two expansion phases).
267
+ per_epic_score = per_entity_score if phase == PlanningPhase.EPIC_EXPANSION else {}
268
+ per_ticket_score = (
269
+ per_entity_score if phase == PlanningPhase.TICKET_EXPANSION else {}
270
+ )
271
+
272
+ return ValidatorOutput(
273
+ gate_result=gate_result,
274
+ local_score=local_score,
275
+ per_epic_score=per_epic_score,
276
+ per_ticket_score=per_ticket_score,
277
+ findings=_collect_findings(result),
278
+ validated_phase=phase,
279
+ )