@event4u/agent-config 1.17.0 → 1.18.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/rules/context-hygiene.md +6 -0
- package/.agent-src/rules/direct-answers.md +17 -26
- package/.agent-src/rules/no-cheap-questions.md +14 -21
- package/.agent-src/rules/onboarding-gate.md +7 -0
- package/.agent-src/rules/roadmap-progress-sync.md +27 -0
- package/.agent-src/rules/rule-type-governance.md +28 -0
- package/.agent-src/templates/roadmaps.md +4 -0
- package/.claude-plugin/marketplace.json +1 -1
- package/CHANGELOG.md +35 -0
- package/README.md +1 -1
- package/docs/architecture.md +1 -1
- package/docs/contracts/load-context-budget-model.md +80 -0
- package/docs/contracts/load-context-schema.md +20 -0
- package/docs/contracts/roadmap-complexity-standard.md +137 -0
- package/docs/guidelines/agent-infra/ask-when-uncertain-demos.md +134 -0
- package/docs/guidelines/agent-infra/direct-answers-demos.md +145 -0
- package/docs/guidelines/agent-infra/verify-before-complete-demos.md +128 -0
- package/package.json +1 -1
- package/scripts/agent-config +20 -0
- package/scripts/ai_council/one_off_archive/2026-05/README.md +45 -0
- package/scripts/ai_council/one_off_archive/2026-05/_one_off_budget_v2_audit.py +206 -0
- package/scripts/build_rule_trigger_matrix.py +360 -0
- package/scripts/check_always_budget.py +39 -0
- package/scripts/check_one_off_location.py +81 -0
- package/scripts/check_references.py +6 -0
- package/scripts/compress.py +5 -2
- package/scripts/context_hygiene_hook.py +173 -0
- package/scripts/hooks/augment-context-hygiene.sh +55 -0
- package/scripts/hooks/augment-onboarding-gate.sh +55 -0
- package/scripts/install.py +58 -19
- package/scripts/lint_examples.py +98 -0
- package/scripts/lint_roadmap_complexity.py +127 -0
- package/scripts/onboarding_gate_hook.py +137 -0
- package/scripts/schemas/rule.schema.json +5 -0
- /package/scripts/ai_council/{_one_off_2a4_acceptance.py → one_off_archive/2026-05/_one_off_2a4_acceptance.py} +0 -0
- /package/scripts/ai_council/{_one_off_context_layer_v1_estimate.py → one_off_archive/2026-05/_one_off_context_layer_v1_estimate.py} +0 -0
- /package/scripts/ai_council/{_one_off_context_layer_v1_review.py → one_off_archive/2026-05/_one_off_context_layer_v1_review.py} +0 -0
- /package/scripts/ai_council/{_one_off_followups_review.py → one_off_archive/2026-05/_one_off_followups_review.py} +0 -0
- /package/scripts/ai_council/{_one_off_nondestructive_inline_audit.py → one_off_archive/2026-05/_one_off_nondestructive_inline_audit.py} +0 -0
- /package/scripts/{_one_off_phase4_dispatch_latency.py → ai_council/one_off_archive/2026-05/_one_off_phase4_dispatch_latency.py} +0 -0
- /package/scripts/{_one_off_phase6_trigger_jaccard.py → ai_council/one_off_archive/2026-05/_one_off_phase6_trigger_jaccard.py} +0 -0
- /package/scripts/ai_council/{_one_off_phase_2a_budget_rebalance.py → one_off_archive/2026-05/_one_off_phase_2a_budget_rebalance.py} +0 -0
- /package/scripts/ai_council/{_one_off_phase_2a_post_revert.py → one_off_archive/2026-05/_one_off_phase_2a_post_revert.py} +0 -0
- /package/scripts/ai_council/{_one_off_rebalancing_audit.py → one_off_archive/2026-05/_one_off_rebalancing_audit.py} +0 -0
- /package/scripts/ai_council/{_one_off_roundtrip.py → one_off_archive/2026-05/_one_off_roundtrip.py} +0 -0
- /package/scripts/ai_council/{_one_off_rule_hardening_v1.py → one_off_archive/2026-05/_one_off_rule_hardening_v1.py} +0 -0
- /package/scripts/ai_council/{_one_off_structural_open_questions.py → one_off_archive/2026-05/_one_off_structural_open_questions.py} +0 -0
- /package/scripts/ai_council/{_one_off_structural_optimization.py → one_off_archive/2026-05/_one_off_structural_optimization.py} +0 -0
- /package/scripts/ai_council/{_one_off_structural_v3_gaps.py → one_off_archive/2026-05/_one_off_structural_v3_gaps.py} +0 -0
- /package/scripts/ai_council/{_one_off_structural_v3_review.py → one_off_archive/2026-05/_one_off_structural_v3_review.py} +0 -0
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""One-off script-location guard (Phase 0a.2 of road-to-rule-hardening).
|
|
3
|
+
|
|
4
|
+
Every ``_one_off_*.py`` script under ``scripts/`` must live inside the
|
|
5
|
+
archive folder ``scripts/ai_council/one_off_archive/<YYYY-MM>/``. The
|
|
6
|
+
guard fails CI if a new probe lands anywhere else in the tree.
|
|
7
|
+
|
|
8
|
+
Rationale: one-off council probes / phase-specific measurements are
|
|
9
|
+
inherently single-purpose; their durable artefact is the council
|
|
10
|
+
session under ``agents/council-sessions/``. Keeping them in the
|
|
11
|
+
archive prevents the ``scripts/`` root from accumulating noise and
|
|
12
|
+
makes their lifecycle visible (folder == month archived).
|
|
13
|
+
|
|
14
|
+
Exit codes:
|
|
15
|
+
0 = clean
|
|
16
|
+
1 = violation (script outside the archive)
|
|
17
|
+
3 = internal error
|
|
18
|
+
"""
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import argparse
|
|
22
|
+
import re
|
|
23
|
+
import sys
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
|
|
26
|
+
REPO_ROOT = Path(__file__).resolve().parent.parent
|
|
27
|
+
SCRIPTS = REPO_ROOT / "scripts"
|
|
28
|
+
ARCHIVE = SCRIPTS / "ai_council" / "one_off_archive"
|
|
29
|
+
ARCHIVE_MONTH_RE = re.compile(r"^\d{4}-\d{2}$")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def find_violations() -> list[Path]:
|
|
33
|
+
"""Return one-off scripts that are outside the archive folder."""
|
|
34
|
+
violations: list[Path] = []
|
|
35
|
+
for path in SCRIPTS.rglob("_one_off_*.py"):
|
|
36
|
+
if not path.is_file():
|
|
37
|
+
continue
|
|
38
|
+
# Must live under scripts/ai_council/one_off_archive/<YYYY-MM>/
|
|
39
|
+
try:
|
|
40
|
+
rel = path.relative_to(ARCHIVE)
|
|
41
|
+
except ValueError:
|
|
42
|
+
violations.append(path)
|
|
43
|
+
continue
|
|
44
|
+
# rel = "<YYYY-MM>/<name>.py"
|
|
45
|
+
parts = rel.parts
|
|
46
|
+
if len(parts) != 2 or not ARCHIVE_MONTH_RE.match(parts[0]):
|
|
47
|
+
violations.append(path)
|
|
48
|
+
return violations
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def main() -> int:
|
|
52
|
+
parser = argparse.ArgumentParser(description=__doc__.strip().splitlines()[0])
|
|
53
|
+
parser.add_argument("--quiet", action="store_true", help="Only print on failure")
|
|
54
|
+
args = parser.parse_args()
|
|
55
|
+
|
|
56
|
+
try:
|
|
57
|
+
violations = find_violations()
|
|
58
|
+
except Exception as exc: # pragma: no cover — defensive
|
|
59
|
+
print(f"❌ internal error: {exc}", file=sys.stderr)
|
|
60
|
+
return 3
|
|
61
|
+
|
|
62
|
+
if violations:
|
|
63
|
+
print("❌ one-off scripts outside the archive:", file=sys.stderr)
|
|
64
|
+
for path in violations:
|
|
65
|
+
rel = path.relative_to(REPO_ROOT)
|
|
66
|
+
print(f" {rel}", file=sys.stderr)
|
|
67
|
+
print(
|
|
68
|
+
"\n Move them under "
|
|
69
|
+
"scripts/ai_council/one_off_archive/<YYYY-MM>/ "
|
|
70
|
+
"(see that folder's README.md).",
|
|
71
|
+
file=sys.stderr,
|
|
72
|
+
)
|
|
73
|
+
return 1
|
|
74
|
+
|
|
75
|
+
if not args.quiet:
|
|
76
|
+
print("✅ all _one_off_*.py scripts are archived")
|
|
77
|
+
return 0
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
if __name__ == "__main__": # pragma: no cover
|
|
81
|
+
sys.exit(main())
|
|
@@ -274,6 +274,12 @@ def check_file(filepath: Path, artifacts: dict[str, set[str]], root: Path) -> Li
|
|
|
274
274
|
if (prefix / rel).exists():
|
|
275
275
|
resolved = True
|
|
276
276
|
break
|
|
277
|
+
# `agents/state/*.json` are runtime hook state files —
|
|
278
|
+
# gitignored, written by hooks at session/turn time, never
|
|
279
|
+
# committed. Prose references to them are descriptive, not
|
|
280
|
+
# checkable file paths.
|
|
281
|
+
if not resolved and raw_ref.startswith("agents/state/"):
|
|
282
|
+
resolved = True
|
|
277
283
|
if not resolved:
|
|
278
284
|
broken.append(BrokenRef(
|
|
279
285
|
file=str(filepath), line=i, ref=m.group(1),
|
package/scripts/compress.py
CHANGED
|
@@ -561,8 +561,11 @@ def project_to_augment() -> None:
|
|
|
561
561
|
dst.symlink_to(Path("..") / ".agent-src" / name)
|
|
562
562
|
print(f" ✅ Symlinked .augment/{name} → ../.agent-src/{name}")
|
|
563
563
|
|
|
564
|
-
# Cleanup: remove any stray top-level entries in .augment/ that are no longer projected
|
|
565
|
-
|
|
564
|
+
# Cleanup: remove any stray top-level entries in .augment/ that are no longer projected.
|
|
565
|
+
# `state` holds runtime state files written by hooks (onboarding-gate,
|
|
566
|
+
# context-hygiene, …) and must survive sync — it is regenerated by
|
|
567
|
+
# the next hook fire, not by compress.
|
|
568
|
+
known = set(AUGMENT_SYMLINK_DIRS) | set(AUGMENT_SYMLINK_FILES) | {"rules", "state"}
|
|
566
569
|
for item in AUGMENT_DIR.iterdir():
|
|
567
570
|
if item.name in known:
|
|
568
571
|
continue
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Platform-agnostic PostToolUse hook for the `context-hygiene` rule.
|
|
3
|
+
|
|
4
|
+
Maintains a deterministic state file the rule body cites for the
|
|
5
|
+
freshness threshold, the 3-failure stop, and tool-loop detection. The
|
|
6
|
+
agent's job shrinks from "remember three counters" to "read this file
|
|
7
|
+
before responding".
|
|
8
|
+
|
|
9
|
+
Output: `agents/state/context-hygiene.json`
|
|
10
|
+
{
|
|
11
|
+
"tool_calls": <int>, // running PostToolUse count
|
|
12
|
+
"consecutive_same_tool": <int>, // includes the latest call
|
|
13
|
+
"last_tool": "<name>",
|
|
14
|
+
"tool_history": [..., last 5 names],
|
|
15
|
+
"loop_detected": <bool>, // ≥ 3 same tool in a row
|
|
16
|
+
"freshness_threshold": <int|null>, // 20/40/60 milestone hit
|
|
17
|
+
"checked_at": "<iso8601>"
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
Exit code is always 0.
|
|
21
|
+
|
|
22
|
+
CLI:
|
|
23
|
+
python3 scripts/context_hygiene_hook.py [--platform NAME] [--verbose]
|
|
24
|
+
"""
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import argparse
|
|
28
|
+
import datetime as _dt
|
|
29
|
+
import json
|
|
30
|
+
import sys
|
|
31
|
+
from pathlib import Path
|
|
32
|
+
|
|
33
|
+
STATE_DIR = Path("agents") / "state"
|
|
34
|
+
STATE_FILE = STATE_DIR / "context-hygiene.json"
|
|
35
|
+
|
|
36
|
+
LOOP_THRESHOLD = 3 # 3+ consecutive same-tool calls
|
|
37
|
+
HISTORY_DEPTH = 5
|
|
38
|
+
FRESHNESS_MILESTONES = (20, 40, 60)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _load_state(target: Path) -> dict:
|
|
42
|
+
if not target.is_file():
|
|
43
|
+
return {
|
|
44
|
+
"tool_calls": 0,
|
|
45
|
+
"consecutive_same_tool": 0,
|
|
46
|
+
"last_tool": None,
|
|
47
|
+
"tool_history": [],
|
|
48
|
+
"loop_detected": False,
|
|
49
|
+
"freshness_threshold": None,
|
|
50
|
+
}
|
|
51
|
+
try:
|
|
52
|
+
decoded = json.loads(target.read_text(encoding="utf-8"))
|
|
53
|
+
if isinstance(decoded, dict):
|
|
54
|
+
return decoded
|
|
55
|
+
except (OSError, json.JSONDecodeError):
|
|
56
|
+
pass
|
|
57
|
+
# Corrupt — start fresh, never block.
|
|
58
|
+
return {
|
|
59
|
+
"tool_calls": 0,
|
|
60
|
+
"consecutive_same_tool": 0,
|
|
61
|
+
"last_tool": None,
|
|
62
|
+
"tool_history": [],
|
|
63
|
+
"loop_detected": False,
|
|
64
|
+
"freshness_threshold": None,
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _extract_tool(payload: dict) -> str | None:
|
|
69
|
+
for key in ("tool_name", "toolName", "tool"):
|
|
70
|
+
v = payload.get(key)
|
|
71
|
+
if isinstance(v, str) and v:
|
|
72
|
+
return v
|
|
73
|
+
return None
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _milestone_hit(prev: int, curr: int) -> int | None:
|
|
77
|
+
"""Return the milestone crossed by going from `prev` to `curr`, else None."""
|
|
78
|
+
for ms in FRESHNESS_MILESTONES:
|
|
79
|
+
if prev < ms <= curr:
|
|
80
|
+
return ms
|
|
81
|
+
return None
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _update(state: dict, tool: str | None) -> dict:
|
|
85
|
+
if tool is None:
|
|
86
|
+
# Non-tool event (e.g. malformed payload) — still mark we ran.
|
|
87
|
+
state["checked_at"] = _dt.datetime.now(_dt.timezone.utc).isoformat(
|
|
88
|
+
timespec="seconds")
|
|
89
|
+
return state
|
|
90
|
+
|
|
91
|
+
prev_count = int(state.get("tool_calls") or 0)
|
|
92
|
+
curr_count = prev_count + 1
|
|
93
|
+
state["tool_calls"] = curr_count
|
|
94
|
+
|
|
95
|
+
last = state.get("last_tool")
|
|
96
|
+
if last == tool:
|
|
97
|
+
state["consecutive_same_tool"] = int(
|
|
98
|
+
state.get("consecutive_same_tool") or 0) + 1
|
|
99
|
+
else:
|
|
100
|
+
state["consecutive_same_tool"] = 1
|
|
101
|
+
state["last_tool"] = tool
|
|
102
|
+
|
|
103
|
+
hist = state.get("tool_history") or []
|
|
104
|
+
if not isinstance(hist, list):
|
|
105
|
+
hist = []
|
|
106
|
+
hist.append(tool)
|
|
107
|
+
state["tool_history"] = hist[-HISTORY_DEPTH:]
|
|
108
|
+
|
|
109
|
+
state["loop_detected"] = (
|
|
110
|
+
state["consecutive_same_tool"] >= LOOP_THRESHOLD)
|
|
111
|
+
|
|
112
|
+
ms = _milestone_hit(prev_count, curr_count)
|
|
113
|
+
if ms is not None:
|
|
114
|
+
state["freshness_threshold"] = ms
|
|
115
|
+
state["checked_at"] = _dt.datetime.now(_dt.timezone.utc).isoformat(
|
|
116
|
+
timespec="seconds")
|
|
117
|
+
return state
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _write_state(consumer_root: Path, state: dict) -> None:
|
|
121
|
+
state_dir = consumer_root / STATE_DIR
|
|
122
|
+
state_dir.mkdir(parents=True, exist_ok=True)
|
|
123
|
+
target = consumer_root / STATE_FILE
|
|
124
|
+
tmp = target.with_suffix(".json.tmp")
|
|
125
|
+
tmp.write_text(json.dumps(state, indent=2) + "\n", encoding="utf-8")
|
|
126
|
+
tmp.replace(target)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def run(stdin_text: str, *, consumer_root: Path, verbose: bool = False) -> int:
|
|
130
|
+
payload: dict = {}
|
|
131
|
+
if stdin_text.strip():
|
|
132
|
+
try:
|
|
133
|
+
decoded = json.loads(stdin_text)
|
|
134
|
+
if isinstance(decoded, dict):
|
|
135
|
+
payload = decoded
|
|
136
|
+
except json.JSONDecodeError:
|
|
137
|
+
pass # silent no-op, never block
|
|
138
|
+
|
|
139
|
+
target = consumer_root / STATE_FILE
|
|
140
|
+
state = _load_state(target)
|
|
141
|
+
state = _update(state, _extract_tool(payload))
|
|
142
|
+
|
|
143
|
+
try:
|
|
144
|
+
_write_state(consumer_root, state)
|
|
145
|
+
except OSError:
|
|
146
|
+
if verbose:
|
|
147
|
+
print("context-hygiene-hook: state write failed",
|
|
148
|
+
file=sys.stderr)
|
|
149
|
+
return 0
|
|
150
|
+
|
|
151
|
+
if verbose:
|
|
152
|
+
print(
|
|
153
|
+
f"context-hygiene-hook: tool_calls={state.get('tool_calls')} "
|
|
154
|
+
f"loop={state.get('loop_detected')} "
|
|
155
|
+
f"threshold={state.get('freshness_threshold')}",
|
|
156
|
+
file=sys.stderr,
|
|
157
|
+
)
|
|
158
|
+
return 0
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def main(argv: list[str] | None = None) -> int:
|
|
162
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
163
|
+
parser.add_argument("--platform", default="generic",
|
|
164
|
+
help="informational platform tag")
|
|
165
|
+
parser.add_argument("--verbose", action="store_true",
|
|
166
|
+
help="emit one stderr line per invocation")
|
|
167
|
+
args = parser.parse_args(argv)
|
|
168
|
+
return run(sys.stdin.read(), consumer_root=Path.cwd(),
|
|
169
|
+
verbose=args.verbose)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
if __name__ == "__main__": # pragma: no cover
|
|
173
|
+
sys.exit(main())
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Augment Code lifecycle-hook trampoline for context-hygiene.
|
|
3
|
+
#
|
|
4
|
+
# Augment requires hook scripts to use the .sh extension and live at
|
|
5
|
+
# either a system path (/etc/augment/...) or user scope
|
|
6
|
+
# (~/.augment/...). This trampoline lives at user scope and dispatches
|
|
7
|
+
# every PostToolUse event to whichever workspace fired it, so a single
|
|
8
|
+
# install covers every project that has ./agent-config available.
|
|
9
|
+
#
|
|
10
|
+
# Behaviour:
|
|
11
|
+
# - Read the JSON event from stdin into a buffer.
|
|
12
|
+
# - Extract workspace_roots[0]; bail silently when missing.
|
|
13
|
+
# - cd into that workspace; bail silently when it is not a directory
|
|
14
|
+
# or does not contain ./agent-config.
|
|
15
|
+
# - Re-pipe the original JSON into
|
|
16
|
+
# ./agent-config context-hygiene:hook --platform augment
|
|
17
|
+
# so context_hygiene_hook.py can update the per-turn tracker.
|
|
18
|
+
# - Always exit 0 — PostToolUse hooks must never block.
|
|
19
|
+
|
|
20
|
+
set -u
|
|
21
|
+
|
|
22
|
+
EVENT_DATA="$(cat)"
|
|
23
|
+
|
|
24
|
+
WORKSPACE=""
|
|
25
|
+
if command -v jq >/dev/null 2>&1; then
|
|
26
|
+
WORKSPACE="$(printf '%s' "$EVENT_DATA" \
|
|
27
|
+
| jq -r '.workspace_roots[0] // empty' 2>/dev/null)"
|
|
28
|
+
elif command -v python3 >/dev/null 2>&1; then
|
|
29
|
+
WORKSPACE="$(printf '%s' "$EVENT_DATA" | python3 -c '
|
|
30
|
+
import json, sys
|
|
31
|
+
try:
|
|
32
|
+
data = json.load(sys.stdin)
|
|
33
|
+
except Exception:
|
|
34
|
+
sys.exit(0)
|
|
35
|
+
roots = data.get("workspace_roots") or []
|
|
36
|
+
if roots:
|
|
37
|
+
print(roots[0])
|
|
38
|
+
' 2>/dev/null)"
|
|
39
|
+
fi
|
|
40
|
+
|
|
41
|
+
if [ -z "$WORKSPACE" ] || [ ! -d "$WORKSPACE" ]; then
|
|
42
|
+
exit 0
|
|
43
|
+
fi
|
|
44
|
+
|
|
45
|
+
cd "$WORKSPACE" 2>/dev/null || exit 0
|
|
46
|
+
|
|
47
|
+
if [ ! -x ./agent-config ]; then
|
|
48
|
+
exit 0
|
|
49
|
+
fi
|
|
50
|
+
|
|
51
|
+
printf '%s' "$EVENT_DATA" \
|
|
52
|
+
| ./agent-config context-hygiene:hook --platform augment \
|
|
53
|
+
>/dev/null 2>&1 || true
|
|
54
|
+
|
|
55
|
+
exit 0
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Augment Code lifecycle-hook trampoline for onboarding-gate.
|
|
3
|
+
#
|
|
4
|
+
# Augment requires hook scripts to use the .sh extension and live at
|
|
5
|
+
# either a system path (/etc/augment/...) or user scope
|
|
6
|
+
# (~/.augment/...). This trampoline lives at user scope and dispatches
|
|
7
|
+
# every event to whichever workspace fired it, so a single install
|
|
8
|
+
# covers every project that has ./agent-config available.
|
|
9
|
+
#
|
|
10
|
+
# Behaviour:
|
|
11
|
+
# - Read the JSON event from stdin into a buffer.
|
|
12
|
+
# - Extract workspace_roots[0]; bail silently when missing.
|
|
13
|
+
# - cd into that workspace; bail silently when it is not a directory
|
|
14
|
+
# or does not contain ./agent-config.
|
|
15
|
+
# - Re-pipe the original JSON into
|
|
16
|
+
# ./agent-config onboarding-gate:hook --platform augment
|
|
17
|
+
# so onboarding_gate_hook.py can refresh the state file.
|
|
18
|
+
# - Always exit 0 — onboarding-gate must never block the agent loop.
|
|
19
|
+
|
|
20
|
+
set -u
|
|
21
|
+
|
|
22
|
+
EVENT_DATA="$(cat)"
|
|
23
|
+
|
|
24
|
+
WORKSPACE=""
|
|
25
|
+
if command -v jq >/dev/null 2>&1; then
|
|
26
|
+
WORKSPACE="$(printf '%s' "$EVENT_DATA" \
|
|
27
|
+
| jq -r '.workspace_roots[0] // empty' 2>/dev/null)"
|
|
28
|
+
elif command -v python3 >/dev/null 2>&1; then
|
|
29
|
+
WORKSPACE="$(printf '%s' "$EVENT_DATA" | python3 -c '
|
|
30
|
+
import json, sys
|
|
31
|
+
try:
|
|
32
|
+
data = json.load(sys.stdin)
|
|
33
|
+
except Exception:
|
|
34
|
+
sys.exit(0)
|
|
35
|
+
roots = data.get("workspace_roots") or []
|
|
36
|
+
if roots:
|
|
37
|
+
print(roots[0])
|
|
38
|
+
' 2>/dev/null)"
|
|
39
|
+
fi
|
|
40
|
+
|
|
41
|
+
if [ -z "$WORKSPACE" ] || [ ! -d "$WORKSPACE" ]; then
|
|
42
|
+
exit 0
|
|
43
|
+
fi
|
|
44
|
+
|
|
45
|
+
cd "$WORKSPACE" 2>/dev/null || exit 0
|
|
46
|
+
|
|
47
|
+
if [ ! -x ./agent-config ]; then
|
|
48
|
+
exit 0
|
|
49
|
+
fi
|
|
50
|
+
|
|
51
|
+
printf '%s' "$EVENT_DATA" \
|
|
52
|
+
| ./agent-config onboarding-gate:hook --platform augment \
|
|
53
|
+
>/dev/null 2>&1 || true
|
|
54
|
+
|
|
55
|
+
exit 0
|
package/scripts/install.py
CHANGED
|
@@ -466,6 +466,8 @@ AUGMENT_USER_DIR = Path.home() / ".augment"
|
|
|
466
466
|
AUGMENT_USER_HOOKS_DIR = AUGMENT_USER_DIR / "hooks"
|
|
467
467
|
AUGMENT_CHAT_HISTORY_TRAMPOLINE = "augment-chat-history.sh"
|
|
468
468
|
AUGMENT_ROADMAP_PROGRESS_TRAMPOLINE = "augment-roadmap-progress.sh"
|
|
469
|
+
AUGMENT_ONBOARDING_GATE_TRAMPOLINE = "augment-onboarding-gate.sh"
|
|
470
|
+
AUGMENT_CONTEXT_HYGIENE_TRAMPOLINE = "augment-context-hygiene.sh"
|
|
469
471
|
# (trampoline name, list of events it should fire on). Each trampoline
|
|
470
472
|
# is a self-contained workspace router; mapping them per-event keeps the
|
|
471
473
|
# wiring explicit and lets a future hook bind to a different surface
|
|
@@ -475,6 +477,10 @@ AUGMENT_HOOK_BINDINGS = (
|
|
|
475
477
|
("SessionStart", "SessionEnd", "Stop", "PostToolUse")),
|
|
476
478
|
(AUGMENT_ROADMAP_PROGRESS_TRAMPOLINE,
|
|
477
479
|
("PostToolUse",)),
|
|
480
|
+
(AUGMENT_ONBOARDING_GATE_TRAMPOLINE,
|
|
481
|
+
("SessionStart",)),
|
|
482
|
+
(AUGMENT_CONTEXT_HYGIENE_TRAMPOLINE,
|
|
483
|
+
("PostToolUse",)),
|
|
478
484
|
)
|
|
479
485
|
|
|
480
486
|
|
|
@@ -505,10 +511,15 @@ def ensure_augment_user_hooks(package_root: Path, force: bool) -> None:
|
|
|
505
511
|
payload and dispatches into whichever project is active at hook-fire
|
|
506
512
|
time.
|
|
507
513
|
|
|
508
|
-
|
|
509
|
-
|
|
514
|
+
Trampolines deployed (see AUGMENT_HOOK_BINDINGS for the source of
|
|
515
|
+
truth):
|
|
516
|
+
- augment-chat-history.sh → SessionStart/SessionEnd/Stop/PostToolUse
|
|
510
517
|
- augment-roadmap-progress.sh → PostToolUse (path-filtered to
|
|
511
518
|
agents/roadmaps/ — see scripts/roadmap_progress_hook.py)
|
|
519
|
+
- augment-onboarding-gate.sh → SessionStart (refresh
|
|
520
|
+
agents/state/onboarding-gate.json from .agent-settings.yml)
|
|
521
|
+
- augment-context-hygiene.sh → PostToolUse (per-turn counter,
|
|
522
|
+
loop detection, freshness milestones)
|
|
512
523
|
"""
|
|
513
524
|
per_event: dict[str, list] = {}
|
|
514
525
|
for name, events in AUGMENT_HOOK_BINDINGS:
|
|
@@ -531,36 +542,64 @@ def ensure_augment_user_hooks(package_root: Path, force: bool) -> None:
|
|
|
531
542
|
)
|
|
532
543
|
|
|
533
544
|
|
|
534
|
-
def
|
|
535
|
-
"""Single hook entry that calls ./agent-config
|
|
545
|
+
def _claude_hook_block(subcommand: str) -> dict:
|
|
546
|
+
"""Single hook entry that calls ./agent-config <subcommand> --platform claude."""
|
|
536
547
|
return {
|
|
537
548
|
"hooks": [
|
|
538
549
|
{
|
|
539
550
|
"type": "command",
|
|
540
|
-
"command": f"./agent-config
|
|
551
|
+
"command": f"./agent-config {subcommand} --platform claude",
|
|
541
552
|
},
|
|
542
553
|
],
|
|
543
554
|
}
|
|
544
555
|
|
|
545
556
|
|
|
546
|
-
|
|
547
|
-
|
|
557
|
+
# Claude Code Tier 1 hook bindings — keep in sync with AUGMENT_HOOK_BINDINGS.
|
|
558
|
+
# `chat-history:hook` is the cross-cutting transcript hook; the three
|
|
559
|
+
# rule-specific hooks are the Phase 4 Tier 1 set from
|
|
560
|
+
# `road-to-rule-hardening.md`.
|
|
561
|
+
CLAUDE_HOOK_SUBCOMMANDS = {
|
|
562
|
+
"chat-history": "chat-history:hook",
|
|
563
|
+
"roadmap-progress": "roadmap-progress:hook",
|
|
564
|
+
"onboarding-gate": "onboarding-gate:hook",
|
|
565
|
+
"context-hygiene": "context-hygiene:hook",
|
|
566
|
+
}
|
|
567
|
+
# (subcommand-key, list of Claude Code lifecycle events). Mirrors
|
|
568
|
+
# AUGMENT_HOOK_BINDINGS so each rule fires on the same logical surface
|
|
569
|
+
# on both platforms — the contract from
|
|
570
|
+
# `agents/contexts/hardening-pattern.md` § Cross-platform parity.
|
|
571
|
+
CLAUDE_HOOK_BINDINGS = (
|
|
572
|
+
("chat-history",
|
|
573
|
+
("SessionStart", "UserPromptSubmit", "PostToolUse", "Stop", "SessionEnd")),
|
|
574
|
+
("roadmap-progress",
|
|
575
|
+
("PostToolUse",)),
|
|
576
|
+
("onboarding-gate",
|
|
577
|
+
("SessionStart",)),
|
|
578
|
+
("context-hygiene",
|
|
579
|
+
("PostToolUse",)),
|
|
580
|
+
)
|
|
548
581
|
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
.
|
|
552
|
-
|
|
582
|
+
|
|
583
|
+
def ensure_claude_bridge(project_root: Path, force: bool) -> None:
|
|
584
|
+
"""Deploy .claude/settings.json with plugin enablement and Tier 1 hooks.
|
|
585
|
+
|
|
586
|
+
Hooks dispatch to the project-root ./agent-config wrapper, which routes
|
|
587
|
+
to the per-rule Python implementation (chat_history.py,
|
|
588
|
+
roadmap_progress_hook.py, onboarding_gate_hook.py,
|
|
589
|
+
context_hygiene_hook.py). They are no-ops when the relevant feature is
|
|
590
|
+
disabled in .agent-settings.yml. Idempotent: reruns merge cleanly
|
|
591
|
+
without duplicating entries (deep_merge replaces hook arrays rather
|
|
592
|
+
than appending).
|
|
553
593
|
"""
|
|
554
|
-
|
|
594
|
+
per_event: dict[str, list] = {}
|
|
595
|
+
for key, events in CLAUDE_HOOK_BINDINGS:
|
|
596
|
+
block = _claude_hook_block(CLAUDE_HOOK_SUBCOMMANDS[key])
|
|
597
|
+
for event in events:
|
|
598
|
+
per_event.setdefault(event, []).append(block)
|
|
599
|
+
|
|
555
600
|
bridge = {
|
|
556
601
|
"enabledPlugins": {"agent-conf@event4u": True},
|
|
557
|
-
"hooks":
|
|
558
|
-
"SessionStart": [claude_hook],
|
|
559
|
-
"UserPromptSubmit": [claude_hook],
|
|
560
|
-
"PostToolUse": [claude_hook],
|
|
561
|
-
"Stop": [claude_hook],
|
|
562
|
-
"SessionEnd": [claude_hook],
|
|
563
|
-
},
|
|
602
|
+
"hooks": per_event,
|
|
564
603
|
}
|
|
565
604
|
merge_json_file(project_root / ".claude" / "settings.json", bridge, force, ".claude/settings.json")
|
|
566
605
|
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Phase 3.4 demo-shape linter — wrong / right / why per demo.
|
|
3
|
+
|
|
4
|
+
Cap: ≤ 100 LOC, stdlib only. Hooked into `task ci` via
|
|
5
|
+
`Taskfile.yml` ▸ `check-examples-shape`. Validates every
|
|
6
|
+
`docs/guidelines/agent-infra/*-demos.md`: frontmatter keys
|
|
7
|
+
(`demo_for:`, `layer: pattern-memory`, `prose_delta:` with before /
|
|
8
|
+
after char counts), and each `## Demo N` section having Wrong /
|
|
9
|
+
Right shape headings, a `**Failure mode:**` line, and a Why-it-works
|
|
10
|
+
explanation (heading or inline).
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import re
|
|
15
|
+
import sys
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
REPO_ROOT = Path(__file__).resolve().parent.parent
|
|
19
|
+
DEMO_GLOB = "docs/guidelines/agent-infra/*-demos.md"
|
|
20
|
+
REQUIRED_FM_KEYS = ("demo_for:", "layer: pattern-memory", "prose_delta:")
|
|
21
|
+
REQUIRED_FM_DELTA = ("rule_chars_before:", "rule_chars_after:")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _frontmatter(text: str) -> str:
|
|
25
|
+
if not text.startswith("---\n"):
|
|
26
|
+
return ""
|
|
27
|
+
end = text.find("\n---\n", 4)
|
|
28
|
+
return text[4:end] if end != -1 else ""
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _check_frontmatter(fm: str, problems: list[str]) -> None:
|
|
32
|
+
for key in (*REQUIRED_FM_KEYS, *REQUIRED_FM_DELTA):
|
|
33
|
+
if key not in fm:
|
|
34
|
+
problems.append(f"frontmatter missing: {key!r}")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _check_demo_sections(text: str, problems: list[str]) -> None:
|
|
38
|
+
demo_pat = re.compile(r"^## Demo \d+\b.*$", re.MULTILINE)
|
|
39
|
+
demo_starts = [m.start() for m in demo_pat.finditer(text)]
|
|
40
|
+
if not demo_starts:
|
|
41
|
+
problems.append("no '## Demo N — …' sections found")
|
|
42
|
+
return
|
|
43
|
+
bounds = demo_starts + [len(text)]
|
|
44
|
+
for i, start in enumerate(demo_starts):
|
|
45
|
+
section = text[start:bounds[i + 1]]
|
|
46
|
+
title = section.splitlines()[0]
|
|
47
|
+
if "### Wrong shape" not in section:
|
|
48
|
+
problems.append(f"{title!r}: missing '### Wrong shape'")
|
|
49
|
+
if "### Right shape" not in section:
|
|
50
|
+
problems.append(f"{title!r}: missing '### Right shape'")
|
|
51
|
+
if "**Failure mode:**" not in section:
|
|
52
|
+
problems.append(f"{title!r}: missing '**Failure mode:**' line")
|
|
53
|
+
has_why_section = "### Why it works" in section
|
|
54
|
+
has_why_inline = "**Why it works:**" in section
|
|
55
|
+
if not (has_why_section or has_why_inline):
|
|
56
|
+
problems.append(
|
|
57
|
+
f"{title!r}: missing 'Why it works' explanation "
|
|
58
|
+
"(### Why it works or **Why it works:** inline)"
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def lint_demo(path: Path) -> list[str]:
|
|
63
|
+
text = path.read_text(encoding="utf-8")
|
|
64
|
+
problems: list[str] = []
|
|
65
|
+
fm = _frontmatter(text)
|
|
66
|
+
if not fm:
|
|
67
|
+
problems.append("missing YAML frontmatter (--- block at top)")
|
|
68
|
+
else:
|
|
69
|
+
_check_frontmatter(fm, problems)
|
|
70
|
+
_check_demo_sections(text, problems)
|
|
71
|
+
return problems
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def main() -> int:
|
|
75
|
+
demos = sorted(REPO_ROOT.glob(DEMO_GLOB))
|
|
76
|
+
if not demos:
|
|
77
|
+
print(f"❌ no demo files matched {DEMO_GLOB}", file=sys.stderr)
|
|
78
|
+
return 1
|
|
79
|
+
failed = 0
|
|
80
|
+
for demo in demos:
|
|
81
|
+
rel = demo.relative_to(REPO_ROOT)
|
|
82
|
+
problems = lint_demo(demo)
|
|
83
|
+
if problems:
|
|
84
|
+
failed += 1
|
|
85
|
+
print(f"❌ {rel}", file=sys.stderr)
|
|
86
|
+
for p in problems:
|
|
87
|
+
print(f" - {p}", file=sys.stderr)
|
|
88
|
+
else:
|
|
89
|
+
print(f"✅ {rel}")
|
|
90
|
+
if failed:
|
|
91
|
+
print(f"\n❌ {failed} demo file(s) failed shape lint", file=sys.stderr)
|
|
92
|
+
return 1
|
|
93
|
+
print(f"\n✅ {len(demos)} demo file(s) shape-clean")
|
|
94
|
+
return 0
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
if __name__ == "__main__":
|
|
98
|
+
sys.exit(main())
|