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.
- specsmither/__init__.py +17 -0
- specsmither/adapters/__init__.py +5 -0
- specsmither/adapters/config.py +134 -0
- specsmither/adapters/crucible_validator.py +279 -0
- specsmither/adapters/in_memory_operations.py +450 -0
- specsmither/adapters/lifecycle_ports.py +527 -0
- specsmither/adapters/lifecycle_runner.py +63 -0
- specsmither/adapters/write_plan_executor.py +664 -0
- specsmither/dag/__init__.py +7 -0
- specsmither/dag/cascade.py +216 -0
- specsmither/dag/critical_path.py +391 -0
- specsmither/dag/status_calculator.py +174 -0
- specsmither/dag/tree.py +411 -0
- specsmither/dag/types.py +57 -0
- specsmither/db/__init__.py +6 -0
- specsmither/db/base.py +192 -0
- specsmither/db/migrations.py +165 -0
- specsmither/db/models/__init__.py +90 -0
- specsmither/db/models/config.py +53 -0
- specsmither/db/models/core.py +347 -0
- specsmither/db/models/planning.py +215 -0
- specsmither/db/models/ticket_children.py +133 -0
- specsmither/db/models/work.py +153 -0
- specsmither/db/repositories/__init__.py +67 -0
- specsmither/db/repositories/all_stores.py +108 -0
- specsmither/db/repositories/base.py +47 -0
- specsmither/db/repositories/config_store.py +144 -0
- specsmither/db/repositories/core_stores.py +757 -0
- specsmither/db/repositories/planning_stores.py +293 -0
- specsmither/db/repositories/ticket_store.py +377 -0
- specsmither/db/repositories/work_stores.py +362 -0
- specsmither/dispatch/__init__.py +9 -0
- specsmither/dispatch/envelopes.py +338 -0
- specsmither/dispatch/error_guidance.py +449 -0
- specsmither/dispatch/facade.py +564 -0
- specsmither/domain/__init__.py +6 -0
- specsmither/domain/enums.py +266 -0
- specsmither/domain/records.py +489 -0
- specsmither/domain/status.py +102 -0
- specsmither/lifecycle/__init__.py +6 -0
- specsmither/lifecycle/audit/__init__.py +26 -0
- specsmither/lifecycle/audit/action_builder.py +219 -0
- specsmither/lifecycle/audit/operation_mapper.py +128 -0
- specsmither/lifecycle/audit/transition_builder.py +69 -0
- specsmither/lifecycle/dispatch.py +129 -0
- specsmither/lifecycle/gate.py +354 -0
- specsmither/lifecycle/guidance/__init__.py +9 -0
- specsmither/lifecycle/guidance/compose.py +588 -0
- specsmither/lifecycle/guidance/types.py +130 -0
- specsmither/lifecycle/operations_registry.py +471 -0
- specsmither/lifecycle/ports.py +379 -0
- specsmither/lifecycle/prechecks/__init__.py +72 -0
- specsmither/lifecycle/prechecks/blueprint_epic_ratio.py +70 -0
- specsmither/lifecycle/prechecks/cascade_rules.py +70 -0
- specsmither/lifecycle/prechecks/count_bounds.py +134 -0
- specsmither/lifecycle/prechecks/cross_cut_references.py +62 -0
- specsmither/lifecycle/prechecks/dependencies_batch.py +223 -0
- specsmither/lifecycle/prechecks/gate_currently_passing.py +72 -0
- specsmither/lifecycle/prechecks/operation_allowed.py +87 -0
- specsmither/lifecycle/prechecks/result.py +75 -0
- specsmither/lifecycle/prechecks/schema_validate.py +164 -0
- specsmither/lifecycle/prechecks/spec_status_check.py +89 -0
- specsmither/lifecycle/state_machine.py +90 -0
- specsmither/lifecycle/verbs/__init__.py +13 -0
- specsmither/lifecycle/verbs/_common.py +74 -0
- specsmither/lifecycle/verbs/action.py +412 -0
- specsmither/lifecycle/verbs/approve.py +191 -0
- specsmither/lifecycle/verbs/complete.py +215 -0
- specsmither/lifecycle/verbs/inspect.py +106 -0
- specsmither/lifecycle/verbs/reject.py +88 -0
- specsmither/lifecycle/verbs/reject_with_feedback.py +102 -0
- specsmither/lifecycle/verbs/start.py +271 -0
- specsmither/lifecycle/verbs/support.py +208 -0
- specsmither/lifecycle/verbs/types.py +118 -0
- specsmither/lifecycle/write_plan.py +1009 -0
- specsmither/mcp/__init__.py +14 -0
- specsmither/mcp/server.py +395 -0
- specsmither/operations/__init__.py +4 -0
- specsmither/operations/crud.py +863 -0
- specsmither/operations/errors.py +246 -0
- specsmither/operations/lookup.py +203 -0
- specsmither/operations/project.py +88 -0
- specsmither/operations/pull_request.py +109 -0
- specsmither/operations/queries.py +713 -0
- specsmither/operations/reopen.py +65 -0
- specsmither/operations/reports.py +1558 -0
- specsmither/operations/search.py +623 -0
- specsmither/operations/workspace.py +212 -0
- specsmither/py.typed +0 -0
- specsmither/rollups/__init__.py +3 -0
- specsmither/rollups/aggregate.py +795 -0
- specsmither/rollups/counts.py +527 -0
- specsmither/rollups/recompute.py +441 -0
- specsmither/toon/__init__.py +16 -0
- specsmither/toon/encoder.py +664 -0
- specsmither/tui/__init__.py +22 -0
- specsmither/tui/__main__.py +8 -0
- specsmither/tui/app.py +752 -0
- specsmither/tui/app.tcss +158 -0
- specsmither/tui/data.py +762 -0
- specsmither/tui/panels.py +553 -0
- specsmither/tui/scaffold/__init__.py +21 -0
- specsmither/tui/scaffold/emitter.py +195 -0
- specsmither/tui/scaffold/templates.py +221 -0
- specsmither/tui/screens/__init__.py +15 -0
- specsmither/tui/screens/dag_view.py +44 -0
- specsmither/tui/screens/planning_monitor.py +56 -0
- specsmither/tui/screens/search.py +75 -0
- specsmither/tui/screens/spec_browser.py +185 -0
- specsmither/tui/screens/work_progress.py +43 -0
- specsmither/tui/ui_common.py +94 -0
- specsmither/tui/widgets.py +319 -0
- specsmither-0.1.0.dist-info/METADATA +112 -0
- specsmither-0.1.0.dist-info/RECORD +117 -0
- specsmither-0.1.0.dist-info/WHEEL +4 -0
- specsmither-0.1.0.dist-info/entry_points.txt +3 -0
- specsmither-0.1.0.dist-info/licenses/LICENSE +201 -0
specsmither/__init__.py
ADDED
|
@@ -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,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
|
+
)
|