@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.
Files changed (50) hide show
  1. package/.agent-src/rules/context-hygiene.md +6 -0
  2. package/.agent-src/rules/direct-answers.md +17 -26
  3. package/.agent-src/rules/no-cheap-questions.md +14 -21
  4. package/.agent-src/rules/onboarding-gate.md +7 -0
  5. package/.agent-src/rules/roadmap-progress-sync.md +27 -0
  6. package/.agent-src/rules/rule-type-governance.md +28 -0
  7. package/.agent-src/templates/roadmaps.md +4 -0
  8. package/.claude-plugin/marketplace.json +1 -1
  9. package/CHANGELOG.md +35 -0
  10. package/README.md +1 -1
  11. package/docs/architecture.md +1 -1
  12. package/docs/contracts/load-context-budget-model.md +80 -0
  13. package/docs/contracts/load-context-schema.md +20 -0
  14. package/docs/contracts/roadmap-complexity-standard.md +137 -0
  15. package/docs/guidelines/agent-infra/ask-when-uncertain-demos.md +134 -0
  16. package/docs/guidelines/agent-infra/direct-answers-demos.md +145 -0
  17. package/docs/guidelines/agent-infra/verify-before-complete-demos.md +128 -0
  18. package/package.json +1 -1
  19. package/scripts/agent-config +20 -0
  20. package/scripts/ai_council/one_off_archive/2026-05/README.md +45 -0
  21. package/scripts/ai_council/one_off_archive/2026-05/_one_off_budget_v2_audit.py +206 -0
  22. package/scripts/build_rule_trigger_matrix.py +360 -0
  23. package/scripts/check_always_budget.py +39 -0
  24. package/scripts/check_one_off_location.py +81 -0
  25. package/scripts/check_references.py +6 -0
  26. package/scripts/compress.py +5 -2
  27. package/scripts/context_hygiene_hook.py +173 -0
  28. package/scripts/hooks/augment-context-hygiene.sh +55 -0
  29. package/scripts/hooks/augment-onboarding-gate.sh +55 -0
  30. package/scripts/install.py +58 -19
  31. package/scripts/lint_examples.py +98 -0
  32. package/scripts/lint_roadmap_complexity.py +127 -0
  33. package/scripts/onboarding_gate_hook.py +137 -0
  34. package/scripts/schemas/rule.schema.json +5 -0
  35. /package/scripts/ai_council/{_one_off_2a4_acceptance.py → one_off_archive/2026-05/_one_off_2a4_acceptance.py} +0 -0
  36. /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
  37. /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
  38. /package/scripts/ai_council/{_one_off_followups_review.py → one_off_archive/2026-05/_one_off_followups_review.py} +0 -0
  39. /package/scripts/ai_council/{_one_off_nondestructive_inline_audit.py → one_off_archive/2026-05/_one_off_nondestructive_inline_audit.py} +0 -0
  40. /package/scripts/{_one_off_phase4_dispatch_latency.py → ai_council/one_off_archive/2026-05/_one_off_phase4_dispatch_latency.py} +0 -0
  41. /package/scripts/{_one_off_phase6_trigger_jaccard.py → ai_council/one_off_archive/2026-05/_one_off_phase6_trigger_jaccard.py} +0 -0
  42. /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
  43. /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
  44. /package/scripts/ai_council/{_one_off_rebalancing_audit.py → one_off_archive/2026-05/_one_off_rebalancing_audit.py} +0 -0
  45. /package/scripts/ai_council/{_one_off_roundtrip.py → one_off_archive/2026-05/_one_off_roundtrip.py} +0 -0
  46. /package/scripts/ai_council/{_one_off_rule_hardening_v1.py → one_off_archive/2026-05/_one_off_rule_hardening_v1.py} +0 -0
  47. /package/scripts/ai_council/{_one_off_structural_open_questions.py → one_off_archive/2026-05/_one_off_structural_open_questions.py} +0 -0
  48. /package/scripts/ai_council/{_one_off_structural_optimization.py → one_off_archive/2026-05/_one_off_structural_optimization.py} +0 -0
  49. /package/scripts/ai_council/{_one_off_structural_v3_gaps.py → one_off_archive/2026-05/_one_off_structural_v3_gaps.py} +0 -0
  50. /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),
@@ -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
- known = set(AUGMENT_SYMLINK_DIRS) | set(AUGMENT_SYMLINK_FILES) | {"rules"}
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
@@ -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
- Two trampolines are deployed:
509
- - augment-chat-history.sh → SessionStart/SessionEnd/Stop/PostToolUse
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 _chat_history_hook_block(platform: str) -> dict:
535
- """Single hook entry that calls ./agent-config chat-history:hook --platform <name>."""
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 chat-history:hook --platform {platform}",
551
+ "command": f"./agent-config {subcommand} --platform claude",
541
552
  },
542
553
  ],
543
554
  }
544
555
 
545
556
 
546
- def ensure_claude_bridge(project_root: Path, force: bool) -> None:
547
- """Deploy .claude/settings.json with plugin enablement and chat-history hooks.
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
- Hooks dispatch to scripts/chat_history.py via the project-root ./agent-config
550
- wrapper. They are no-ops when chat_history.enabled is false in
551
- .agent-settings.yml. Idempotent: reruns merge cleanly without duplicating
552
- entries (deep_merge replaces hook arrays rather than appending).
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
- claude_hook = _chat_history_hook_block("claude")
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())