@event4u/agent-config 2.9.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/rules/no-roadmap-references.md +19 -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/builtin/memory_visibility.py +32 -3
- 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/.agent-src/templates/scripts/work_engine/scoring/memory_visibility.py +147 -1
- package/.claude-plugin/marketplace.json +3 -1
- package/CHANGELOG.md +65 -0
- package/README.md +66 -17
- package/config/agent-settings.template.yml +85 -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 +31 -0
- package/docs/contracts/file-ownership-matrix.md +1 -0
- package/docs/contracts/hook-architecture-v1.md +47 -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 +34 -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 +139 -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/docs/readme-split-plan.md +102 -0
- package/package.json +1 -1
- package/scripts/_cli/cmd_settings_check.py +171 -0
- package/scripts/agent-config +40 -0
- package/scripts/chat_history.py +19 -0
- package/scripts/check_beta_review_markers.py +127 -0
- package/scripts/check_council_references.py +46 -5
- package/scripts/check_release_trunk_sync.py +152 -0
- 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 +3 -3
- package/scripts/lint_hook_concern_budget.py +203 -0
- package/scripts/roadmap_progress_hook.py +11 -0
- 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
|
@@ -37,7 +37,7 @@ MANIFEST_PATH = REPO_ROOT / "scripts" / "hook_manifest.yaml"
|
|
|
37
37
|
# Lazy import — we want this module to be importable even if the
|
|
38
38
|
# hooks package state_io has changed (test isolation).
|
|
39
39
|
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
40
|
-
from state_io import atomic_write_json, feedback_dir # noqa: E402
|
|
40
|
+
from state_io import atomic_write_json, feedback_dir, is_replay_mode # noqa: E402
|
|
41
41
|
|
|
42
42
|
EXIT_ALLOW = 0
|
|
43
43
|
EXIT_BLOCK = 1
|
|
@@ -272,6 +272,10 @@ def _write_feedback(envelope: dict, session_id: str, entries: list[dict],
|
|
|
272
272
|
not control flow. We only swallow IO errors here; fail-open
|
|
273
273
|
matches the dispatcher's overall posture.
|
|
274
274
|
"""
|
|
275
|
+
# Replay mode skips feedback emission entirely so fixture replays
|
|
276
|
+
# never create per-session dirs under agents/state/.dispatcher/.
|
|
277
|
+
if is_replay_mode():
|
|
278
|
+
return
|
|
275
279
|
workspace = envelope.get("workspace_root") or str(Path.cwd())
|
|
276
280
|
state_root = Path(workspace) / "agents" / "state"
|
|
277
281
|
fb_dir = feedback_dir(state_root, session_id)
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Fixture-driven hook replay — read-only dispatch through the runtime.
|
|
3
|
+
|
|
4
|
+
Reads a stdin payload fixture from `tests/fixtures/hooks/` (one file per
|
|
5
|
+
event in `EVENT_VOCABULARY`), sets `AGENT_CONFIG_REPLAY=1`, and invokes
|
|
6
|
+
the universal dispatcher with the platform / event / payload tuple. The
|
|
7
|
+
replay flag tells `state_io` (and concerns that honour it) to skip every
|
|
8
|
+
write under `agents/state/` so the replay never mutates real session
|
|
9
|
+
state.
|
|
10
|
+
|
|
11
|
+
Invocation:
|
|
12
|
+
|
|
13
|
+
python3 scripts/hooks/replay_hook.py \\
|
|
14
|
+
--platform <name> \\
|
|
15
|
+
--event <agent-config-event> \\
|
|
16
|
+
--payload tests/fixtures/hooks/<event>.json \\
|
|
17
|
+
[--native-event <native>] \\
|
|
18
|
+
[--manifest <path>] \\
|
|
19
|
+
[--json]
|
|
20
|
+
|
|
21
|
+
The `--json` flag prints a structured replay summary on stdout
|
|
22
|
+
(platform, event, dispatcher exit code, captured stderr lines).
|
|
23
|
+
Non-zero exit is propagated from the dispatcher.
|
|
24
|
+
|
|
25
|
+
Contract reference: `docs/contracts/hook-architecture-v1.md` § Replay
|
|
26
|
+
mode. Roadmap step: P2.4b of `agents/roadmaps/road-to-proof-not-features.md`.
|
|
27
|
+
"""
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
import argparse
|
|
31
|
+
import json
|
|
32
|
+
import os
|
|
33
|
+
import subprocess
|
|
34
|
+
import sys
|
|
35
|
+
from pathlib import Path
|
|
36
|
+
|
|
37
|
+
REPO_ROOT = Path(__file__).resolve().parents[2]
|
|
38
|
+
DISPATCHER = REPO_ROOT / "scripts" / "hooks" / "dispatch_hook.py"
|
|
39
|
+
DEFAULT_MANIFEST = REPO_ROOT / "scripts" / "hook_manifest.yaml"
|
|
40
|
+
FIXTURE_DIR = REPO_ROOT / "tests" / "fixtures" / "hooks"
|
|
41
|
+
REPLAY_ENV_VAR = "AGENT_CONFIG_REPLAY"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _resolve_payload(arg: str) -> Path:
|
|
45
|
+
"""Accept either an absolute path, a path relative to CWD, or a bare
|
|
46
|
+
event name that resolves to `tests/fixtures/hooks/<name>.json`."""
|
|
47
|
+
candidate = Path(arg)
|
|
48
|
+
if candidate.is_file():
|
|
49
|
+
return candidate
|
|
50
|
+
bare = FIXTURE_DIR / f"{arg}.json"
|
|
51
|
+
if bare.is_file():
|
|
52
|
+
return bare
|
|
53
|
+
raise FileNotFoundError(
|
|
54
|
+
f"replay_hook: payload not found — tried '{candidate}' and '{bare}'")
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _build_argparser() -> argparse.ArgumentParser:
|
|
58
|
+
p = argparse.ArgumentParser(description=__doc__)
|
|
59
|
+
p.add_argument("--platform", required=True,
|
|
60
|
+
help="Platform key as declared in hook_manifest.yaml "
|
|
61
|
+
"(augment, claude, cursor, cline, windsurf, gemini, copilot).")
|
|
62
|
+
p.add_argument("--event", required=True,
|
|
63
|
+
help="agent-config event (see EVENT_VOCABULARY in "
|
|
64
|
+
"scripts/hooks/dispatch_hook.py).")
|
|
65
|
+
p.add_argument("--payload", required=True,
|
|
66
|
+
help="Path to a fixture JSON file, or a bare event name "
|
|
67
|
+
"resolved under tests/fixtures/hooks/.")
|
|
68
|
+
p.add_argument("--native-event", default="",
|
|
69
|
+
help="Optional native event name for diagnostics.")
|
|
70
|
+
p.add_argument("--manifest", default=str(DEFAULT_MANIFEST),
|
|
71
|
+
help=f"Hook manifest path (default: {DEFAULT_MANIFEST}).")
|
|
72
|
+
p.add_argument("--json", action="store_true",
|
|
73
|
+
help="Emit a structured summary on stdout.")
|
|
74
|
+
p.add_argument("--dry-run", action="store_true",
|
|
75
|
+
help="Resolve concerns and print the dispatch plan; "
|
|
76
|
+
"do not invoke concerns.")
|
|
77
|
+
return p
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def main(argv: list[str] | None = None) -> int:
|
|
81
|
+
args = _build_argparser().parse_args(argv)
|
|
82
|
+
|
|
83
|
+
try:
|
|
84
|
+
payload_path = _resolve_payload(args.payload)
|
|
85
|
+
except FileNotFoundError as exc:
|
|
86
|
+
sys.stderr.write(f"❌ {exc}\n")
|
|
87
|
+
return 2
|
|
88
|
+
|
|
89
|
+
payload_text = payload_path.read_text(encoding="utf-8")
|
|
90
|
+
# Validate JSON early so dispatcher stderr stays focused on real
|
|
91
|
+
# concern problems. Empty / non-object payloads are still dispatched
|
|
92
|
+
# — that mirrors the platform contract (stdin can be empty).
|
|
93
|
+
try:
|
|
94
|
+
decoded = json.loads(payload_text) if payload_text.strip() else {}
|
|
95
|
+
except (ValueError, TypeError) as exc:
|
|
96
|
+
sys.stderr.write(f"❌ replay_hook: invalid JSON in {payload_path}: {exc}\n")
|
|
97
|
+
return 2
|
|
98
|
+
|
|
99
|
+
env = dict(os.environ)
|
|
100
|
+
env[REPLAY_ENV_VAR] = "1"
|
|
101
|
+
|
|
102
|
+
cmd = [sys.executable, str(DISPATCHER),
|
|
103
|
+
"--platform", args.platform,
|
|
104
|
+
"--event", args.event,
|
|
105
|
+
"--manifest", args.manifest]
|
|
106
|
+
if args.native_event:
|
|
107
|
+
cmd.extend(["--native-event", args.native_event])
|
|
108
|
+
if args.dry_run:
|
|
109
|
+
cmd.append("--dry-run")
|
|
110
|
+
|
|
111
|
+
proc = subprocess.run(
|
|
112
|
+
cmd, input=payload_text, capture_output=True, text=True, env=env,
|
|
113
|
+
check=False,
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
if args.json:
|
|
117
|
+
summary = {
|
|
118
|
+
"platform": args.platform,
|
|
119
|
+
"event": args.event,
|
|
120
|
+
"native_event": args.native_event or "",
|
|
121
|
+
"payload": str(payload_path.relative_to(REPO_ROOT)
|
|
122
|
+
if str(payload_path).startswith(str(REPO_ROOT))
|
|
123
|
+
else payload_path),
|
|
124
|
+
"session_id": decoded.get("session_id") if isinstance(decoded, dict) else None,
|
|
125
|
+
"exit_code": proc.returncode,
|
|
126
|
+
"dispatcher_stdout": (proc.stdout or "").strip(),
|
|
127
|
+
"dispatcher_stderr": (proc.stderr or "").strip(),
|
|
128
|
+
"replay_mode": True,
|
|
129
|
+
}
|
|
130
|
+
print(json.dumps(summary, indent=2))
|
|
131
|
+
else:
|
|
132
|
+
if proc.stdout:
|
|
133
|
+
sys.stdout.write(proc.stdout)
|
|
134
|
+
if proc.stderr:
|
|
135
|
+
sys.stderr.write(proc.stderr)
|
|
136
|
+
sys.stderr.write(
|
|
137
|
+
f"replay_hook: platform={args.platform} event={args.event} "
|
|
138
|
+
f"payload={payload_path.name} rc={proc.returncode} "
|
|
139
|
+
f"(AGENT_CONFIG_REPLAY=1, no writes)\n")
|
|
140
|
+
return proc.returncode
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
if __name__ == "__main__": # pragma: no cover
|
|
144
|
+
sys.exit(main())
|
|
@@ -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
|
@@ -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
|
|
|
@@ -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())
|
|
@@ -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)],
|
|
@@ -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-]*$",
|