@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
package/scripts/skill_linter.py
CHANGED
|
@@ -775,8 +775,14 @@ def lint_skill(path: Path, text: str) -> LintResult:
|
|
|
775
775
|
# is *both* large AND prose-dominant OR ships ≥ 2 independently invocable
|
|
776
776
|
# procedures. Reference catalogues (quality-tools 411 L / density 0.83)
|
|
777
777
|
# pass; multi-procedure skills are flagged for split.
|
|
778
|
+
#
|
|
779
|
+
# Frontmatter opt-out: `meta_skill: true` exempts a skill from the size
|
|
780
|
+
# warn when the skill's purpose *is* breadth (skill-writing, agent-docs-
|
|
781
|
+
# writing, skill-reviewer, etc.). Meta-skills inherently bundle multiple
|
|
782
|
+
# procedures and inline examples.
|
|
778
783
|
total_lines = len(text.splitlines())
|
|
779
|
-
|
|
784
|
+
is_meta_skill = bool(fm) and re.search(r"^meta_skill:\s*true\s*$", fm, re.MULTILINE)
|
|
785
|
+
if total_lines > 400 and not is_meta_skill:
|
|
780
786
|
density = _density_score(text)
|
|
781
787
|
procedures = _count_procedure_sections(text)
|
|
782
788
|
if density < 0.6 or procedures >= 2:
|
|
@@ -832,6 +838,12 @@ def lint_skill(path: Path, text: str) -> LintResult:
|
|
|
832
838
|
f"{meaningful_steps} steps) — may lack its own executable workflow"))
|
|
833
839
|
suggestions.append("Expand the skill so it remains executable without opening a guideline")
|
|
834
840
|
|
|
841
|
+
# --- evals.json schema validator ---
|
|
842
|
+
# When a skill ships sibling `evals/evals.json` (quantitative behavior
|
|
843
|
+
# eval per skill-writing § 7), validate its shape. Triggers.json is a
|
|
844
|
+
# separate concern handled elsewhere. All issues here are WARN.
|
|
845
|
+
issues.extend(validate_evals_json(path))
|
|
846
|
+
|
|
835
847
|
return LintResult(
|
|
836
848
|
file=str(path),
|
|
837
849
|
artifact_type="skill",
|
|
@@ -841,6 +853,64 @@ def lint_skill(path: Path, text: str) -> LintResult:
|
|
|
841
853
|
)
|
|
842
854
|
|
|
843
855
|
|
|
856
|
+
def validate_evals_json(skill_path: Path) -> list[Issue]:
|
|
857
|
+
"""Validate `{skill_dir}/evals/evals.json` against the schema declared
|
|
858
|
+
in `skill-writing` § 7. Returns WARN-level issues only; never blocks.
|
|
859
|
+
Skipped entirely when the file is absent."""
|
|
860
|
+
evals_path = skill_path.parent / "evals" / "evals.json"
|
|
861
|
+
if not evals_path.is_file():
|
|
862
|
+
return []
|
|
863
|
+
issues: list[Issue] = []
|
|
864
|
+
try:
|
|
865
|
+
data = json.loads(evals_path.read_text(encoding="utf-8"))
|
|
866
|
+
except (OSError, json.JSONDecodeError) as exc:
|
|
867
|
+
return [Issue("warning", "evals_json_unreadable",
|
|
868
|
+
f"evals/evals.json could not be parsed: {exc}")]
|
|
869
|
+
if not isinstance(data, dict):
|
|
870
|
+
return [Issue("warning", "evals_json_shape",
|
|
871
|
+
"evals/evals.json root must be an object")]
|
|
872
|
+
if "skill" not in data or not isinstance(data["skill"], str):
|
|
873
|
+
issues.append(Issue("warning", "evals_json_missing_skill",
|
|
874
|
+
"evals/evals.json must declare top-level 'skill' (string)"))
|
|
875
|
+
scenarios = data.get("scenarios")
|
|
876
|
+
if not isinstance(scenarios, list) or len(scenarios) < 1:
|
|
877
|
+
issues.append(Issue("warning", "evals_json_no_scenarios",
|
|
878
|
+
"evals/evals.json must declare 'scenarios' (non-empty array)"))
|
|
879
|
+
return issues
|
|
880
|
+
valid_kinds = {"contains", "file_exists", "rubric"}
|
|
881
|
+
for idx, scenario in enumerate(scenarios):
|
|
882
|
+
loc = f"scenarios[{idx}]"
|
|
883
|
+
if not isinstance(scenario, dict):
|
|
884
|
+
issues.append(Issue("warning", "evals_json_scenario_shape",
|
|
885
|
+
f"{loc} must be an object"))
|
|
886
|
+
continue
|
|
887
|
+
for key in ("id", "prompt"):
|
|
888
|
+
if key not in scenario or not isinstance(scenario[key], str) or not scenario[key].strip():
|
|
889
|
+
issues.append(Issue("warning", "evals_json_scenario_missing_field",
|
|
890
|
+
f"{loc} missing required string field '{key}'"))
|
|
891
|
+
assertions = scenario.get("assertions")
|
|
892
|
+
if not isinstance(assertions, list) or len(assertions) < 1:
|
|
893
|
+
issues.append(Issue("warning", "evals_json_scenario_no_assertions",
|
|
894
|
+
f"{loc}.assertions must be a non-empty array"))
|
|
895
|
+
continue
|
|
896
|
+
for a_idx, assertion in enumerate(assertions):
|
|
897
|
+
a_loc = f"{loc}.assertions[{a_idx}]"
|
|
898
|
+
if not isinstance(assertion, dict):
|
|
899
|
+
issues.append(Issue("warning", "evals_json_assertion_shape",
|
|
900
|
+
f"{a_loc} must be an object"))
|
|
901
|
+
continue
|
|
902
|
+
kind = assertion.get("kind")
|
|
903
|
+
if kind not in valid_kinds:
|
|
904
|
+
issues.append(Issue("warning", "evals_json_assertion_kind",
|
|
905
|
+
f"{a_loc}.kind must be one of {sorted(valid_kinds)}, got {kind!r}"))
|
|
906
|
+
continue
|
|
907
|
+
required_field = {"contains": "value", "file_exists": "path", "rubric": "criterion"}[kind]
|
|
908
|
+
if required_field not in assertion or not isinstance(assertion[required_field], str):
|
|
909
|
+
issues.append(Issue("warning", "evals_json_assertion_missing_field",
|
|
910
|
+
f"{a_loc} (kind={kind}) missing required string field '{required_field}'"))
|
|
911
|
+
return issues
|
|
912
|
+
|
|
913
|
+
|
|
844
914
|
def extract_frontmatter(text: str) -> Optional[str]:
|
|
845
915
|
match = FRONTMATTER_PATTERN.search(text)
|
|
846
916
|
return match.group(1) if match else None
|
|
@@ -2233,17 +2303,26 @@ def lint_type_boundaries(path: Path, text: str, artifact_type: str) -> List[Issu
|
|
|
2233
2303
|
# Check frontmatter skills field
|
|
2234
2304
|
frontmatter = extract_frontmatter(text)
|
|
2235
2305
|
has_skills_field = False
|
|
2306
|
+
# Commands tagged `type: orchestrator` aggregate other commands /
|
|
2307
|
+
# routers — they intentionally do not declare a `skills:` list and
|
|
2308
|
+
# are exempt from the no-skill-reference check. The tag is the
|
|
2309
|
+
# contract; no hard-coded path list.
|
|
2310
|
+
is_orchestrator = False
|
|
2236
2311
|
if frontmatter:
|
|
2237
2312
|
skills_match = re.search(r'skills:\s*\[(.+)\]', frontmatter)
|
|
2238
2313
|
has_skills_field = bool(skills_match and skills_match.group(1).strip())
|
|
2314
|
+
type_match = re.search(r'^type:\s*[\'"]?orchestrator[\'"]?\s*$',
|
|
2315
|
+
frontmatter, re.MULTILINE)
|
|
2316
|
+
is_orchestrator = bool(type_match)
|
|
2239
2317
|
|
|
2240
2318
|
# Also check body for skill references
|
|
2241
2319
|
has_skill_ref = bool(re.search(r'skill|SKILL\.md', text))
|
|
2242
2320
|
|
|
2243
|
-
if not has_skills_field and not has_skill_ref:
|
|
2321
|
+
if not has_skills_field and not has_skill_ref and not is_orchestrator:
|
|
2244
2322
|
issues.append(Issue("warning", "command_missing_skill_references",
|
|
2245
2323
|
"Command does not reference any skills — "
|
|
2246
|
-
"commands should orchestrate skills, not contain domain logic"
|
|
2324
|
+
"commands should orchestrate skills, not contain domain logic "
|
|
2325
|
+
"(use `type: orchestrator` in frontmatter to exempt routers)"))
|
|
2247
2326
|
|
|
2248
2327
|
# --- Skill: validation should be concrete, not vague ---
|
|
2249
2328
|
if artifact_type == "skill":
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Structural smoke-test for the README Quickstart path.
|
|
3
|
+
|
|
4
|
+
Verifies the 3-step Quickstart from a fresh-project perspective:
|
|
5
|
+
|
|
6
|
+
1. `scripts/install.py --project <tmpdir>` produces a usable
|
|
7
|
+
`.agent-settings.yml` with the documented default `cost_profile`.
|
|
8
|
+
2. The decision_engine block (P2.x of road-to-productization) parses
|
|
9
|
+
cleanly through the same engine parser the runtime uses.
|
|
10
|
+
3. The work-engine state-file format (`agents/state/<id>.json`) is
|
|
11
|
+
emit-ready — schema for `decision_result` matches the contract.
|
|
12
|
+
|
|
13
|
+
What it does NOT do:
|
|
14
|
+
- Invoke a real LLM agent (CI doesn't run a model). The end-to-end
|
|
15
|
+
`/onboard → /work → decision_result` chain still requires the host
|
|
16
|
+
agent. This smoke test asserts the *mechanics* the agent depends
|
|
17
|
+
on, so a Quickstart break is caught before the agent ever runs.
|
|
18
|
+
|
|
19
|
+
Exit codes: 0 = green; 1 = one or more checks failed; 2 = setup error.
|
|
20
|
+
"""
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import shutil
|
|
24
|
+
import subprocess
|
|
25
|
+
import sys
|
|
26
|
+
import tempfile
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
|
|
29
|
+
ROOT = Path(__file__).resolve().parent.parent
|
|
30
|
+
INSTALLER = ROOT / "scripts" / "install.py"
|
|
31
|
+
TEMPLATE = ROOT / "config" / "agent-settings.template.yml"
|
|
32
|
+
|
|
33
|
+
EXPECTED_DEFAULT_PROFILE = "balanced"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _fail(msg: str) -> int:
|
|
37
|
+
print(f"::error::{msg}", file=sys.stderr)
|
|
38
|
+
return 1
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _check_installer_runs(tmpdir: Path) -> tuple[int, Path | None]:
|
|
42
|
+
"""Step 1 — run installer against a fresh tmpdir."""
|
|
43
|
+
cmd = [
|
|
44
|
+
sys.executable,
|
|
45
|
+
str(INSTALLER),
|
|
46
|
+
"--project",
|
|
47
|
+
str(tmpdir),
|
|
48
|
+
"--package",
|
|
49
|
+
str(ROOT),
|
|
50
|
+
"--skip-bridges",
|
|
51
|
+
]
|
|
52
|
+
try:
|
|
53
|
+
result = subprocess.run(cmd, capture_output=True, text=True, timeout=60)
|
|
54
|
+
except subprocess.TimeoutExpired:
|
|
55
|
+
return _fail("installer timed out after 60s"), None
|
|
56
|
+
if result.returncode != 0:
|
|
57
|
+
return (
|
|
58
|
+
_fail(f"installer exited {result.returncode}\nstdout:\n{result.stdout}\nstderr:\n{result.stderr}"),
|
|
59
|
+
None,
|
|
60
|
+
)
|
|
61
|
+
settings = tmpdir / ".agent-settings.yml"
|
|
62
|
+
if not settings.exists():
|
|
63
|
+
return _fail(".agent-settings.yml not written by installer"), None
|
|
64
|
+
return 0, settings
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _check_default_profile(settings: Path) -> int:
|
|
68
|
+
"""Step 2 — assert default cost_profile matches the contract."""
|
|
69
|
+
import yaml
|
|
70
|
+
|
|
71
|
+
parsed = yaml.safe_load(settings.read_text(encoding="utf-8"))
|
|
72
|
+
if not isinstance(parsed, dict):
|
|
73
|
+
return _fail(f"{settings.name}: top-level is not a YAML mapping")
|
|
74
|
+
profile = parsed.get("cost_profile")
|
|
75
|
+
if profile != EXPECTED_DEFAULT_PROFILE:
|
|
76
|
+
return _fail(
|
|
77
|
+
f"cost_profile drift: docs/contracts/cost-profile-defaults.md "
|
|
78
|
+
f"declares '{EXPECTED_DEFAULT_PROFILE}', settings has '{profile!r}'"
|
|
79
|
+
)
|
|
80
|
+
return 0
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _check_decision_engine_block(settings: Path) -> int:
|
|
84
|
+
"""Step 3 — decision_engine block parses through the engine parser."""
|
|
85
|
+
sys.path.insert(0, str(ROOT / ".agent-src.uncompressed" / "templates" / "scripts"))
|
|
86
|
+
try:
|
|
87
|
+
from work_engine.scoring.decision_engine import ( # type: ignore[import-not-found]
|
|
88
|
+
DecisionEngineSettings,
|
|
89
|
+
parse as parse_decision_engine,
|
|
90
|
+
)
|
|
91
|
+
except ImportError as exc:
|
|
92
|
+
return _fail(f"decision_engine module not importable: {exc}")
|
|
93
|
+
|
|
94
|
+
import yaml
|
|
95
|
+
|
|
96
|
+
parsed = yaml.safe_load(settings.read_text(encoding="utf-8"))
|
|
97
|
+
block = parsed.get("decision_engine") if isinstance(parsed, dict) else None
|
|
98
|
+
try:
|
|
99
|
+
settings_obj = parse_decision_engine(block)
|
|
100
|
+
except Exception as exc: # noqa: BLE001 — surface the schema error
|
|
101
|
+
return _fail(f"decision_engine block rejected by parser: {exc}")
|
|
102
|
+
if not isinstance(settings_obj, DecisionEngineSettings):
|
|
103
|
+
return _fail("parser returned non-DecisionEngineSettings instance")
|
|
104
|
+
return 0
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def main() -> int:
|
|
108
|
+
if not INSTALLER.exists():
|
|
109
|
+
print(f"::error::installer not found at {INSTALLER}", file=sys.stderr)
|
|
110
|
+
return 2
|
|
111
|
+
if not TEMPLATE.exists():
|
|
112
|
+
print(f"::error::template not found at {TEMPLATE}", file=sys.stderr)
|
|
113
|
+
return 2
|
|
114
|
+
|
|
115
|
+
failures = 0
|
|
116
|
+
tmpdir = Path(tempfile.mkdtemp(prefix="agent-config-quickstart-"))
|
|
117
|
+
try:
|
|
118
|
+
rc, settings = _check_installer_runs(tmpdir)
|
|
119
|
+
failures += rc
|
|
120
|
+
if settings is not None:
|
|
121
|
+
failures += _check_default_profile(settings)
|
|
122
|
+
failures += _check_decision_engine_block(settings)
|
|
123
|
+
finally:
|
|
124
|
+
shutil.rmtree(tmpdir, ignore_errors=True)
|
|
125
|
+
|
|
126
|
+
if failures:
|
|
127
|
+
print(f"\n❌ smoke-quickstart: {failures} check(s) failed", file=sys.stderr)
|
|
128
|
+
return 1
|
|
129
|
+
print("✅ smoke-quickstart: install → settings → decision_engine green")
|
|
130
|
+
return 0
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
if __name__ == "__main__":
|
|
134
|
+
sys.exit(main())
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Decision-engine settings validator (road-to-productization P2).
|
|
4
|
+
|
|
5
|
+
Walks every ``agent-settings.yml`` / ``agent-settings.template.yml``
|
|
6
|
+
under the repo, parses any ``decision_engine`` block via the canonical
|
|
7
|
+
``work_engine.scoring.decision_engine.parse`` schema, and surfaces:
|
|
8
|
+
|
|
9
|
+
- hard errors → exit 1 (unknown keys, invalid enum values, bad types).
|
|
10
|
+
- warnings → exit 0 with a ``::warning::`` line per finding
|
|
11
|
+
(gates active but ``hooks.enabled`` is false → gates won't fire).
|
|
12
|
+
|
|
13
|
+
Contract: ``docs/contracts/decision-engine-gates.md``. Wired into
|
|
14
|
+
``task ci`` via ``taskfiles/ci-fast.yml`` so configuration drift is
|
|
15
|
+
caught before a Decision Engine surprise lands in main.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import sys
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
|
|
23
|
+
try:
|
|
24
|
+
import yaml
|
|
25
|
+
except ImportError: # pragma: no cover — bootstrap guard
|
|
26
|
+
print("::error::PyYAML not installed; cannot validate decision_engine block")
|
|
27
|
+
sys.exit(3)
|
|
28
|
+
|
|
29
|
+
REPO_ROOT = Path(__file__).resolve().parent.parent
|
|
30
|
+
TEMPLATE_SCRIPTS = REPO_ROOT / ".agent-src.uncompressed" / "templates" / "scripts"
|
|
31
|
+
if str(TEMPLATE_SCRIPTS) not in sys.path:
|
|
32
|
+
sys.path.insert(0, str(TEMPLATE_SCRIPTS))
|
|
33
|
+
|
|
34
|
+
from work_engine.scoring.decision_engine import ( # noqa: E402
|
|
35
|
+
DecisionEngineConfigError,
|
|
36
|
+
parse,
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
# Files we always validate, even if they don't exist (template is
|
|
40
|
+
# canonical — its absence is itself a regression).
|
|
41
|
+
TEMPLATE_PATH = REPO_ROOT / "config" / "agent-settings.template.yml"
|
|
42
|
+
# Project-level overrides developers may have on disk locally.
|
|
43
|
+
LOCAL_PATHS = [REPO_ROOT / ".agent-settings.yml"]
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _load_yaml(path: Path) -> dict | None:
|
|
47
|
+
if not path.is_file():
|
|
48
|
+
return None
|
|
49
|
+
try:
|
|
50
|
+
raw = yaml.safe_load(path.read_text(encoding="utf-8"))
|
|
51
|
+
except yaml.YAMLError as exc:
|
|
52
|
+
print(f"::error file={path}::malformed YAML: {exc}")
|
|
53
|
+
return {}
|
|
54
|
+
if raw is None:
|
|
55
|
+
return {}
|
|
56
|
+
if not isinstance(raw, dict):
|
|
57
|
+
print(f"::error file={path}::top-level must be a mapping")
|
|
58
|
+
return {}
|
|
59
|
+
return raw
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _validate(path: Path, doc: dict) -> tuple[int, int]:
|
|
63
|
+
"""Return ``(errors, warnings)`` counts for ``doc``."""
|
|
64
|
+
errors = 0
|
|
65
|
+
warnings = 0
|
|
66
|
+
block = doc.get("decision_engine")
|
|
67
|
+
if block is None:
|
|
68
|
+
return 0, 0
|
|
69
|
+
try:
|
|
70
|
+
settings = parse(block)
|
|
71
|
+
except DecisionEngineConfigError as exc:
|
|
72
|
+
rel = path.relative_to(REPO_ROOT)
|
|
73
|
+
print(f"::error file={rel}::decision_engine: {exc}")
|
|
74
|
+
return 1, 0
|
|
75
|
+
if settings.any_gate_active:
|
|
76
|
+
hooks_block = doc.get("hooks") or {}
|
|
77
|
+
if isinstance(hooks_block, dict) and hooks_block.get("enabled") is False:
|
|
78
|
+
rel = path.relative_to(REPO_ROOT)
|
|
79
|
+
print(
|
|
80
|
+
f"::warning file={rel}::decision_engine gates configured "
|
|
81
|
+
"(min_confidence/block_on_risk/require_memory_hits) but "
|
|
82
|
+
"hooks.enabled=false — gates will not fire. Either enable "
|
|
83
|
+
"hooks or remove the gate keys."
|
|
84
|
+
)
|
|
85
|
+
warnings += 1
|
|
86
|
+
return errors, warnings
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def main() -> int:
|
|
90
|
+
total_errors = 0
|
|
91
|
+
total_warnings = 0
|
|
92
|
+
paths: list[Path] = []
|
|
93
|
+
if TEMPLATE_PATH.is_file():
|
|
94
|
+
paths.append(TEMPLATE_PATH)
|
|
95
|
+
else:
|
|
96
|
+
print(f"::error file={TEMPLATE_PATH}::template missing")
|
|
97
|
+
return 1
|
|
98
|
+
for candidate in LOCAL_PATHS:
|
|
99
|
+
if candidate.is_file():
|
|
100
|
+
paths.append(candidate)
|
|
101
|
+
for path in paths:
|
|
102
|
+
doc = _load_yaml(path)
|
|
103
|
+
if doc is None:
|
|
104
|
+
continue
|
|
105
|
+
errors, warnings = _validate(path, doc)
|
|
106
|
+
total_errors += errors
|
|
107
|
+
total_warnings += warnings
|
|
108
|
+
if total_errors:
|
|
109
|
+
return 1
|
|
110
|
+
if total_warnings:
|
|
111
|
+
# Warnings already printed; CI treats exit 0 + ::warning:: as
|
|
112
|
+
# green-with-note. Surface a summary for human readers.
|
|
113
|
+
print(
|
|
114
|
+
f"decision_engine: {total_warnings} warning(s); see ::warning:: lines above"
|
|
115
|
+
)
|
|
116
|
+
return 0
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
if __name__ == "__main__":
|
|
120
|
+
try:
|
|
121
|
+
sys.exit(main())
|
|
122
|
+
except Exception as exc: # noqa: BLE001
|
|
123
|
+
print(f"::error::validate_decision_engine internal error: {exc}")
|
|
124
|
+
sys.exit(3)
|