@event4u/agent-config 2.10.0 → 2.11.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/nextjs-patterns/SKILL.md +203 -0
- 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 +3 -1
- package/CHANGELOG.md +37 -0
- package/README.md +37 -8
- package/config/agent-settings.template.yml +57 -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/check_beta_review_markers.py +127 -0
- package/scripts/check_release_trunk_sync.py +152 -0
- package/scripts/install.py +3 -3
- package/scripts/schemas/command.schema.json +5 -0
- package/scripts/skill_linter.py +11 -2
- package/scripts/smoke_quickstart.py +134 -0
- package/scripts/validate_decision_engine.py +124 -0
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Release-trunk-sync CI gate (road-to-productization P1.3).
|
|
4
|
+
|
|
5
|
+
Fails if `main` is more than one tagged release behind the current
|
|
6
|
+
release-prep branch's target version. No-ops on every other branch
|
|
7
|
+
class. Owner contract: `docs/contracts/release-trunk-sync.md`.
|
|
8
|
+
|
|
9
|
+
Exit codes: 0 = pass / no-op, 1 = main is too far behind, 3 = internal
|
|
10
|
+
error (git unavailable, malformed tag, etc.).
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import os
|
|
16
|
+
import re
|
|
17
|
+
import subprocess
|
|
18
|
+
import sys
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
RELEASE_BRANCH_RE = re.compile(r"^release/(\d+)\.(\d+)\.(\d+)$")
|
|
22
|
+
SEMVER_TAG_RE = re.compile(r"^(\d+)\.(\d+)\.(\d+)$")
|
|
23
|
+
BOOTSTRAP_FILE = Path("docs/contracts/release-trunk-sync.bootstrap")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _git(*args: str) -> str:
|
|
27
|
+
proc = subprocess.run(
|
|
28
|
+
["git", *args], capture_output=True, text=True, check=False
|
|
29
|
+
)
|
|
30
|
+
if proc.returncode != 0:
|
|
31
|
+
return ""
|
|
32
|
+
return proc.stdout.strip()
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _current_branch() -> str:
|
|
36
|
+
return _git("rev-parse", "--abbrev-ref", "HEAD")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _parse_semver(text: str) -> tuple[int, int, int] | None:
|
|
40
|
+
m = SEMVER_TAG_RE.match(text)
|
|
41
|
+
if not m:
|
|
42
|
+
return None
|
|
43
|
+
return int(m.group(1)), int(m.group(2)), int(m.group(3))
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _all_tags() -> list[tuple[int, int, int]]:
|
|
47
|
+
raw = _git("tag", "--list")
|
|
48
|
+
tags = []
|
|
49
|
+
for line in raw.splitlines():
|
|
50
|
+
parsed = _parse_semver(line.strip())
|
|
51
|
+
if parsed is not None:
|
|
52
|
+
tags.append(parsed)
|
|
53
|
+
tags.sort()
|
|
54
|
+
return tags
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _main_tag() -> tuple[int, int, int] | None:
|
|
58
|
+
"""Highest semver tag whose commit is reachable from main."""
|
|
59
|
+
# Try local main, fall back to origin/main.
|
|
60
|
+
for ref in ("refs/heads/main", "refs/remotes/origin/main"):
|
|
61
|
+
head = _git("rev-parse", "--verify", ref)
|
|
62
|
+
if head:
|
|
63
|
+
break
|
|
64
|
+
else:
|
|
65
|
+
return None
|
|
66
|
+
# `git tag --merged <main>` lists tags reachable from main.
|
|
67
|
+
raw = _git("tag", "--merged", head)
|
|
68
|
+
reachable: list[tuple[int, int, int]] = []
|
|
69
|
+
for line in raw.splitlines():
|
|
70
|
+
parsed = _parse_semver(line.strip())
|
|
71
|
+
if parsed is not None:
|
|
72
|
+
reachable.append(parsed)
|
|
73
|
+
if not reachable:
|
|
74
|
+
return None
|
|
75
|
+
return max(reachable)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _prior_release(
|
|
79
|
+
target: tuple[int, int, int], tags: list[tuple[int, int, int]]
|
|
80
|
+
) -> tuple[int, int, int] | None:
|
|
81
|
+
earlier = [t for t in tags if t < target]
|
|
82
|
+
return max(earlier) if earlier else None
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _bootstrap_ok(target: tuple[int, int, int]) -> bool:
|
|
86
|
+
if not BOOTSTRAP_FILE.exists():
|
|
87
|
+
return False
|
|
88
|
+
target_s = "{0}.{1}.{2}".format(*target)
|
|
89
|
+
for line in BOOTSTRAP_FILE.read_text().splitlines():
|
|
90
|
+
line = line.strip()
|
|
91
|
+
if not line or line.startswith("#"):
|
|
92
|
+
continue
|
|
93
|
+
if line == target_s:
|
|
94
|
+
return True
|
|
95
|
+
return False
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def main() -> int:
|
|
99
|
+
branch = _current_branch()
|
|
100
|
+
if branch == "HEAD" or not branch:
|
|
101
|
+
print("::warning::detached HEAD — release-trunk-sync gate skipped")
|
|
102
|
+
return 0
|
|
103
|
+
# CI override: GitHub Actions sometimes runs on the merge ref.
|
|
104
|
+
ci_ref = os.environ.get("GITHUB_HEAD_REF") or os.environ.get(
|
|
105
|
+
"GITHUB_REF_NAME"
|
|
106
|
+
)
|
|
107
|
+
if ci_ref:
|
|
108
|
+
branch = ci_ref
|
|
109
|
+
m = RELEASE_BRANCH_RE.match(branch)
|
|
110
|
+
if not m:
|
|
111
|
+
return 0 # non-release branch class — gate is a no-op
|
|
112
|
+
target = (int(m.group(1)), int(m.group(2)), int(m.group(3)))
|
|
113
|
+
tags = _all_tags()
|
|
114
|
+
if not tags:
|
|
115
|
+
print(
|
|
116
|
+
"::warning::no semver tags found — release-trunk-sync gate skipped"
|
|
117
|
+
)
|
|
118
|
+
return 0
|
|
119
|
+
main_tag = _main_tag()
|
|
120
|
+
if main_tag is None:
|
|
121
|
+
print(
|
|
122
|
+
"::warning::no semver tag reachable from main — gate skipped"
|
|
123
|
+
)
|
|
124
|
+
return 0
|
|
125
|
+
if main_tag >= target:
|
|
126
|
+
return 0 # main already at or ahead of release target
|
|
127
|
+
prior = _prior_release(target, tags)
|
|
128
|
+
if prior is not None and main_tag >= prior:
|
|
129
|
+
return 0 # within the one-release tolerance
|
|
130
|
+
if _bootstrap_ok(target):
|
|
131
|
+
target_s = "{0}.{1}.{2}".format(*target)
|
|
132
|
+
print(
|
|
133
|
+
f"::warning::release-trunk-sync gate suppressed for {target_s} "
|
|
134
|
+
"via bootstrap file"
|
|
135
|
+
)
|
|
136
|
+
return 0
|
|
137
|
+
main_s = "{0}.{1}.{2}".format(*main_tag)
|
|
138
|
+
target_s = "{0}.{1}.{2}".format(*target)
|
|
139
|
+
print(
|
|
140
|
+
f"::error::main is at {main_s}; release-prep branch targets "
|
|
141
|
+
f"{target_s}. Main must be no more than one tagged release behind. "
|
|
142
|
+
"See docs/contracts/release-trunk-sync.md."
|
|
143
|
+
)
|
|
144
|
+
return 1
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
if __name__ == "__main__":
|
|
148
|
+
try:
|
|
149
|
+
sys.exit(main())
|
|
150
|
+
except Exception as exc: # noqa: BLE001
|
|
151
|
+
print(f"::error::release-trunk-sync gate internal error: {exc}")
|
|
152
|
+
sys.exit(3)
|
package/scripts/install.py
CHANGED
|
@@ -12,8 +12,8 @@ format in `.agent-settings.yml`, leaves a one-shot backup as
|
|
|
12
12
|
exactly once; subsequent runs are idempotent.
|
|
13
13
|
|
|
14
14
|
Usage:
|
|
15
|
-
python3 scripts/install.py # defaults: cost_profile=
|
|
16
|
-
python3 scripts/install.py --profile=
|
|
15
|
+
python3 scripts/install.py # defaults: cost_profile=balanced
|
|
16
|
+
python3 scripts/install.py --profile=minimal # set cost_profile=minimal (kernel only)
|
|
17
17
|
python3 scripts/install.py --force # overwrite existing files
|
|
18
18
|
python3 scripts/install.py --skip-bridges # only create .agent-settings.yml
|
|
19
19
|
python3 scripts/install.py --project <dir> # override project root
|
|
@@ -42,7 +42,7 @@ try:
|
|
|
42
42
|
except ImportError: # pragma: no cover — alt sys.path layout
|
|
43
43
|
from _lib.json_pointers import build_merge_entries # type: ignore[no-redef] # noqa: PLC0415
|
|
44
44
|
|
|
45
|
-
DEFAULT_PROFILE = "
|
|
45
|
+
DEFAULT_PROFILE = "balanced"
|
|
46
46
|
SUPPORTED_PROFILES = ("minimal", "balanced", "full")
|
|
47
47
|
COST_PROFILE_PLACEHOLDER = "__COST_PROFILE__"
|
|
48
48
|
|
|
@@ -39,6 +39,11 @@
|
|
|
39
39
|
"pattern": "^[a-z][a-z0-9-]*$",
|
|
40
40
|
"description": "Locked verb cluster this command belongs to. See docs/contracts/command-clusters.md."
|
|
41
41
|
},
|
|
42
|
+
"type": {
|
|
43
|
+
"type": "string",
|
|
44
|
+
"enum": ["orchestrator"],
|
|
45
|
+
"description": "Optional type tag. `orchestrator` marks a command that aggregates other commands / skills (cluster routers, top-level entry points) and exempts it from the `command_missing_skill_references` linter check. Omit the key for ordinary commands. See road-to-productization.md P5.3."
|
|
46
|
+
},
|
|
42
47
|
"sub": {
|
|
43
48
|
"type": "string",
|
|
44
49
|
"pattern": "^[a-z][a-z0-9-]*$",
|
package/scripts/skill_linter.py
CHANGED
|
@@ -2233,17 +2233,26 @@ def lint_type_boundaries(path: Path, text: str, artifact_type: str) -> List[Issu
|
|
|
2233
2233
|
# Check frontmatter skills field
|
|
2234
2234
|
frontmatter = extract_frontmatter(text)
|
|
2235
2235
|
has_skills_field = False
|
|
2236
|
+
# Commands tagged `type: orchestrator` aggregate other commands /
|
|
2237
|
+
# routers — they intentionally do not declare a `skills:` list and
|
|
2238
|
+
# are exempt from the no-skill-reference check. The tag is the
|
|
2239
|
+
# contract; no hard-coded path list.
|
|
2240
|
+
is_orchestrator = False
|
|
2236
2241
|
if frontmatter:
|
|
2237
2242
|
skills_match = re.search(r'skills:\s*\[(.+)\]', frontmatter)
|
|
2238
2243
|
has_skills_field = bool(skills_match and skills_match.group(1).strip())
|
|
2244
|
+
type_match = re.search(r'^type:\s*[\'"]?orchestrator[\'"]?\s*$',
|
|
2245
|
+
frontmatter, re.MULTILINE)
|
|
2246
|
+
is_orchestrator = bool(type_match)
|
|
2239
2247
|
|
|
2240
2248
|
# Also check body for skill references
|
|
2241
2249
|
has_skill_ref = bool(re.search(r'skill|SKILL\.md', text))
|
|
2242
2250
|
|
|
2243
|
-
if not has_skills_field and not has_skill_ref:
|
|
2251
|
+
if not has_skills_field and not has_skill_ref and not is_orchestrator:
|
|
2244
2252
|
issues.append(Issue("warning", "command_missing_skill_references",
|
|
2245
2253
|
"Command does not reference any skills — "
|
|
2246
|
-
"commands should orchestrate skills, not contain domain logic"
|
|
2254
|
+
"commands should orchestrate skills, not contain domain logic "
|
|
2255
|
+
"(use `type: orchestrator` in frontmatter to exempt routers)"))
|
|
2247
2256
|
|
|
2248
2257
|
# --- Skill: validation should be concrete, not vague ---
|
|
2249
2258
|
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)
|