@event4u/agent-config 2.8.0 → 2.10.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/personas/engineering-manager.md +133 -0
- package/.agent-src/personas/finance-partner.md +129 -0
- package/.agent-src/personas/people-strategist.md +126 -0
- package/.agent-src/personas/strategist.md +129 -0
- package/.agent-src/rules/no-roadmap-references.md +19 -0
- package/.agent-src/skills/build-buy-partner/SKILL.md +145 -0
- package/.agent-src/skills/comp-banding/SKILL.md +160 -0
- package/.agent-src/skills/competitive-moat-analysis/SKILL.md +152 -0
- package/.agent-src/skills/contracts-cognition/SKILL.md +147 -0
- package/.agent-src/skills/data-handling-judgment/SKILL.md +155 -0
- package/.agent-src/skills/forecasting/SKILL.md +164 -0
- package/.agent-src/skills/hiring-loop-design/SKILL.md +167 -0
- package/.agent-src/skills/market-entry-analysis/SKILL.md +144 -0
- package/.agent-src/skills/onboarding-program/SKILL.md +157 -0
- package/.agent-src/skills/one-on-one-cadence/SKILL.md +161 -0
- package/.agent-src/skills/org-design/SKILL.md +158 -0
- package/.agent-src/skills/perf-feedback-craft/SKILL.md +157 -0
- package/.agent-src/skills/privacy-review/SKILL.md +160 -0
- package/.agent-src/skills/runway-cognition/SKILL.md +136 -0
- package/.agent-src/skills/scenario-modeling/SKILL.md +139 -0
- package/.agent-src/skills/throughput-vs-morale-tradeoff/SKILL.md +165 -0
- package/.agent-src/skills/unit-economics-modeling/SKILL.md +54 -7
- package/.agent-src/skills/vision-articulation/SKILL.md +146 -0
- package/.agent-src/templates/agents/agent-project-settings.example.yml +1 -1
- package/.agent-src/templates/scripts/telemetry/settings.py +65 -0
- package/.agent-src/templates/scripts/tier_usage_report.py +183 -0
- package/.agent-src/templates/scripts/work_engine/hooks/builtin/memory_visibility.py +32 -3
- package/.agent-src/templates/scripts/work_engine/scoring/memory_visibility.py +147 -1
- package/.claude-plugin/marketplace.json +18 -1
- package/AGENTS.md +1 -1
- package/CHANGELOG.md +134 -0
- package/README.md +34 -14
- package/config/agent-settings.template.yml +28 -0
- package/docs/architecture.md +37 -11
- package/docs/catalog.md +22 -4
- package/docs/contracts/adr-forecast-construction-shape.md +89 -0
- package/docs/contracts/adr-wing4-context-spine.md +125 -0
- package/docs/contracts/command-clusters.md +41 -0
- package/docs/contracts/command-surface-tiers.md +25 -9
- package/docs/contracts/context-spine.md +8 -0
- package/docs/contracts/decision-trace-v1.md +30 -0
- package/docs/contracts/hook-architecture-v1.md +46 -0
- package/docs/contracts/mcp-beta-criteria.md +129 -0
- package/docs/contracts/memory-visibility-v1.md +33 -0
- package/docs/contracts/settings-sync-yaml-subset.md +138 -0
- package/docs/guidelines/wing4-handoff.md +127 -0
- package/docs/mcp-server.md +1 -1
- package/docs/readme-split-plan.md +102 -0
- package/package.json +1 -1
- package/scripts/_cli/cmd_doctor.py +527 -14
- package/scripts/_cli/cmd_settings_check.py +171 -0
- package/scripts/_cli/cmd_validate.py +10 -0
- package/scripts/agent-config +59 -18
- package/scripts/chat_history.py +19 -0
- package/scripts/check_council_references.py +46 -5
- package/scripts/hooks/dispatch_hook.py +5 -1
- package/scripts/hooks/replay_hook.py +144 -0
- package/scripts/hooks/state_io.py +24 -1
- package/scripts/hooks_doctor.py +184 -0
- package/scripts/install.py +5 -0
- package/scripts/lint_context_spine_usage.py +1 -0
- package/scripts/lint_hook_concern_budget.py +203 -0
- package/scripts/mcp_server/__init__.py +1 -0
- package/scripts/mcp_server/server.py +4 -3
- package/scripts/roadmap_progress_hook.py +11 -0
- package/scripts/schemas/skill.schema.json +2 -2
- package/scripts/skill_linter.py +107 -3
|
@@ -40,6 +40,18 @@ except ImportError: # pragma: no cover — Windows
|
|
|
40
40
|
|
|
41
41
|
LOCK_BASENAME = ".dispatcher.lock"
|
|
42
42
|
|
|
43
|
+
REPLAY_ENV_VAR = "AGENT_CONFIG_REPLAY"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def is_replay_mode() -> bool:
|
|
47
|
+
"""True when the caller signalled read-only fixture replay.
|
|
48
|
+
|
|
49
|
+
Concerns and the dispatcher honour the flag by skipping side
|
|
50
|
+
effects under `agents/state/` (and any other concern-owned state
|
|
51
|
+
surface). See `docs/contracts/hook-architecture-v1.md` § Replay mode.
|
|
52
|
+
"""
|
|
53
|
+
return os.environ.get(REPLAY_ENV_VAR, "").strip() == "1"
|
|
54
|
+
|
|
43
55
|
|
|
44
56
|
def _lock_path(state_dir: Path) -> Path:
|
|
45
57
|
return state_dir / LOCK_BASENAME
|
|
@@ -52,7 +64,12 @@ def atomic_write_json(target: Path, payload: Any, *, indent: int = 2) -> None:
|
|
|
52
64
|
directory the caller treats as the lock scope). The lock file is
|
|
53
65
|
`<target.parent>/.dispatcher.lock`. Caller does not need to create
|
|
54
66
|
the directory in advance — this function ensures it.
|
|
67
|
+
|
|
68
|
+
Under `AGENT_CONFIG_REPLAY=1` the call is a no-op so fixture
|
|
69
|
+
replay never mutates real session state.
|
|
55
70
|
"""
|
|
71
|
+
if is_replay_mode():
|
|
72
|
+
return
|
|
56
73
|
target = Path(target)
|
|
57
74
|
state_dir = target.parent
|
|
58
75
|
state_dir.mkdir(parents=True, exist_ok=True)
|
|
@@ -63,7 +80,11 @@ def atomic_write_json(target: Path, payload: Any, *, indent: int = 2) -> None:
|
|
|
63
80
|
def atomic_write_text(target: Path, text: str) -> None:
|
|
64
81
|
"""Write text to `target` atomically and concurrency-safely. Same
|
|
65
82
|
locking discipline as `atomic_write_json` — useful for non-JSON
|
|
66
|
-
state payloads (chat-history transcript, status text).
|
|
83
|
+
state payloads (chat-history transcript, status text).
|
|
84
|
+
|
|
85
|
+
Under `AGENT_CONFIG_REPLAY=1` the call is a no-op."""
|
|
86
|
+
if is_replay_mode():
|
|
87
|
+
return
|
|
67
88
|
target = Path(target)
|
|
68
89
|
state_dir = target.parent
|
|
69
90
|
state_dir.mkdir(parents=True, exist_ok=True)
|
|
@@ -117,6 +138,8 @@ __all__ = [
|
|
|
117
138
|
"atomic_write_json",
|
|
118
139
|
"atomic_write_text",
|
|
119
140
|
"feedback_dir",
|
|
141
|
+
"is_replay_mode",
|
|
120
142
|
"LOCK_BASENAME",
|
|
121
143
|
"FEEDBACK_DIRNAME",
|
|
144
|
+
"REPLAY_ENV_VAR",
|
|
122
145
|
]
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Hook doctor — read-only diagnostic over the hook runtime.
|
|
3
|
+
|
|
4
|
+
Wraps `scripts/hooks_status.py` (bridge presence + manifest bindings)
|
|
5
|
+
and adds three diagnostics the bare status table does not surface:
|
|
6
|
+
|
|
7
|
+
* **Concerns** — every concern declared in the manifest, its
|
|
8
|
+
`fail_closed` posture, the on-disk script path, and a one-line
|
|
9
|
+
file-exists check.
|
|
10
|
+
* **Trampolines** — per-platform shell trampoline expected under
|
|
11
|
+
`scripts/hooks/<platform>-dispatcher.sh`; flags any platform that
|
|
12
|
+
has manifest bindings but no trampoline on disk.
|
|
13
|
+
* **Last feedback** — for each concern, the most-recent dispatcher
|
|
14
|
+
feedback file under `agents/state/.dispatcher/*/<concern>.json`,
|
|
15
|
+
plus the per-rule state file under `agents/state/<concern>.json`
|
|
16
|
+
when one exists.
|
|
17
|
+
|
|
18
|
+
This is a **read-only** report. It never installs, modifies, or runs
|
|
19
|
+
anything — same contract as `hooks_status.py`. CI uses `--strict` to
|
|
20
|
+
turn missing bindings / trampolines into a non-zero exit.
|
|
21
|
+
|
|
22
|
+
Schema: docs/contracts/hook-architecture-v1.md.
|
|
23
|
+
"""
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import argparse
|
|
27
|
+
import json
|
|
28
|
+
import sys
|
|
29
|
+
from pathlib import Path
|
|
30
|
+
|
|
31
|
+
REPO_ROOT = Path(__file__).resolve().parents[1]
|
|
32
|
+
sys.path.insert(0, str(REPO_ROOT / "scripts"))
|
|
33
|
+
sys.path.insert(0, str(REPO_ROOT / "scripts" / "hooks"))
|
|
34
|
+
|
|
35
|
+
import dispatch_hook # noqa: E402
|
|
36
|
+
import hooks_status # noqa: E402
|
|
37
|
+
|
|
38
|
+
TRAMPOLINE_DIR = REPO_ROOT / "scripts" / "hooks"
|
|
39
|
+
STATE_DIR_DEFAULT = "agents/state"
|
|
40
|
+
|
|
41
|
+
# Platforms whose bridge file (settings.json) invokes the universal
|
|
42
|
+
# dispatcher directly — no shell trampoline required. Excluded from the
|
|
43
|
+
# "missing trampoline" check.
|
|
44
|
+
NATIVE_DISPATCH_PLATFORMS = frozenset({"claude"})
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _trampoline_for(platform: str) -> Path:
|
|
48
|
+
return TRAMPOLINE_DIR / f"{platform}-dispatcher.sh"
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _concern_state_file(state_dir: Path, concern: str) -> Path | None:
|
|
52
|
+
target = state_dir / f"{concern}.json"
|
|
53
|
+
return target if target.is_file() else None
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _latest_feedback(state_dir: Path, concern: str) -> Path | None:
|
|
57
|
+
"""Return the most-recent dispatcher feedback file for the concern,
|
|
58
|
+
walking `agents/state/.dispatcher/<session>/<concern>.json`."""
|
|
59
|
+
dispatcher_dir = state_dir / ".dispatcher"
|
|
60
|
+
if not dispatcher_dir.is_dir():
|
|
61
|
+
return None
|
|
62
|
+
candidates = sorted(
|
|
63
|
+
dispatcher_dir.glob(f"*/{concern}.json"),
|
|
64
|
+
key=lambda p: p.stat().st_mtime,
|
|
65
|
+
reverse=True,
|
|
66
|
+
)
|
|
67
|
+
return candidates[0] if candidates else None
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _rel(path: Path | None, root: Path) -> str | None:
|
|
71
|
+
if path is None:
|
|
72
|
+
return None
|
|
73
|
+
try:
|
|
74
|
+
return str(path.resolve().relative_to(root.resolve()))
|
|
75
|
+
except ValueError:
|
|
76
|
+
return str(path)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def collect(project_root: Path, manifest: dict,
|
|
80
|
+
state_dir_rel: str = STATE_DIR_DEFAULT) -> dict:
|
|
81
|
+
"""Build the doctor payload — JSON-serialisable."""
|
|
82
|
+
matrix = hooks_status.collect(project_root, manifest)
|
|
83
|
+
state_dir = project_root / state_dir_rel
|
|
84
|
+
|
|
85
|
+
concerns_def = manifest.get("concerns") or {}
|
|
86
|
+
concerns: list[dict] = []
|
|
87
|
+
for name, spec in sorted(concerns_def.items()):
|
|
88
|
+
script_rel = (spec or {}).get("script") or ""
|
|
89
|
+
script_path = REPO_ROOT / script_rel if script_rel else None
|
|
90
|
+
state_file = _concern_state_file(state_dir, name)
|
|
91
|
+
last_feedback = _latest_feedback(state_dir, name)
|
|
92
|
+
concerns.append({
|
|
93
|
+
"concern": name,
|
|
94
|
+
"fail_closed": bool((spec or {}).get("fail_closed", False)),
|
|
95
|
+
"script": script_rel or None,
|
|
96
|
+
"script_present": bool(script_path and script_path.is_file()),
|
|
97
|
+
"state_file": _rel(state_file, project_root),
|
|
98
|
+
"last_feedback": _rel(last_feedback, project_root),
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
trampolines: list[dict] = []
|
|
102
|
+
for row in matrix["platforms"]:
|
|
103
|
+
platform = row["platform"]
|
|
104
|
+
needs_trampoline = bool(row["bindings"]) and platform not in NATIVE_DISPATCH_PLATFORMS
|
|
105
|
+
tpath = _trampoline_for(platform)
|
|
106
|
+
trampolines.append({
|
|
107
|
+
"platform": platform,
|
|
108
|
+
"expected": _rel(tpath, REPO_ROOT),
|
|
109
|
+
"present": tpath.is_file(),
|
|
110
|
+
"required": needs_trampoline,
|
|
111
|
+
"missing": needs_trampoline and not tpath.is_file(),
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
return {
|
|
115
|
+
"schema_version": 1,
|
|
116
|
+
"platforms": matrix["platforms"],
|
|
117
|
+
"concerns": concerns,
|
|
118
|
+
"trampolines": trampolines,
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _render_table(payload: dict) -> str:
|
|
123
|
+
lines: list[str] = [hooks_status._render_table(payload), ""]
|
|
124
|
+
lines.append("Concerns")
|
|
125
|
+
lines.append("-" * 60)
|
|
126
|
+
for c in payload["concerns"]:
|
|
127
|
+
posture = "fail-closed" if c["fail_closed"] else "fail-open"
|
|
128
|
+
script_mark = "✅ " if c["script_present"] else "❌ "
|
|
129
|
+
lines.append(f"{script_mark}{c['concern']:<22} {posture:<11} {c['script'] or '(no script)'}")
|
|
130
|
+
if c["state_file"]:
|
|
131
|
+
lines.append(f" state: {c['state_file']}")
|
|
132
|
+
if c["last_feedback"]:
|
|
133
|
+
lines.append(f" feedback: {c['last_feedback']}")
|
|
134
|
+
lines.append("")
|
|
135
|
+
lines.append("Trampolines")
|
|
136
|
+
lines.append("-" * 60)
|
|
137
|
+
for t in payload["trampolines"]:
|
|
138
|
+
marker = "❌ " if t["missing"] else ("· " if not t["required"] else "✅ ")
|
|
139
|
+
suffix = "" if t["required"] else " (not required)"
|
|
140
|
+
lines.append(f"{marker}{t['platform']:<9} {t['expected']}{suffix}")
|
|
141
|
+
return "\n".join(lines)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _final_exit_code(payload: dict, strict: bool) -> int:
|
|
145
|
+
if not strict:
|
|
146
|
+
return 0
|
|
147
|
+
rc = hooks_status._final_exit_code(payload, strict)
|
|
148
|
+
if rc:
|
|
149
|
+
return rc
|
|
150
|
+
if any(t["missing"] for t in payload["trampolines"]):
|
|
151
|
+
return 1
|
|
152
|
+
if any(not c["script_present"] for c in payload["concerns"]):
|
|
153
|
+
return 1
|
|
154
|
+
return 0
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def main(argv: list[str] | None = None) -> int:
|
|
158
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
159
|
+
parser.add_argument("--format", choices=["table", "json"], default="table")
|
|
160
|
+
parser.add_argument("--project-root", default=".",
|
|
161
|
+
help="Project root to inspect (default: cwd)")
|
|
162
|
+
parser.add_argument("--manifest", default=str(dispatch_hook.MANIFEST_PATH))
|
|
163
|
+
parser.add_argument("--strict", action="store_true",
|
|
164
|
+
help="Exit non-zero on missing bridges, trampolines, "
|
|
165
|
+
"or concern scripts (CI-friendly).")
|
|
166
|
+
args = parser.parse_args(argv)
|
|
167
|
+
|
|
168
|
+
manifest_path = Path(args.manifest)
|
|
169
|
+
if not manifest_path.exists():
|
|
170
|
+
sys.stderr.write(f"hooks_doctor: manifest missing at {manifest_path}\n")
|
|
171
|
+
return 2
|
|
172
|
+
manifest = dispatch_hook._load_yaml(manifest_path)
|
|
173
|
+
project_root = Path(args.project_root).resolve()
|
|
174
|
+
payload = collect(project_root, manifest)
|
|
175
|
+
|
|
176
|
+
if args.format == "json":
|
|
177
|
+
print(json.dumps(payload, indent=2, sort_keys=True))
|
|
178
|
+
else:
|
|
179
|
+
print(_render_table(payload))
|
|
180
|
+
return _final_exit_code(payload, args.strict)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
if __name__ == "__main__":
|
|
184
|
+
raise SystemExit(main())
|
package/scripts/install.py
CHANGED
|
@@ -105,6 +105,11 @@ def warn(msg: str) -> None:
|
|
|
105
105
|
|
|
106
106
|
def fail(msg: str) -> "None":
|
|
107
107
|
print(f" ❌ {msg}", file=sys.stderr)
|
|
108
|
+
print(
|
|
109
|
+
" Diagnose: `./agent-config doctor` "
|
|
110
|
+
"(or `--check <id>` for a single category)",
|
|
111
|
+
file=sys.stderr,
|
|
112
|
+
)
|
|
108
113
|
sys.exit(1)
|
|
109
114
|
|
|
110
115
|
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Lint the hook concern budget against `scripts/hook_manifest.yaml`.
|
|
3
|
+
|
|
4
|
+
P3.3 of `agents/roadmaps/road-to-proof-not-features.md`. Static gate
|
|
5
|
+
that mirrors the always-rule budget pattern:
|
|
6
|
+
|
|
7
|
+
- **max concerns per (platform, event)** — warns when any cell exceeds
|
|
8
|
+
the configured threshold. Default threshold is a placeholder sourced
|
|
9
|
+
from `current-max × 1.5, rounded up` until Phase 1 captures real
|
|
10
|
+
decision-trace evidence (`max(observed-in-Phase-1) × 1.5`).
|
|
11
|
+
- **fail-closed only for declared Tier-1 concerns** — errors when a
|
|
12
|
+
concern carries `fail_closed: true` without being listed in
|
|
13
|
+
`hooks.concern_budget.tier1_concerns`.
|
|
14
|
+
|
|
15
|
+
Out of scope for the static gate: **max execution time per concern**.
|
|
16
|
+
That signal lives in runtime decision-trace logs (Phase 2) and is
|
|
17
|
+
checked by a separate runtime probe once Phase 1 sessions produce
|
|
18
|
+
data — tracked as a P3.3 follow-up, not blocking this gate.
|
|
19
|
+
|
|
20
|
+
Defaults (override in `.agent-settings.yml`):
|
|
21
|
+
|
|
22
|
+
hooks:
|
|
23
|
+
concern_budget:
|
|
24
|
+
max_per_event: 8
|
|
25
|
+
tier1_concerns: []
|
|
26
|
+
hard_fail: false
|
|
27
|
+
|
|
28
|
+
Exit codes (warn-only mode, the default):
|
|
29
|
+
|
|
30
|
+
0 — clean, OR violations exist but `hard_fail` is false
|
|
31
|
+
1 — schema load failed (file absent / malformed)
|
|
32
|
+
2 — `hard_fail: true` and at least one violation
|
|
33
|
+
|
|
34
|
+
Hard-fail mode is gated on Phase 1 evidence (≥10 captured sessions per
|
|
35
|
+
the roadmap exit criterion).
|
|
36
|
+
|
|
37
|
+
Invocation:
|
|
38
|
+
|
|
39
|
+
python3 scripts/lint_hook_concern_budget.py [--manifest PATH]
|
|
40
|
+
[--settings PATH]
|
|
41
|
+
[--strict]
|
|
42
|
+
|
|
43
|
+
`--strict` upgrades warn-only to hard-fail regardless of settings — for
|
|
44
|
+
CI lanes that want to surface the gate on every PR.
|
|
45
|
+
"""
|
|
46
|
+
from __future__ import annotations
|
|
47
|
+
|
|
48
|
+
import argparse
|
|
49
|
+
import re
|
|
50
|
+
import sys
|
|
51
|
+
from pathlib import Path
|
|
52
|
+
|
|
53
|
+
REPO_ROOT = Path(__file__).resolve().parent.parent
|
|
54
|
+
DEFAULT_MANIFEST = REPO_ROOT / "scripts" / "hook_manifest.yaml"
|
|
55
|
+
DEFAULT_SETTINGS = REPO_ROOT / ".agent-settings.yml"
|
|
56
|
+
|
|
57
|
+
DEFAULT_MAX_PER_EVENT = 8
|
|
58
|
+
DEFAULT_TIER1: list[str] = []
|
|
59
|
+
DEFAULT_HARD_FAIL = False
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _load_manifest(path: Path) -> dict:
|
|
63
|
+
sys.path.insert(0, str(REPO_ROOT / "scripts"))
|
|
64
|
+
from hooks.dispatch_hook import _load_yaml # noqa: E402
|
|
65
|
+
return _load_yaml(path)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _read_settings_block(settings_path: Path) -> dict:
|
|
69
|
+
"""Minimal YAML walk for `hooks.concern_budget.*`. Mirrors the
|
|
70
|
+
pattern used by `scripts/minimal_safe_diff_hook.py` — no PyYAML
|
|
71
|
+
dependency, tolerant of missing keys / blocks."""
|
|
72
|
+
out: dict = {}
|
|
73
|
+
if not settings_path.is_file():
|
|
74
|
+
return out
|
|
75
|
+
in_hooks = False
|
|
76
|
+
in_budget = False
|
|
77
|
+
in_tier1 = False
|
|
78
|
+
try:
|
|
79
|
+
text = settings_path.read_text(encoding="utf-8")
|
|
80
|
+
except OSError:
|
|
81
|
+
return out
|
|
82
|
+
for raw in text.splitlines():
|
|
83
|
+
line = raw.rstrip()
|
|
84
|
+
if re.match(r"^hooks\s*:\s*(?:#.*)?$", line):
|
|
85
|
+
in_hooks, in_budget, in_tier1 = True, False, False
|
|
86
|
+
continue
|
|
87
|
+
if in_hooks and re.match(r"^\S", line):
|
|
88
|
+
in_hooks = in_budget = in_tier1 = False
|
|
89
|
+
if in_hooks and re.match(r"^\s{2}concern_budget\s*:\s*(?:#.*)?$", line):
|
|
90
|
+
in_budget, in_tier1 = True, False
|
|
91
|
+
continue
|
|
92
|
+
if in_budget and re.match(r"^\s{2}\S", line):
|
|
93
|
+
in_budget = in_tier1 = False
|
|
94
|
+
if in_budget:
|
|
95
|
+
m = re.match(r"^\s{4}max_per_event\s*:\s*(\d+)", line)
|
|
96
|
+
if m:
|
|
97
|
+
out["max_per_event"] = int(m.group(1))
|
|
98
|
+
in_tier1 = False
|
|
99
|
+
continue
|
|
100
|
+
m = re.match(r"^\s{4}hard_fail\s*:\s*(true|false)", line)
|
|
101
|
+
if m:
|
|
102
|
+
out["hard_fail"] = m.group(1) == "true"
|
|
103
|
+
in_tier1 = False
|
|
104
|
+
continue
|
|
105
|
+
if re.match(r"^\s{4}tier1_concerns\s*:\s*\[\s*\]", line):
|
|
106
|
+
out["tier1_concerns"] = []
|
|
107
|
+
in_tier1 = False
|
|
108
|
+
continue
|
|
109
|
+
if re.match(r"^\s{4}tier1_concerns\s*:\s*(?:#.*)?$", line):
|
|
110
|
+
out.setdefault("tier1_concerns", [])
|
|
111
|
+
in_tier1 = True
|
|
112
|
+
continue
|
|
113
|
+
if in_tier1:
|
|
114
|
+
m = re.match(r"^\s{6}-\s*([A-Za-z0-9_\-]+)", line)
|
|
115
|
+
if m:
|
|
116
|
+
out.setdefault("tier1_concerns", []).append(m.group(1))
|
|
117
|
+
return out
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _check_concern_counts(manifest: dict, max_per_event: int,
|
|
121
|
+
warnings: list[str]) -> None:
|
|
122
|
+
platforms = manifest.get("platforms") or {}
|
|
123
|
+
if not isinstance(platforms, dict):
|
|
124
|
+
return
|
|
125
|
+
for plat, block in platforms.items():
|
|
126
|
+
if not isinstance(block, dict) or block.get("fallback_only"):
|
|
127
|
+
continue
|
|
128
|
+
for event, names in block.items():
|
|
129
|
+
if not isinstance(names, list):
|
|
130
|
+
continue
|
|
131
|
+
count = len(names)
|
|
132
|
+
if count > max_per_event:
|
|
133
|
+
warnings.append(
|
|
134
|
+
f"platforms.{plat}.{event}: {count} concerns "
|
|
135
|
+
f"(threshold {max_per_event}). Trim or raise "
|
|
136
|
+
"hooks.concern_budget.max_per_event in .agent-settings.yml."
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _check_fail_closed_tier(manifest: dict, tier1: list[str],
|
|
141
|
+
errors: list[str]) -> None:
|
|
142
|
+
concerns = manifest.get("concerns") or {}
|
|
143
|
+
if not isinstance(concerns, dict):
|
|
144
|
+
return
|
|
145
|
+
allowed = set(tier1)
|
|
146
|
+
for name, spec in concerns.items():
|
|
147
|
+
if not isinstance(spec, dict):
|
|
148
|
+
continue
|
|
149
|
+
if spec.get("fail_closed") is True and name not in allowed:
|
|
150
|
+
errors.append(
|
|
151
|
+
f"concerns.{name}: fail_closed=true but not declared in "
|
|
152
|
+
"hooks.concern_budget.tier1_concerns. Promotion to Tier-1 "
|
|
153
|
+
"is explicit opt-in (Phase 1 evidence required)."
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def lint(manifest_path: Path, settings_path: Path, *,
|
|
158
|
+
strict: bool = False) -> int:
|
|
159
|
+
if not manifest_path.is_file():
|
|
160
|
+
sys.stderr.write(f"lint_hook_concern_budget: file not found: "
|
|
161
|
+
f"{manifest_path}\n")
|
|
162
|
+
return 1
|
|
163
|
+
try:
|
|
164
|
+
manifest = _load_manifest(manifest_path)
|
|
165
|
+
except Exception as exc: # pragma: no cover
|
|
166
|
+
sys.stderr.write(f"lint_hook_concern_budget: load error: {exc}\n")
|
|
167
|
+
return 1
|
|
168
|
+
if not isinstance(manifest, dict):
|
|
169
|
+
sys.stderr.write("lint_hook_concern_budget: manifest is not a mapping\n")
|
|
170
|
+
return 1
|
|
171
|
+
|
|
172
|
+
settings = _read_settings_block(settings_path)
|
|
173
|
+
max_per_event = settings.get("max_per_event", DEFAULT_MAX_PER_EVENT)
|
|
174
|
+
tier1 = settings.get("tier1_concerns", DEFAULT_TIER1)
|
|
175
|
+
hard_fail = settings.get("hard_fail", DEFAULT_HARD_FAIL) or strict
|
|
176
|
+
|
|
177
|
+
warnings: list[str] = []
|
|
178
|
+
errors: list[str] = []
|
|
179
|
+
_check_concern_counts(manifest, max_per_event, warnings)
|
|
180
|
+
_check_fail_closed_tier(manifest, tier1, errors)
|
|
181
|
+
|
|
182
|
+
for w in warnings:
|
|
183
|
+
sys.stderr.write(f"warn: {w}\n")
|
|
184
|
+
for e in errors:
|
|
185
|
+
sys.stderr.write(f"error: {e}\n")
|
|
186
|
+
|
|
187
|
+
if hard_fail and (warnings or errors):
|
|
188
|
+
return 2
|
|
189
|
+
return 0
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def main(argv: list[str] | None = None) -> int:
|
|
193
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
194
|
+
parser.add_argument("--manifest", type=Path, default=DEFAULT_MANIFEST)
|
|
195
|
+
parser.add_argument("--settings", type=Path, default=DEFAULT_SETTINGS)
|
|
196
|
+
parser.add_argument("--strict", action="store_true",
|
|
197
|
+
help="upgrade warn-only to hard-fail")
|
|
198
|
+
args = parser.parse_args(argv)
|
|
199
|
+
return lint(args.manifest, args.settings, strict=args.strict)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
if __name__ == "__main__":
|
|
203
|
+
raise SystemExit(main())
|
|
@@ -11,6 +11,7 @@ boundary in `agents/roadmaps/road-to-mcp-server.md`. No `tools`
|
|
|
11
11
|
primitive, no engine spawn, no shell execution.
|
|
12
12
|
|
|
13
13
|
Stability: experimental. Contract: `docs/contracts/mcp-phase-1-scope.md`.
|
|
14
|
+
Promotion to beta gated on `docs/contracts/mcp-beta-criteria.md`.
|
|
14
15
|
"""
|
|
15
16
|
from __future__ import annotations
|
|
16
17
|
|
|
@@ -125,9 +125,10 @@ def build_server(
|
|
|
125
125
|
name=SERVER_NAME,
|
|
126
126
|
version=__version__,
|
|
127
127
|
instructions=(
|
|
128
|
-
"agent-config MCP server (Phase 3, experimental
|
|
129
|
-
"
|
|
130
|
-
"
|
|
128
|
+
"agent-config MCP server (Phase 3, experimental; beta gates "
|
|
129
|
+
"in docs/contracts/mcp-beta-criteria.md). Exposes all skills "
|
|
130
|
+
"+ commands as instructional prompts, plus rules + guidelines "
|
|
131
|
+
"+ contexts as read-only resources."
|
|
131
132
|
),
|
|
132
133
|
)
|
|
133
134
|
|
|
@@ -26,10 +26,13 @@ from __future__ import annotations
|
|
|
26
26
|
|
|
27
27
|
import argparse
|
|
28
28
|
import json
|
|
29
|
+
import os
|
|
29
30
|
import subprocess
|
|
30
31
|
import sys
|
|
31
32
|
from pathlib import Path
|
|
32
33
|
|
|
34
|
+
REPLAY_ENV_VAR = "AGENT_CONFIG_REPLAY"
|
|
35
|
+
|
|
33
36
|
# Tools whose successful execution can write to a roadmap file. We keep
|
|
34
37
|
# the list explicit so an unknown tool name (e.g. a new MCP tool that
|
|
35
38
|
# happens to mention a roadmap path in its input) does not trigger a
|
|
@@ -134,6 +137,14 @@ def run(stdin_text: str, *, consumer_root: Path, verbose: bool = False) -> int:
|
|
|
134
137
|
file=sys.stderr)
|
|
135
138
|
return 0
|
|
136
139
|
|
|
140
|
+
# Replay mode (`AGENT_CONFIG_REPLAY=1`) skips the regenerator subprocess
|
|
141
|
+
# so fixture dispatches never rewrite agents/roadmaps-progress.md.
|
|
142
|
+
if os.environ.get(REPLAY_ENV_VAR, "").strip() == "1":
|
|
143
|
+
if verbose:
|
|
144
|
+
print("roadmap-progress-hook: replay mode, skipping regenerator",
|
|
145
|
+
file=sys.stderr)
|
|
146
|
+
return 0
|
|
147
|
+
|
|
137
148
|
try:
|
|
138
149
|
subprocess.run(
|
|
139
150
|
[sys.executable, str(script)],
|
|
@@ -72,9 +72,9 @@
|
|
|
72
72
|
"uniqueItems": true,
|
|
73
73
|
"items": {
|
|
74
74
|
"type": "string",
|
|
75
|
-
"enum": ["product", "team", "repo", "channel-stage", "funnel-stage", "customer-segment"]
|
|
75
|
+
"enum": ["product", "team", "repo", "channel-stage", "funnel-stage", "customer-segment", "fiscal-period", "org-stage", "regulatory-regime"]
|
|
76
76
|
},
|
|
77
|
-
"description": "Senior-skill opt-in for the context spine. Declares which slots under agents/context-spine/ the skill expects to read. Cross-wing slots (product, team, repo) are locked at 3 by council Q1 (KEEP-3); wing-scoped slots
|
|
77
|
+
"description": "Senior-skill opt-in for the context spine. Declares which slots under agents/context-spine/ the skill expects to read. Cross-wing slots (product, team, repo) are locked at 3 by council Q1 (KEEP-3); wing-scoped slots follow the per-wing ADR track in docs/contracts/context-spine.md § 5. Wing-3 (channel-stage, funnel-stage, customer-segment) authorized by docs/contracts/adr-gtm-context-spine.md; Wing-4 (fiscal-period, org-stage, regulatory-regime) authorized by docs/contracts/adr-wing4-context-spine.md."
|
|
78
78
|
},
|
|
79
79
|
"execution": {
|
|
80
80
|
"type": "object",
|
package/scripts/skill_linter.py
CHANGED
|
@@ -211,6 +211,48 @@ WING3_CHANNEL_TACTIC_PATTERN = re.compile(
|
|
|
211
211
|
re.IGNORECASE,
|
|
212
212
|
)
|
|
213
213
|
|
|
214
|
+
# --- Wing-4 Money/Strategy/Ops cognition-boundary patterns (council Q7 / J2) ---
|
|
215
|
+
# Triggered only when a skill's context_spine declares a Wing-4 slot.
|
|
216
|
+
# See docs/contracts/adr-wing4-context-spine.md and
|
|
217
|
+
# agents/roadmaps/road-to-money-strategy-ops.md § J2.
|
|
218
|
+
WING4_SPINE_SLOTS = {"fiscal-period", "org-stage", "regulatory-regime"}
|
|
219
|
+
|
|
220
|
+
# agent-operability: external finance / HR / legal SaaS URLs
|
|
221
|
+
WING4_SAAS_URL_PATTERN = re.compile(
|
|
222
|
+
r"https?://[\w.-]*\.(quickbooks|intuit|netsuite|xero|sage|"
|
|
223
|
+
r"carta|pulley|gusto|bamboohr|lattice|15five|justworks|"
|
|
224
|
+
r"docusign|ironclad|onetrust|rippling|workday|deel|"
|
|
225
|
+
r"namely|adp|paychex|trinet|hibob|cultureamp)\.(com|io|co)\b",
|
|
226
|
+
re.IGNORECASE,
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
# vendor-independence: finance / HR / legal brand / SDK slugs
|
|
230
|
+
WING4_VENDOR_BLACKLIST = re.compile(
|
|
231
|
+
r"\b(quickbooks|netsuite|xero|sage intacct|"
|
|
232
|
+
r"carta|pulley|gusto|bamboohr|lattice|15five|justworks|"
|
|
233
|
+
r"docusign|ironclad|onetrust|rippling|workday|deel|"
|
|
234
|
+
r"namely|adp|paychex|trinet|hibob|culture amp)\b",
|
|
235
|
+
re.IGNORECASE,
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
# stage-agnosticism: prescriptive stage-specific thresholds that lock cognition
|
|
239
|
+
# Catches hardcoded runway / ARR / burn / team-size prescriptions tied to a
|
|
240
|
+
# specific funding stage. Framework-style framing ("read the org-stage slot",
|
|
241
|
+
# "applies across seed and public") passes; hard prescriptions ("18 months of
|
|
242
|
+
# runway", "Series A teams must hire") fire.
|
|
243
|
+
WING4_STAGE_AGNOSTIC_PATTERN = re.compile(
|
|
244
|
+
r"(?:"
|
|
245
|
+
r"\b\d+\s+months?\s+of\s+runway\b"
|
|
246
|
+
r"|\brunway\s+of\s+at\s+least\s+\d+\s+months?\b"
|
|
247
|
+
r"|\bminimum\s+runway\s+of\s+\d+\b"
|
|
248
|
+
r"|\b(?:seed|series\s+[a-d]|growth|pre-?ipo|post-?ipo)[-\s]stage\s+"
|
|
249
|
+
r"(?:companies|startups|teams|founders|orgs)\s+(?:must|should|always|never)\b"
|
|
250
|
+
r"|\bteam\s+of\s+\d+\s+(?:or\s+more|or\s+fewer)\b"
|
|
251
|
+
r"|\b(?:arr|mrr|burn\s+rate)\s+(?:of|over|under|above|below)\s+\$\d+"
|
|
252
|
+
r")",
|
|
253
|
+
re.IGNORECASE,
|
|
254
|
+
)
|
|
255
|
+
|
|
214
256
|
|
|
215
257
|
@dataclass
|
|
216
258
|
class Issue:
|
|
@@ -619,9 +661,9 @@ def lint_skill(path: Path, text: str) -> LintResult:
|
|
|
619
661
|
skill_name = path.parent.name if path.name == "SKILL.md" else path.stem
|
|
620
662
|
if skill_name and "-" not in skill_name and len(skill_name) >= 3:
|
|
621
663
|
# Single word without qualifier — likely too generic
|
|
622
|
-
ALLOWED_BARE_NOUNS = {"database", "devcontainer", "docker", "eloquent", "flux", "
|
|
623
|
-
"laravel", "livewire", "mcp", "openapi", "performance",
|
|
624
|
-
"terraform", "terragrunt", "traefik", "websocket"}
|
|
664
|
+
ALLOWED_BARE_NOUNS = {"database", "devcontainer", "docker", "eloquent", "flux", "forecasting",
|
|
665
|
+
"grafana", "laravel", "livewire", "mcp", "openapi", "performance",
|
|
666
|
+
"security", "terraform", "terragrunt", "traefik", "websocket"}
|
|
625
667
|
if skill_name.lower() not in ALLOWED_BARE_NOUNS:
|
|
626
668
|
issues.append(Issue("warning", "bare_noun_name",
|
|
627
669
|
f"Bare-noun skill name `{skill_name}` — consider adding a qualifier (e.g., `{skill_name}-management`)"))
|
|
@@ -660,6 +702,10 @@ def lint_skill(path: Path, text: str) -> LintResult:
|
|
|
660
702
|
if spine_slots and any(s in WING3_SPINE_SLOTS for s in spine_slots):
|
|
661
703
|
issues.extend(lint_wing3_boundaries(text))
|
|
662
704
|
|
|
705
|
+
# --- Wing-4 Money/Strategy/Ops cognition-boundary check (council Q7 / J2) ---
|
|
706
|
+
if spine_slots and any(s in WING4_SPINE_SLOTS for s in spine_slots):
|
|
707
|
+
issues.extend(lint_wing4_boundaries(text))
|
|
708
|
+
|
|
663
709
|
procedure_block = find_procedure_block(text)
|
|
664
710
|
if procedure_block is not None:
|
|
665
711
|
if not procedure_block:
|
|
@@ -1140,6 +1186,64 @@ def lint_wing3_boundaries(text: str) -> List[Issue]:
|
|
|
1140
1186
|
return issues
|
|
1141
1187
|
|
|
1142
1188
|
|
|
1189
|
+
def lint_wing4_boundaries(text: str) -> List[Issue]:
|
|
1190
|
+
"""Four Wing-4 Money/Strategy/Ops cognition-boundary checks.
|
|
1191
|
+
|
|
1192
|
+
Triggered when a skill's ``context_spine`` declares at least one
|
|
1193
|
+
Wing-4 slot (fiscal-period, org-stage, regulatory-regime). Enforces
|
|
1194
|
+
council Q7 / J2 verdict that Money/Strategy/Ops cognition stays:
|
|
1195
|
+
|
|
1196
|
+
- **agent-operability** — no external finance/HR/legal SaaS URLs.
|
|
1197
|
+
- **vendor-independence** — no QuickBooks/Carta/Gusto-class brand slugs.
|
|
1198
|
+
- **transferability** — no stack-locked tooling instructions.
|
|
1199
|
+
- **stage-agnosticism** — no prescriptive stage-specific thresholds.
|
|
1200
|
+
|
|
1201
|
+
Carve-outs are identical to Wing-3: fenced code, inline backticks,
|
|
1202
|
+
the ``## Do NOT`` block, and ``**WHEN NOT to use this**`` lists.
|
|
1203
|
+
Regulatory regime names (GDPR / HIPAA / SOC2 / PCI / CCPA) are
|
|
1204
|
+
cognition-relevant constraints, not vendors — they pass.
|
|
1205
|
+
"""
|
|
1206
|
+
issues: List[Issue] = []
|
|
1207
|
+
body = _strip_wing3_carve_outs(text)
|
|
1208
|
+
|
|
1209
|
+
match = WING4_SAAS_URL_PATTERN.search(body)
|
|
1210
|
+
if match:
|
|
1211
|
+
issues.append(Issue(
|
|
1212
|
+
"warning", "wing4_agent_operability",
|
|
1213
|
+
f"Wing-4 skill cites external SaaS URL `{match.group(0)}` outside "
|
|
1214
|
+
f"carve-outs — cognition skills must operate without SaaS auth "
|
|
1215
|
+
f"(council Q7 boundary)",
|
|
1216
|
+
))
|
|
1217
|
+
|
|
1218
|
+
match = WING4_VENDOR_BLACKLIST.search(body)
|
|
1219
|
+
if match:
|
|
1220
|
+
issues.append(Issue(
|
|
1221
|
+
"warning", "wing4_vendor_independence",
|
|
1222
|
+
f"Wing-4 skill names vendor `{match.group(0)}` outside carve-outs "
|
|
1223
|
+
f"— keep cognition vendor-agnostic (council Q7 boundary)",
|
|
1224
|
+
))
|
|
1225
|
+
|
|
1226
|
+
match = WING3_STACK_LOCKED_PATTERN.search(body)
|
|
1227
|
+
if match:
|
|
1228
|
+
issues.append(Issue(
|
|
1229
|
+
"warning", "wing4_transferability",
|
|
1230
|
+
f"Wing-4 skill includes stack-locked instruction `{match.group(0)}` "
|
|
1231
|
+
f"outside carve-outs — cognition should transfer across stacks "
|
|
1232
|
+
f"(council Q7 boundary)",
|
|
1233
|
+
))
|
|
1234
|
+
|
|
1235
|
+
match = WING4_STAGE_AGNOSTIC_PATTERN.search(body)
|
|
1236
|
+
if match:
|
|
1237
|
+
issues.append(Issue(
|
|
1238
|
+
"warning", "wing4_stage_agnosticism",
|
|
1239
|
+
f"Wing-4 skill prescribes stage-locked threshold "
|
|
1240
|
+
f"`{match.group(0)}` outside carve-outs — cognition must "
|
|
1241
|
+
f"transfer across seed and public (council Q7 boundary)",
|
|
1242
|
+
))
|
|
1243
|
+
|
|
1244
|
+
return issues
|
|
1245
|
+
|
|
1246
|
+
|
|
1143
1247
|
def lint_execution_metadata(execution: dict) -> List[Issue]:
|
|
1144
1248
|
"""Validate the execution block of a skill."""
|
|
1145
1249
|
issues: List[Issue] = []
|