@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.
Files changed (67) hide show
  1. package/.agent-src/personas/engineering-manager.md +133 -0
  2. package/.agent-src/personas/finance-partner.md +129 -0
  3. package/.agent-src/personas/people-strategist.md +126 -0
  4. package/.agent-src/personas/strategist.md +129 -0
  5. package/.agent-src/rules/no-roadmap-references.md +19 -0
  6. package/.agent-src/skills/build-buy-partner/SKILL.md +145 -0
  7. package/.agent-src/skills/comp-banding/SKILL.md +160 -0
  8. package/.agent-src/skills/competitive-moat-analysis/SKILL.md +152 -0
  9. package/.agent-src/skills/contracts-cognition/SKILL.md +147 -0
  10. package/.agent-src/skills/data-handling-judgment/SKILL.md +155 -0
  11. package/.agent-src/skills/forecasting/SKILL.md +164 -0
  12. package/.agent-src/skills/hiring-loop-design/SKILL.md +167 -0
  13. package/.agent-src/skills/market-entry-analysis/SKILL.md +144 -0
  14. package/.agent-src/skills/onboarding-program/SKILL.md +157 -0
  15. package/.agent-src/skills/one-on-one-cadence/SKILL.md +161 -0
  16. package/.agent-src/skills/org-design/SKILL.md +158 -0
  17. package/.agent-src/skills/perf-feedback-craft/SKILL.md +157 -0
  18. package/.agent-src/skills/privacy-review/SKILL.md +160 -0
  19. package/.agent-src/skills/runway-cognition/SKILL.md +136 -0
  20. package/.agent-src/skills/scenario-modeling/SKILL.md +139 -0
  21. package/.agent-src/skills/throughput-vs-morale-tradeoff/SKILL.md +165 -0
  22. package/.agent-src/skills/unit-economics-modeling/SKILL.md +54 -7
  23. package/.agent-src/skills/vision-articulation/SKILL.md +146 -0
  24. package/.agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  25. package/.agent-src/templates/scripts/telemetry/settings.py +65 -0
  26. package/.agent-src/templates/scripts/tier_usage_report.py +183 -0
  27. package/.agent-src/templates/scripts/work_engine/hooks/builtin/memory_visibility.py +32 -3
  28. package/.agent-src/templates/scripts/work_engine/scoring/memory_visibility.py +147 -1
  29. package/.claude-plugin/marketplace.json +18 -1
  30. package/AGENTS.md +1 -1
  31. package/CHANGELOG.md +134 -0
  32. package/README.md +34 -14
  33. package/config/agent-settings.template.yml +28 -0
  34. package/docs/architecture.md +37 -11
  35. package/docs/catalog.md +22 -4
  36. package/docs/contracts/adr-forecast-construction-shape.md +89 -0
  37. package/docs/contracts/adr-wing4-context-spine.md +125 -0
  38. package/docs/contracts/command-clusters.md +41 -0
  39. package/docs/contracts/command-surface-tiers.md +25 -9
  40. package/docs/contracts/context-spine.md +8 -0
  41. package/docs/contracts/decision-trace-v1.md +30 -0
  42. package/docs/contracts/hook-architecture-v1.md +46 -0
  43. package/docs/contracts/mcp-beta-criteria.md +129 -0
  44. package/docs/contracts/memory-visibility-v1.md +33 -0
  45. package/docs/contracts/settings-sync-yaml-subset.md +138 -0
  46. package/docs/guidelines/wing4-handoff.md +127 -0
  47. package/docs/mcp-server.md +1 -1
  48. package/docs/readme-split-plan.md +102 -0
  49. package/package.json +1 -1
  50. package/scripts/_cli/cmd_doctor.py +527 -14
  51. package/scripts/_cli/cmd_settings_check.py +171 -0
  52. package/scripts/_cli/cmd_validate.py +10 -0
  53. package/scripts/agent-config +59 -18
  54. package/scripts/chat_history.py +19 -0
  55. package/scripts/check_council_references.py +46 -5
  56. package/scripts/hooks/dispatch_hook.py +5 -1
  57. package/scripts/hooks/replay_hook.py +144 -0
  58. package/scripts/hooks/state_io.py +24 -1
  59. package/scripts/hooks_doctor.py +184 -0
  60. package/scripts/install.py +5 -0
  61. package/scripts/lint_context_spine_usage.py +1 -0
  62. package/scripts/lint_hook_concern_budget.py +203 -0
  63. package/scripts/mcp_server/__init__.py +1 -0
  64. package/scripts/mcp_server/server.py +4 -3
  65. package/scripts/roadmap_progress_hook.py +11 -0
  66. package/scripts/schemas/skill.schema.json +2 -2
  67. 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())
@@ -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
 
@@ -34,6 +34,7 @@ SKILL_GLOBS = (
34
34
  VALID_SLOTS = (
35
35
  "product", "team", "repo",
36
36
  "channel-stage", "funnel-stage", "customer-segment",
37
+ "fiscal-period", "org-stage", "regulatory-regime",
37
38
  )
38
39
 
39
40
  CONTEXT_SPINE_PAT = re.compile(
@@ -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). Exposes "
129
- "all skills + commands as instructional prompts, plus "
130
- "rules + guidelines + contexts as read-only resources."
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 (channel-stage, funnel-stage, customer-segment — Wing-3 GTM) follow the per-wing ADR track in docs/contracts/context-spine.md § 5. Wing-3 slots authorized by docs/contracts/adr-gtm-context-spine.md."
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",
@@ -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", "grafana",
623
- "laravel", "livewire", "mcp", "openapi", "performance", "security",
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] = []