@event4u/agent-config 2.10.0 → 2.12.0
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.
- package/.agent-src/commands/agents.md +1 -0
- package/.agent-src/commands/challenge-me.md +1 -0
- package/.agent-src/commands/chat-history.md +1 -0
- package/.agent-src/commands/context.md +1 -0
- package/.agent-src/commands/council.md +1 -0
- package/.agent-src/commands/feature.md +1 -0
- package/.agent-src/commands/fix.md +1 -0
- package/.agent-src/commands/grill-me.md +1 -0
- package/.agent-src/commands/judge.md +1 -0
- package/.agent-src/commands/memory.md +1 -0
- package/.agent-src/commands/module.md +1 -0
- package/.agent-src/commands/onboard.md +32 -4
- package/.agent-src/commands/optimize.md +1 -0
- package/.agent-src/commands/override.md +1 -0
- package/.agent-src/commands/roadmap.md +1 -0
- package/.agent-src/commands/tests.md +1 -0
- package/.agent-src/skills/canvas-design/SKILL.md +132 -0
- package/.agent-src/skills/canvas-design/evals/triggers.json +16 -0
- package/.agent-src/skills/doc-coauthoring/SKILL.md +129 -0
- package/.agent-src/skills/doc-coauthoring/evals/triggers.json +16 -0
- package/.agent-src/skills/nextjs-patterns/SKILL.md +203 -0
- package/.agent-src/skills/skill-writing/SKILL.md +101 -16
- package/.agent-src/skills/sql-writing/SKILL.md +1 -1
- package/.agent-src/skills/symfony-workflow/SKILL.md +173 -0
- package/.agent-src/templates/scripts/work_engine/hook_bootstrap.py +4 -0
- package/.agent-src/templates/scripts/work_engine/hooks/builtin/__init__.py +3 -0
- package/.agent-src/templates/scripts/work_engine/hooks/builtin/decision_gate.py +162 -0
- package/.agent-src/templates/scripts/work_engine/hooks/settings.py +24 -6
- package/.agent-src/templates/scripts/work_engine/scoring/decision_engine.py +351 -0
- package/.claude-plugin/marketplace.json +5 -1
- package/CHANGELOG.md +68 -0
- package/README.md +37 -8
- package/config/agent-settings.template.yml +66 -0
- package/docs/architecture.md +1 -1
- package/docs/contracts/STABILITY.md +16 -0
- package/docs/contracts/adr-chat-history-split.md +1 -0
- package/docs/contracts/adr-forecast-construction-shape.md +1 -0
- package/docs/contracts/adr-gtm-context-spine.md +1 -0
- package/docs/contracts/adr-level-6-productization.md +147 -0
- package/docs/contracts/adr-settings-sync-engine.md +1 -0
- package/docs/contracts/adr-wing4-context-spine.md +1 -0
- package/docs/contracts/agent-memory-contract.md +1 -0
- package/docs/contracts/agents-md-tech-stack.md +1 -0
- package/docs/contracts/audit-log-v1.md +1 -0
- package/docs/contracts/command-clusters.md +1 -0
- package/docs/contracts/command-surface-tiers.md +1 -0
- package/docs/contracts/context-paths.md +1 -0
- package/docs/contracts/cost-profile-defaults.md +105 -0
- package/docs/contracts/cross-wing-handoff.md +1 -0
- package/docs/contracts/decision-engine-gates.md +115 -0
- package/docs/contracts/decision-trace-v1.md +1 -0
- package/docs/contracts/file-ownership-matrix.md +1 -0
- package/docs/contracts/hook-architecture-v1.md +1 -0
- package/docs/contracts/implement-ticket-flow.md +1 -0
- package/docs/contracts/installed-tools-lockfile.md +1 -0
- package/docs/contracts/kernel-membership.md +1 -0
- package/docs/contracts/linear-ai-rules-inclusion.md +1 -0
- package/docs/contracts/linear-ai-three-layers.md +1 -0
- package/docs/contracts/linter-structural-model.md +1 -0
- package/docs/contracts/load-context-budget-model.md +1 -0
- package/docs/contracts/load-context-schema.md +1 -0
- package/docs/contracts/memory-visibility-v1.md +1 -0
- package/docs/contracts/one-off-script-lifecycle.md +1 -0
- package/docs/contracts/orchestration-dsl-v1.md +1 -0
- package/docs/contracts/package-self-orientation.md +1 -0
- package/docs/contracts/persona-schema.md +1 -0
- package/docs/contracts/release-trunk-sync.md +104 -0
- package/docs/contracts/roadmap-complexity-standard.md +1 -0
- package/docs/contracts/rule-classification.md +1 -0
- package/docs/contracts/rule-interactions.md +26 -0
- package/docs/contracts/rule-priority-hierarchy.md +1 -0
- package/docs/contracts/rule-router.md +1 -0
- package/docs/contracts/settings-sync-yaml-subset.md +1 -0
- package/docs/contracts/skill-domains.md +1 -0
- package/docs/contracts/tier-3-contrib-plugin.md +1 -0
- package/docs/contracts/ui-stack-extension.md +1 -0
- package/docs/contracts/ui-track-flow.md +1 -0
- package/docs/customization.md +1 -1
- package/docs/getting-started.md +3 -1
- package/docs/installation.md +8 -6
- package/package.json +1 -1
- package/scripts/ai_council/clients.py +17 -4
- package/scripts/ai_council/orchestrator.py +6 -2
- package/scripts/check_beta_review_markers.py +127 -0
- package/scripts/check_references.py +25 -0
- package/scripts/check_release_trunk_sync.py +152 -0
- package/scripts/council_cli.py +36 -5
- package/scripts/install.py +3 -3
- package/scripts/run_skill_evals.py +185 -0
- package/scripts/schemas/command.schema.json +5 -0
- package/scripts/schemas/skill.schema.json +4 -0
- package/scripts/skill_linter.py +82 -3
- package/scripts/smoke_quickstart.py +134 -0
- package/scripts/validate_decision_engine.py +124 -0
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
"""``DecisionGateHook`` — refuse to advance when an opt-in gate fires.
|
|
2
|
+
|
|
3
|
+
Bridges :mod:`work_engine.scoring.decision_engine` into the dispatcher
|
|
4
|
+
hook bus. Reads the gate config from
|
|
5
|
+
:class:`work_engine.hooks.settings.HookSettings.decision_engine` and
|
|
6
|
+
fires on ``AFTER_STEP`` only for the phase each gate owns.
|
|
7
|
+
|
|
8
|
+
Gate-conflict resolution and the non-TTY timeout protocol live in
|
|
9
|
+
``docs/contracts/decision-engine-gates.md``. The hook only consumes
|
|
10
|
+
them; it never re-implements gate logic.
|
|
11
|
+
|
|
12
|
+
Three actions, mapped 1:1 from :func:`evaluate_gates`:
|
|
13
|
+
|
|
14
|
+
- ``stop`` → raise :class:`HookHalt` with a numbered-option
|
|
15
|
+
surface. Dispatcher returns ``BLOCKED``.
|
|
16
|
+
- ``warn`` → raise :class:`HookError` so the runner logs the
|
|
17
|
+
reason and the step proceeds.
|
|
18
|
+
- ``ask_timeout`` → non-interactive context; apply
|
|
19
|
+
``on_block_fallback`` and re-resolve to ``stop``
|
|
20
|
+
or ``warn``. ``block_reason=ask_timeout`` is
|
|
21
|
+
surfaced verbatim so the trace records it.
|
|
22
|
+
- ``ask`` → interactive context; for the CLI integration this
|
|
23
|
+
collapses to ``stop`` with the prompt surface,
|
|
24
|
+
matching how every other ``HookHalt`` is rendered.
|
|
25
|
+
The interactive resumption path is owned by the
|
|
26
|
+
CLI, not by the hook.
|
|
27
|
+
|
|
28
|
+
Default-off: when ``settings.decision_engine`` is ``None`` or every
|
|
29
|
+
gate is ``off`` the hook short-circuits without examining state.
|
|
30
|
+
"""
|
|
31
|
+
from __future__ import annotations
|
|
32
|
+
|
|
33
|
+
from typing import Any
|
|
34
|
+
|
|
35
|
+
from ...scoring.decision_engine import (
|
|
36
|
+
DecisionEngineSettings,
|
|
37
|
+
GateDecision,
|
|
38
|
+
evaluate_gates,
|
|
39
|
+
)
|
|
40
|
+
from ...scoring.decision_trace import (
|
|
41
|
+
derive_confidence_band,
|
|
42
|
+
derive_risk_class,
|
|
43
|
+
summarise_memory,
|
|
44
|
+
summarise_verify,
|
|
45
|
+
)
|
|
46
|
+
from ..context import HookContext
|
|
47
|
+
from ..events import HookEvent
|
|
48
|
+
from ..exceptions import HookError, HookHalt
|
|
49
|
+
from ..registry import HookRegistry
|
|
50
|
+
|
|
51
|
+
_BLOCK_REASON_PREFIX = "decision_gate"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class DecisionGateHook:
|
|
55
|
+
"""Evaluate decision-engine gates on every ``AFTER_STEP``.
|
|
56
|
+
|
|
57
|
+
Parameters
|
|
58
|
+
----------
|
|
59
|
+
settings:
|
|
60
|
+
Resolved :class:`DecisionEngineSettings`. The hook stores it as
|
|
61
|
+
a frozen reference; tests pass a fresh instance per scenario.
|
|
62
|
+
"""
|
|
63
|
+
|
|
64
|
+
def __init__(self, settings: DecisionEngineSettings) -> None:
|
|
65
|
+
self._settings = settings
|
|
66
|
+
|
|
67
|
+
def register(self, registry: HookRegistry) -> None:
|
|
68
|
+
"""Register the gate callback on ``AFTER_STEP``."""
|
|
69
|
+
registry.register(HookEvent.AFTER_STEP, self._evaluate)
|
|
70
|
+
|
|
71
|
+
# -- lifecycle callback ------------------------------------------
|
|
72
|
+
|
|
73
|
+
def _evaluate(self, ctx: HookContext) -> None:
|
|
74
|
+
if not self._settings.any_gate_active:
|
|
75
|
+
return
|
|
76
|
+
phase = ctx.step_name
|
|
77
|
+
if not phase:
|
|
78
|
+
return
|
|
79
|
+
delivery = ctx.delivery
|
|
80
|
+
memory = summarise_memory(getattr(delivery, "memory", None))
|
|
81
|
+
verify = summarise_verify(getattr(delivery, "verify", None))
|
|
82
|
+
ambiguity = bool(getattr(delivery, "questions", None))
|
|
83
|
+
decision = evaluate_gates(
|
|
84
|
+
self._settings,
|
|
85
|
+
phase=phase,
|
|
86
|
+
confidence_band=derive_confidence_band(
|
|
87
|
+
memory_hits=memory["hits"],
|
|
88
|
+
verify_claims=verify["claims"],
|
|
89
|
+
verify_first_try_passes=verify["first_try_passes"],
|
|
90
|
+
ambiguity_flag=ambiguity,
|
|
91
|
+
),
|
|
92
|
+
risk_class=derive_risk_class(
|
|
93
|
+
getattr(delivery, "changes", None),
|
|
94
|
+
),
|
|
95
|
+
memory_hits=memory["hits"],
|
|
96
|
+
)
|
|
97
|
+
if decision is None:
|
|
98
|
+
return
|
|
99
|
+
self._apply(decision)
|
|
100
|
+
|
|
101
|
+
# -- action dispatch ----------------------------------------------
|
|
102
|
+
|
|
103
|
+
def _apply(self, decision: GateDecision) -> None:
|
|
104
|
+
action = decision.action
|
|
105
|
+
if action == "warn":
|
|
106
|
+
raise HookError(self._format_reason(decision))
|
|
107
|
+
if action == "ask_timeout":
|
|
108
|
+
fallback = self._settings.on_block_fallback
|
|
109
|
+
if fallback == "warn":
|
|
110
|
+
raise HookError(self._format_reason(decision, suffix="ask_timeout"))
|
|
111
|
+
raise HookHalt(
|
|
112
|
+
f"{_BLOCK_REASON_PREFIX}:{decision.gate_id}:ask_timeout",
|
|
113
|
+
surface=self._surface(decision, suffix="ask_timeout"),
|
|
114
|
+
)
|
|
115
|
+
raise HookHalt(
|
|
116
|
+
f"{_BLOCK_REASON_PREFIX}:{decision.gate_id}",
|
|
117
|
+
surface=self._surface(decision),
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
# -- formatting helpers -------------------------------------------
|
|
121
|
+
|
|
122
|
+
@staticmethod
|
|
123
|
+
def _format_reason(decision: GateDecision, *, suffix: str = "") -> str:
|
|
124
|
+
tag = f"{_BLOCK_REASON_PREFIX}:{decision.gate_id}"
|
|
125
|
+
if suffix:
|
|
126
|
+
tag = f"{tag}:{suffix}"
|
|
127
|
+
return f"{tag} — {decision.reason}"
|
|
128
|
+
|
|
129
|
+
@staticmethod
|
|
130
|
+
def _surface(
|
|
131
|
+
decision: GateDecision, *, suffix: str = "",
|
|
132
|
+
) -> list[str]:
|
|
133
|
+
header = f"Decision-engine gate fired: {decision.gate_id} (phase={decision.phase})"
|
|
134
|
+
if suffix:
|
|
135
|
+
header = f"{header} [{suffix}]"
|
|
136
|
+
return [
|
|
137
|
+
header,
|
|
138
|
+
f"Reason: {decision.reason}",
|
|
139
|
+
"1) Address the gate condition and resume.",
|
|
140
|
+
"2) Lower the gate in `.agent-settings.yml` "
|
|
141
|
+
"(`decision_engine` block) and resume.",
|
|
142
|
+
"3) Abort the run.",
|
|
143
|
+
]
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def build_decision_gate_hook(
|
|
147
|
+
settings: Any,
|
|
148
|
+
) -> DecisionGateHook | None:
|
|
149
|
+
"""Construct the hook from a :class:`DecisionEngineSettings`-like
|
|
150
|
+
object. Returns ``None`` when the config is absent or every gate is
|
|
151
|
+
``off``; the bootstrap layer then skips registration entirely.
|
|
152
|
+
"""
|
|
153
|
+
if settings is None:
|
|
154
|
+
return None
|
|
155
|
+
if not isinstance(settings, DecisionEngineSettings):
|
|
156
|
+
return None
|
|
157
|
+
if not settings.any_gate_active:
|
|
158
|
+
return None
|
|
159
|
+
return DecisionGateHook(settings)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
__all__ = ["DecisionGateHook", "build_decision_gate_hook"]
|
|
@@ -28,6 +28,11 @@ from pathlib import Path
|
|
|
28
28
|
from typing import Any
|
|
29
29
|
|
|
30
30
|
from work_engine._lib.agent_settings import load_agent_settings
|
|
31
|
+
from work_engine.scoring.decision_engine import (
|
|
32
|
+
DecisionEngineConfigError,
|
|
33
|
+
DecisionEngineSettings,
|
|
34
|
+
parse as _parse_decision_engine,
|
|
35
|
+
)
|
|
31
36
|
|
|
32
37
|
DEFAULT_SETTINGS_FILE = ".agent-settings.yml"
|
|
33
38
|
DEFAULT_CHAT_HISTORY_SCRIPT = "scripts/chat_history.py"
|
|
@@ -41,6 +46,11 @@ class HookSettings:
|
|
|
41
46
|
empty regardless of the per-hook fields; this is the default when no
|
|
42
47
|
settings file exists or no ``hooks`` block is declared, and it is
|
|
43
48
|
what keeps golden-replay tests byte-stable.
|
|
49
|
+
|
|
50
|
+
``decision_engine`` carries the parsed gate config so
|
|
51
|
+
:class:`DecisionGateHook` can read it without re-parsing
|
|
52
|
+
``.agent-settings.yml``. When the block is absent or malformed the
|
|
53
|
+
field stays at the default (every gate ``off``).
|
|
44
54
|
"""
|
|
45
55
|
|
|
46
56
|
enabled: bool = False
|
|
@@ -54,6 +64,7 @@ class HookSettings:
|
|
|
54
64
|
cost_profile: str = "standard"
|
|
55
65
|
chat_history_enabled: bool = False
|
|
56
66
|
chat_history_script: str = DEFAULT_CHAT_HISTORY_SCRIPT
|
|
67
|
+
decision_engine: DecisionEngineSettings = DecisionEngineSettings()
|
|
57
68
|
|
|
58
69
|
|
|
59
70
|
_DEFAULT = HookSettings()
|
|
@@ -89,8 +100,18 @@ def _settings_from_raw(data: dict[str, Any]) -> HookSettings:
|
|
|
89
100
|
if not isinstance(hooks, dict):
|
|
90
101
|
return _DEFAULT
|
|
91
102
|
enabled = _coerce_bool(hooks.get("enabled"), False)
|
|
103
|
+
|
|
104
|
+
decision_engine_raw = data.get("decision_engine")
|
|
105
|
+
try:
|
|
106
|
+
decision_engine_settings = _parse_decision_engine(decision_engine_raw)
|
|
107
|
+
except DecisionEngineConfigError:
|
|
108
|
+
decision_engine_settings = DecisionEngineSettings()
|
|
109
|
+
|
|
92
110
|
if not enabled:
|
|
93
|
-
return HookSettings(
|
|
111
|
+
return HookSettings(
|
|
112
|
+
enabled=False,
|
|
113
|
+
decision_engine=decision_engine_settings,
|
|
114
|
+
)
|
|
94
115
|
|
|
95
116
|
chat_section = hooks.get("chat_history")
|
|
96
117
|
if isinstance(chat_section, dict):
|
|
@@ -108,11 +129,7 @@ def _settings_from_raw(data: dict[str, Any]) -> HookSettings:
|
|
|
108
129
|
and _coerce_bool(global_chat.get("enabled"), False)
|
|
109
130
|
)
|
|
110
131
|
|
|
111
|
-
|
|
112
|
-
decision_trace_on = (
|
|
113
|
-
isinstance(decision_engine, dict)
|
|
114
|
-
and _coerce_bool(decision_engine.get("surface_traces"), False)
|
|
115
|
-
)
|
|
132
|
+
decision_trace_on = decision_engine_settings.surface_traces
|
|
116
133
|
|
|
117
134
|
memory_section = data.get("memory")
|
|
118
135
|
visibility_off = False
|
|
@@ -154,6 +171,7 @@ def _settings_from_raw(data: dict[str, Any]) -> HookSettings:
|
|
|
154
171
|
cost_profile=cost_profile,
|
|
155
172
|
chat_history_enabled=chat_block_enabled and global_chat_on,
|
|
156
173
|
chat_history_script=chat_script,
|
|
174
|
+
decision_engine=decision_engine_settings,
|
|
157
175
|
)
|
|
158
176
|
|
|
159
177
|
|
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
"""Decision-engine gates — schema, validation, and per-phase evaluation.
|
|
2
|
+
|
|
3
|
+
Reads the optional ``decision_engine:`` block from ``.agent-settings.yml``.
|
|
4
|
+
Absent block = current behaviour (observe-only, no gates fire).
|
|
5
|
+
|
|
6
|
+
Schema (all keys optional; the parser rejects unknown keys hard):
|
|
7
|
+
|
|
8
|
+
- ``surface_traces`` (bool, default ``false``) — opt-in for
|
|
9
|
+
``DecisionTraceHook``. Predates the gates; lives here so the
|
|
10
|
+
``decision_engine:`` block has one source-of-truth schema.
|
|
11
|
+
- ``min_confidence`` (``low``/``medium``/``high``/``off``, default
|
|
12
|
+
``off``) — confidence-band floor; Phase=Plan refuses to advance
|
|
13
|
+
when the band is below.
|
|
14
|
+
- ``block_on_risk`` (``low``/``medium``/``high``/``off``, default
|
|
15
|
+
``off``) — risk-class ceiling; Phase=Implement refuses to advance
|
|
16
|
+
when risk exceeds.
|
|
17
|
+
- ``require_memory_hits`` (bool, default ``false``) — Phase=Refine
|
|
18
|
+
demands ``memory_hits >= 1``.
|
|
19
|
+
- ``on_block`` (``stop``/``ask``/``warn``, default ``stop``) —
|
|
20
|
+
what happens when a gate fires.
|
|
21
|
+
- ``ask_timeout_seconds`` (int, default ``30``) — timeout when
|
|
22
|
+
``on_block=ask`` runs in a non-interactive context (no TTY, or
|
|
23
|
+
``CI=true``).
|
|
24
|
+
- ``on_block_fallback`` (``stop``/``warn``, default ``stop``) —
|
|
25
|
+
resolution after ``ask_timeout`` elapses.
|
|
26
|
+
|
|
27
|
+
Gate-conflict resolution (first match wins, only one gate fires per
|
|
28
|
+
phase):
|
|
29
|
+
|
|
30
|
+
1. ``block_on_risk`` (highest impact)
|
|
31
|
+
2. ``require_memory_hits``
|
|
32
|
+
3. ``min_confidence`` (lowest impact)
|
|
33
|
+
|
|
34
|
+
See ``docs/contracts/decision-engine-gates.md`` for the full
|
|
35
|
+
priority matrix.
|
|
36
|
+
"""
|
|
37
|
+
from __future__ import annotations
|
|
38
|
+
|
|
39
|
+
import os
|
|
40
|
+
from dataclasses import dataclass
|
|
41
|
+
from typing import Any, Callable
|
|
42
|
+
|
|
43
|
+
ALLOWED_KEYS: frozenset[str] = frozenset({
|
|
44
|
+
"surface_traces",
|
|
45
|
+
"min_confidence",
|
|
46
|
+
"block_on_risk",
|
|
47
|
+
"require_memory_hits",
|
|
48
|
+
"on_block",
|
|
49
|
+
"ask_timeout_seconds",
|
|
50
|
+
"on_block_fallback",
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
_LEVEL_VALUES: frozenset[str] = frozenset({"low", "medium", "high", "off"})
|
|
54
|
+
_LEVEL_RANK: dict[str, int] = {"low": 1, "medium": 2, "high": 3}
|
|
55
|
+
_ON_BLOCK_VALUES: frozenset[str] = frozenset({"stop", "ask", "warn"})
|
|
56
|
+
_FALLBACK_VALUES: frozenset[str] = frozenset({"stop", "warn"})
|
|
57
|
+
|
|
58
|
+
GATE_PRIORITY: tuple[str, ...] = (
|
|
59
|
+
"block_on_risk",
|
|
60
|
+
"require_memory_hits",
|
|
61
|
+
"min_confidence",
|
|
62
|
+
)
|
|
63
|
+
"""Conflict-resolution order. Highest-impact gate first; the first
|
|
64
|
+
firing gate emits its reason and downstream gates are skipped."""
|
|
65
|
+
|
|
66
|
+
_PHASE_FOR_GATE: dict[str, str] = {
|
|
67
|
+
"block_on_risk": "implement",
|
|
68
|
+
"require_memory_hits": "refine",
|
|
69
|
+
"min_confidence": "plan",
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class DecisionEngineConfigError(ValueError):
|
|
74
|
+
"""Raised when the ``decision_engine:`` block is malformed."""
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@dataclass(frozen=True)
|
|
78
|
+
class DecisionEngineSettings:
|
|
79
|
+
"""Resolved ``decision_engine:`` block. Frozen to keep gate
|
|
80
|
+
evaluations replay-stable."""
|
|
81
|
+
|
|
82
|
+
surface_traces: bool = False
|
|
83
|
+
min_confidence: str = "off"
|
|
84
|
+
block_on_risk: str = "off"
|
|
85
|
+
require_memory_hits: bool = False
|
|
86
|
+
on_block: str = "stop"
|
|
87
|
+
ask_timeout_seconds: int = 30
|
|
88
|
+
on_block_fallback: str = "stop"
|
|
89
|
+
|
|
90
|
+
@property
|
|
91
|
+
def any_gate_active(self) -> bool:
|
|
92
|
+
"""True when at least one gate is enabled."""
|
|
93
|
+
return (
|
|
94
|
+
self.min_confidence != "off"
|
|
95
|
+
or self.block_on_risk != "off"
|
|
96
|
+
or self.require_memory_hits
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
@dataclass(frozen=True)
|
|
101
|
+
class GateDecision:
|
|
102
|
+
"""Outcome of one gate evaluation. ``action`` is the resolved
|
|
103
|
+
response after applying ``on_block`` plus the non-TTY fallback."""
|
|
104
|
+
|
|
105
|
+
gate_id: str
|
|
106
|
+
phase: str
|
|
107
|
+
reason: str
|
|
108
|
+
action: str # "stop" | "warn" | "ask" | "ask_timeout"
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def parse(data: Any) -> DecisionEngineSettings:
|
|
112
|
+
"""Parse a ``decision_engine`` block into validated settings.
|
|
113
|
+
|
|
114
|
+
Returns defaults when ``data`` is ``None`` (block absent) or an
|
|
115
|
+
empty mapping. Raises :class:`DecisionEngineConfigError` on
|
|
116
|
+
unknown keys or invalid values.
|
|
117
|
+
"""
|
|
118
|
+
if data is None:
|
|
119
|
+
return DecisionEngineSettings()
|
|
120
|
+
if not isinstance(data, dict):
|
|
121
|
+
raise DecisionEngineConfigError(
|
|
122
|
+
"decision_engine: must be a mapping, got "
|
|
123
|
+
f"{type(data).__name__}"
|
|
124
|
+
)
|
|
125
|
+
unknown = set(data.keys()) - ALLOWED_KEYS
|
|
126
|
+
if unknown:
|
|
127
|
+
raise DecisionEngineConfigError(
|
|
128
|
+
"decision_engine: unknown key(s): "
|
|
129
|
+
+ ", ".join(sorted(unknown))
|
|
130
|
+
+ ". Allowed: " + ", ".join(sorted(ALLOWED_KEYS))
|
|
131
|
+
)
|
|
132
|
+
return DecisionEngineSettings(
|
|
133
|
+
surface_traces=_coerce_bool(data.get("surface_traces"), False),
|
|
134
|
+
min_confidence=_coerce_level(
|
|
135
|
+
data.get("min_confidence", "off"), "min_confidence",
|
|
136
|
+
),
|
|
137
|
+
block_on_risk=_coerce_level(
|
|
138
|
+
data.get("block_on_risk", "off"), "block_on_risk",
|
|
139
|
+
),
|
|
140
|
+
require_memory_hits=_coerce_bool(
|
|
141
|
+
data.get("require_memory_hits"), False,
|
|
142
|
+
),
|
|
143
|
+
on_block=_coerce_choice(
|
|
144
|
+
data.get("on_block", "stop"), "on_block", _ON_BLOCK_VALUES,
|
|
145
|
+
),
|
|
146
|
+
ask_timeout_seconds=_coerce_int(
|
|
147
|
+
data.get("ask_timeout_seconds", 30), "ask_timeout_seconds",
|
|
148
|
+
),
|
|
149
|
+
on_block_fallback=_coerce_choice(
|
|
150
|
+
data.get("on_block_fallback", "stop"),
|
|
151
|
+
"on_block_fallback", _FALLBACK_VALUES,
|
|
152
|
+
),
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _coerce_bool(value: Any, default: bool) -> bool:
|
|
158
|
+
if isinstance(value, bool):
|
|
159
|
+
return value
|
|
160
|
+
if value is None:
|
|
161
|
+
return default
|
|
162
|
+
if isinstance(value, str):
|
|
163
|
+
s = value.strip().lower()
|
|
164
|
+
if s in ("true", "yes", "on", "1"):
|
|
165
|
+
return True
|
|
166
|
+
if s in ("false", "no", "off", "0"):
|
|
167
|
+
return False
|
|
168
|
+
raise DecisionEngineConfigError(
|
|
169
|
+
f"decision_engine.{value!r}: expected bool"
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _coerce_level(value: Any, key: str) -> str:
|
|
174
|
+
if value is None:
|
|
175
|
+
return "off"
|
|
176
|
+
# YAML 1.1 parses unquoted ``off`` as boolean False; accept it as
|
|
177
|
+
# the off sentinel so writers don't have to quote. Boolean True
|
|
178
|
+
# stays rejected — there is no defensible level it maps to.
|
|
179
|
+
if isinstance(value, bool):
|
|
180
|
+
if value is False:
|
|
181
|
+
return "off"
|
|
182
|
+
raise DecisionEngineConfigError(
|
|
183
|
+
f"decision_engine.{key}: boolean True is not a valid level "
|
|
184
|
+
"(quote a string: low/medium/high/off)"
|
|
185
|
+
)
|
|
186
|
+
if not isinstance(value, str):
|
|
187
|
+
raise DecisionEngineConfigError(
|
|
188
|
+
f"decision_engine.{key}: expected string, got "
|
|
189
|
+
f"{type(value).__name__}"
|
|
190
|
+
)
|
|
191
|
+
s = value.strip().lower()
|
|
192
|
+
if s not in _LEVEL_VALUES:
|
|
193
|
+
raise DecisionEngineConfigError(
|
|
194
|
+
f"decision_engine.{key}: invalid value {value!r}. "
|
|
195
|
+
"Allowed: " + ", ".join(sorted(_LEVEL_VALUES))
|
|
196
|
+
)
|
|
197
|
+
return s
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _coerce_choice(value: Any, key: str, allowed: frozenset[str]) -> str:
|
|
201
|
+
if not isinstance(value, str):
|
|
202
|
+
raise DecisionEngineConfigError(
|
|
203
|
+
f"decision_engine.{key}: expected string, got "
|
|
204
|
+
f"{type(value).__name__}"
|
|
205
|
+
)
|
|
206
|
+
s = value.strip().lower()
|
|
207
|
+
if s not in allowed:
|
|
208
|
+
raise DecisionEngineConfigError(
|
|
209
|
+
f"decision_engine.{key}: invalid value {value!r}. "
|
|
210
|
+
"Allowed: " + ", ".join(sorted(allowed))
|
|
211
|
+
)
|
|
212
|
+
return s
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _coerce_int(value: Any, key: str) -> int:
|
|
216
|
+
if isinstance(value, bool):
|
|
217
|
+
raise DecisionEngineConfigError(
|
|
218
|
+
f"decision_engine.{key}: expected int, got bool"
|
|
219
|
+
)
|
|
220
|
+
if isinstance(value, int):
|
|
221
|
+
if value < 0:
|
|
222
|
+
raise DecisionEngineConfigError(
|
|
223
|
+
f"decision_engine.{key}: must be >= 0"
|
|
224
|
+
)
|
|
225
|
+
return value
|
|
226
|
+
raise DecisionEngineConfigError(
|
|
227
|
+
f"decision_engine.{key}: expected int, got "
|
|
228
|
+
f"{type(value).__name__}"
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def evaluate_gates(
|
|
233
|
+
settings: DecisionEngineSettings,
|
|
234
|
+
*,
|
|
235
|
+
phase: str,
|
|
236
|
+
confidence_band: str | None,
|
|
237
|
+
risk_class: str | None,
|
|
238
|
+
memory_hits: int,
|
|
239
|
+
is_interactive: Callable[[], bool] | None = None,
|
|
240
|
+
) -> GateDecision | None:
|
|
241
|
+
"""Evaluate gates for ``phase``. Returns the first firing gate, or
|
|
242
|
+
``None`` when no gate fires.
|
|
243
|
+
|
|
244
|
+
Conflict resolution follows :data:`GATE_PRIORITY` — only the first
|
|
245
|
+
matching gate's phase is considered. Each gate maps to exactly one
|
|
246
|
+
phase via :data:`_PHASE_FOR_GATE`.
|
|
247
|
+
"""
|
|
248
|
+
if not settings.any_gate_active:
|
|
249
|
+
return None
|
|
250
|
+
for gate_id in GATE_PRIORITY:
|
|
251
|
+
if _PHASE_FOR_GATE.get(gate_id) != phase:
|
|
252
|
+
continue
|
|
253
|
+
decision = _evaluate_single(
|
|
254
|
+
gate_id, settings,
|
|
255
|
+
confidence_band=confidence_band,
|
|
256
|
+
risk_class=risk_class,
|
|
257
|
+
memory_hits=memory_hits,
|
|
258
|
+
)
|
|
259
|
+
if decision is not None:
|
|
260
|
+
action = _resolve_action(settings, is_interactive)
|
|
261
|
+
return GateDecision(
|
|
262
|
+
gate_id=decision.gate_id,
|
|
263
|
+
phase=decision.phase,
|
|
264
|
+
reason=decision.reason,
|
|
265
|
+
action=action,
|
|
266
|
+
)
|
|
267
|
+
return None
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def _evaluate_single(
|
|
271
|
+
gate_id: str,
|
|
272
|
+
settings: DecisionEngineSettings,
|
|
273
|
+
*,
|
|
274
|
+
confidence_band: str | None,
|
|
275
|
+
risk_class: str | None,
|
|
276
|
+
memory_hits: int,
|
|
277
|
+
) -> GateDecision | None:
|
|
278
|
+
if gate_id == "min_confidence" and settings.min_confidence != "off":
|
|
279
|
+
floor = _LEVEL_RANK[settings.min_confidence]
|
|
280
|
+
actual = _LEVEL_RANK.get((confidence_band or "").lower(), 0)
|
|
281
|
+
if actual < floor:
|
|
282
|
+
return GateDecision(
|
|
283
|
+
gate_id=gate_id, phase="plan", action="stop",
|
|
284
|
+
reason=(
|
|
285
|
+
f"confidence_band={confidence_band!r} below floor "
|
|
286
|
+
f"min_confidence={settings.min_confidence!r}"
|
|
287
|
+
),
|
|
288
|
+
)
|
|
289
|
+
elif gate_id == "block_on_risk" and settings.block_on_risk != "off":
|
|
290
|
+
ceiling = _LEVEL_RANK[settings.block_on_risk]
|
|
291
|
+
actual = _LEVEL_RANK.get((risk_class or "").lower(), 0)
|
|
292
|
+
if actual >= ceiling:
|
|
293
|
+
return GateDecision(
|
|
294
|
+
gate_id=gate_id, phase="implement", action="stop",
|
|
295
|
+
reason=(
|
|
296
|
+
f"risk_class={risk_class!r} at/above ceiling "
|
|
297
|
+
f"block_on_risk={settings.block_on_risk!r}"
|
|
298
|
+
),
|
|
299
|
+
)
|
|
300
|
+
elif gate_id == "require_memory_hits" and settings.require_memory_hits:
|
|
301
|
+
if memory_hits < 1:
|
|
302
|
+
return GateDecision(
|
|
303
|
+
gate_id=gate_id, phase="refine", action="stop",
|
|
304
|
+
reason=(
|
|
305
|
+
f"memory_hits={memory_hits} but "
|
|
306
|
+
"require_memory_hits=true (need >= 1)"
|
|
307
|
+
),
|
|
308
|
+
)
|
|
309
|
+
return None
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def _resolve_action(
|
|
313
|
+
settings: DecisionEngineSettings,
|
|
314
|
+
is_interactive: Callable[[], bool] | None,
|
|
315
|
+
) -> str:
|
|
316
|
+
"""Map ``on_block`` to an action, applying the non-TTY fallback.
|
|
317
|
+
|
|
318
|
+
Non-interactive context = either ``is_interactive()`` returns
|
|
319
|
+
False, or the ``CI`` env var is truthy. ``on_block=ask`` collapses
|
|
320
|
+
to ``ask_timeout`` (consumer applies ``on_block_fallback``).
|
|
321
|
+
"""
|
|
322
|
+
if settings.on_block in ("stop", "warn"):
|
|
323
|
+
return settings.on_block
|
|
324
|
+
interactive = (
|
|
325
|
+
is_interactive() if is_interactive is not None
|
|
326
|
+
else _default_is_interactive()
|
|
327
|
+
)
|
|
328
|
+
if interactive:
|
|
329
|
+
return "ask"
|
|
330
|
+
return "ask_timeout"
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def _default_is_interactive() -> bool:
|
|
334
|
+
if os.environ.get("CI", "").strip().lower() in ("1", "true", "yes"):
|
|
335
|
+
return False
|
|
336
|
+
try:
|
|
337
|
+
import sys
|
|
338
|
+
return sys.stdin.isatty() and sys.stdout.isatty()
|
|
339
|
+
except (AttributeError, ValueError):
|
|
340
|
+
return False
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
__all__ = [
|
|
344
|
+
"ALLOWED_KEYS",
|
|
345
|
+
"GATE_PRIORITY",
|
|
346
|
+
"DecisionEngineConfigError",
|
|
347
|
+
"DecisionEngineSettings",
|
|
348
|
+
"GateDecision",
|
|
349
|
+
"evaluate_gates",
|
|
350
|
+
"parse",
|
|
351
|
+
]
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
},
|
|
7
7
|
"metadata": {
|
|
8
8
|
"description": "Shared agent configuration \u2014 skills for AI coding tools (Claude Code, Augment, Cursor, Cline, Windsurf, Gemini CLI).",
|
|
9
|
-
"version": "2.
|
|
9
|
+
"version": "2.12.0",
|
|
10
10
|
"keywords": [
|
|
11
11
|
"agent-config",
|
|
12
12
|
"skills",
|
|
@@ -71,6 +71,7 @@
|
|
|
71
71
|
"./.claude/skills/bug-fix",
|
|
72
72
|
"./.claude/skills/bug-investigate",
|
|
73
73
|
"./.claude/skills/build-buy-partner",
|
|
74
|
+
"./.claude/skills/canvas-design",
|
|
74
75
|
"./.claude/skills/challenge-me",
|
|
75
76
|
"./.claude/skills/challenge-me-vision",
|
|
76
77
|
"./.claude/skills/challenge-me-with-docs",
|
|
@@ -126,6 +127,7 @@
|
|
|
126
127
|
"./.claude/skills/devcontainer",
|
|
127
128
|
"./.claude/skills/developer-like-execution",
|
|
128
129
|
"./.claude/skills/discovery-interview",
|
|
130
|
+
"./.claude/skills/doc-coauthoring",
|
|
129
131
|
"./.claude/skills/docker",
|
|
130
132
|
"./.claude/skills/dto-creator",
|
|
131
133
|
"./.claude/skills/e2e-heal",
|
|
@@ -219,6 +221,7 @@
|
|
|
219
221
|
"./.claude/skills/module-explore",
|
|
220
222
|
"./.claude/skills/module-management",
|
|
221
223
|
"./.claude/skills/multi-tenancy",
|
|
224
|
+
"./.claude/skills/nextjs-patterns",
|
|
222
225
|
"./.claude/skills/okr-tree-modeling",
|
|
223
226
|
"./.claude/skills/onboard",
|
|
224
227
|
"./.claude/skills/onboarding-design",
|
|
@@ -316,6 +319,7 @@
|
|
|
316
319
|
"./.claude/skills/sql-writing",
|
|
317
320
|
"./.claude/skills/stakeholder-tradeoff",
|
|
318
321
|
"./.claude/skills/subagent-orchestration",
|
|
322
|
+
"./.claude/skills/symfony-workflow",
|
|
319
323
|
"./.claude/skills/sync-agent-settings",
|
|
320
324
|
"./.claude/skills/sync-gitignore",
|
|
321
325
|
"./.claude/skills/sync-gitignore-fix",
|