@event4u/agent-config 1.17.0 → 1.19.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 (158) hide show
  1. package/.agent-src/commands/council/default.md +74 -76
  2. package/.agent-src/commands/feature/roadmap.md +22 -0
  3. package/.agent-src/commands/roadmap/create.md +38 -6
  4. package/.agent-src/commands/roadmap/execute.md +36 -9
  5. package/.agent-src/rules/agent-authority.md +1 -0
  6. package/.agent-src/rules/agent-docs.md +1 -0
  7. package/.agent-src/rules/analysis-skill-routing.md +1 -0
  8. package/.agent-src/rules/architecture.md +1 -0
  9. package/.agent-src/rules/artifact-drafting-protocol.md +1 -0
  10. package/.agent-src/rules/artifact-engagement-recording.md +1 -0
  11. package/.agent-src/rules/ask-when-uncertain.md +1 -0
  12. package/.agent-src/rules/augment-portability.md +1 -0
  13. package/.agent-src/rules/augment-source-of-truth.md +1 -0
  14. package/.agent-src/rules/autonomous-execution.md +1 -0
  15. package/.agent-src/rules/capture-learnings.md +1 -0
  16. package/.agent-src/rules/chat-history-cadence.md +34 -0
  17. package/.agent-src/rules/chat-history-ownership.md +1 -0
  18. package/.agent-src/rules/chat-history-visibility.md +1 -0
  19. package/.agent-src/rules/cli-output-handling.md +2 -2
  20. package/.agent-src/rules/command-suggestion-policy.md +1 -0
  21. package/.agent-src/rules/commit-conventions.md +1 -0
  22. package/.agent-src/rules/commit-policy.md +1 -0
  23. package/.agent-src/rules/context-hygiene.md +28 -0
  24. package/.agent-src/rules/direct-answers.md +18 -26
  25. package/.agent-src/rules/docker-commands.md +1 -0
  26. package/.agent-src/rules/docs-sync.md +1 -0
  27. package/.agent-src/rules/downstream-changes.md +1 -0
  28. package/.agent-src/rules/e2e-testing.md +1 -0
  29. package/.agent-src/rules/guidelines.md +1 -0
  30. package/.agent-src/rules/improve-before-implement.md +1 -0
  31. package/.agent-src/rules/language-and-tone.md +1 -0
  32. package/.agent-src/rules/laravel-translations.md +1 -0
  33. package/.agent-src/rules/markdown-safe-codeblocks.md +1 -0
  34. package/.agent-src/rules/minimal-safe-diff.md +1 -0
  35. package/.agent-src/rules/missing-tool-handling.md +1 -0
  36. package/.agent-src/rules/model-recommendation.md +1 -0
  37. package/.agent-src/rules/no-cheap-questions.md +15 -21
  38. package/.agent-src/rules/no-roadmap-references.md +1 -0
  39. package/.agent-src/rules/non-destructive-by-default.md +1 -0
  40. package/.agent-src/rules/onboarding-gate.md +33 -0
  41. package/.agent-src/rules/package-ci-checks.md +1 -0
  42. package/.agent-src/rules/php-coding.md +1 -0
  43. package/.agent-src/rules/preservation-guard.md +1 -0
  44. package/.agent-src/rules/review-routing-awareness.md +1 -0
  45. package/.agent-src/rules/reviewer-awareness.md +1 -0
  46. package/.agent-src/rules/roadmap-progress-sync.md +49 -0
  47. package/.agent-src/rules/role-mode-adherence.md +2 -2
  48. package/.agent-src/rules/rule-type-governance.md +29 -0
  49. package/.agent-src/rules/runtime-safety.md +1 -0
  50. package/.agent-src/rules/scope-control.md +1 -0
  51. package/.agent-src/rules/security-sensitive-stop.md +1 -0
  52. package/.agent-src/rules/size-enforcement.md +1 -0
  53. package/.agent-src/rules/skill-improvement-trigger.md +1 -0
  54. package/.agent-src/rules/skill-quality.md +1 -0
  55. package/.agent-src/rules/slash-command-routing-policy.md +39 -0
  56. package/.agent-src/rules/think-before-action.md +1 -0
  57. package/.agent-src/rules/token-efficiency.md +1 -0
  58. package/.agent-src/rules/tool-safety.md +1 -0
  59. package/.agent-src/rules/ui-audit-gate.md +1 -0
  60. package/.agent-src/rules/upstream-proposal.md +1 -0
  61. package/.agent-src/rules/user-interaction.md +1 -0
  62. package/.agent-src/rules/verify-before-complete.md +1 -0
  63. package/.agent-src/skills/roadmap-management/SKILL.md +29 -4
  64. package/.agent-src/skills/verify-completion-evidence/SKILL.md +8 -1
  65. package/.agent-src/templates/agent-settings.md +16 -0
  66. package/.agent-src/templates/roadmaps.md +12 -3
  67. package/.agent-src/templates/scripts/work_engine/hook_bootstrap.py +9 -0
  68. package/.agent-src/templates/scripts/work_engine/hooks/__init__.py +4 -0
  69. package/.agent-src/templates/scripts/work_engine/hooks/builtin/__init__.py +4 -0
  70. package/.agent-src/templates/scripts/work_engine/hooks/builtin/decision_trace.py +163 -0
  71. package/.agent-src/templates/scripts/work_engine/hooks/builtin/memory_visibility.py +111 -0
  72. package/.agent-src/templates/scripts/work_engine/hooks/settings.py +36 -0
  73. package/.agent-src/templates/scripts/work_engine/scoring/decision_trace.py +141 -0
  74. package/.agent-src/templates/scripts/work_engine/scoring/memory_visibility.py +125 -0
  75. package/.claude-plugin/marketplace.json +1 -1
  76. package/CHANGELOG.md +97 -0
  77. package/README.md +20 -20
  78. package/config/agent-settings.template.yml +23 -0
  79. package/docs/architecture.md +1 -1
  80. package/docs/catalog.md +5 -2
  81. package/docs/contracts/adr-settings-sync-engine.md +127 -0
  82. package/docs/contracts/decision-trace-v1.md +146 -0
  83. package/docs/contracts/file-ownership-matrix.json +7 -0
  84. package/docs/contracts/hook-architecture-v1.md +213 -0
  85. package/docs/contracts/load-context-budget-model.md +80 -0
  86. package/docs/contracts/load-context-schema.md +20 -0
  87. package/docs/contracts/memory-visibility-v1.md +138 -0
  88. package/docs/contracts/one-off-script-lifecycle.md +109 -0
  89. package/docs/contracts/roadmap-complexity-standard.md +137 -0
  90. package/docs/contracts/rule-interactions.yml +22 -0
  91. package/docs/customization.md +1 -0
  92. package/docs/development.md +4 -1
  93. package/docs/guidelines/agent-infra/ask-when-uncertain-demos.md +134 -0
  94. package/docs/guidelines/agent-infra/direct-answers-demos.md +145 -0
  95. package/docs/guidelines/agent-infra/layered-settings.md +32 -13
  96. package/docs/guidelines/agent-infra/verify-before-complete-demos.md +128 -0
  97. package/package.json +1 -1
  98. package/scripts/agent-config +64 -0
  99. package/scripts/ai_council/bundler.py +3 -3
  100. package/scripts/ai_council/clients.py +24 -8
  101. package/scripts/ai_council/one_off_archive/2026-05/README.md +67 -0
  102. package/scripts/ai_council/one_off_archive/2026-05/_one_off_budget_v2_audit.py +206 -0
  103. package/scripts/ai_council/{_one_off_roundtrip.py → one_off_archive/2026-05/_one_off_roundtrip.py} +13 -8
  104. package/scripts/ai_council/one_off_archive/2026-05/_one_off_tier_retrofit.py +180 -0
  105. package/scripts/ai_council/session.py +92 -0
  106. package/scripts/build_rule_trigger_matrix.py +360 -0
  107. package/scripts/capture_showcase_session.py +361 -0
  108. package/scripts/chat_history.py +11 -1
  109. package/scripts/check_always_budget.py +46 -2
  110. package/scripts/check_one_off_location.py +81 -0
  111. package/scripts/check_references.py +6 -0
  112. package/scripts/compress.py +5 -2
  113. package/scripts/context_hygiene_hook.py +181 -0
  114. package/scripts/council_cli.py +357 -0
  115. package/scripts/hook_manifest.yaml +184 -0
  116. package/scripts/hooks/__init__.py +1 -0
  117. package/scripts/hooks/augment-context-hygiene.sh +55 -0
  118. package/scripts/hooks/augment-dispatcher.sh +72 -0
  119. package/scripts/hooks/augment-onboarding-gate.sh +55 -0
  120. package/scripts/hooks/cline-dispatcher.sh +86 -0
  121. package/scripts/hooks/cursor-dispatcher.sh +76 -0
  122. package/scripts/hooks/dispatch_hook.py +348 -0
  123. package/scripts/hooks/envelope.py +98 -0
  124. package/scripts/hooks/gemini-dispatcher.sh +117 -0
  125. package/scripts/hooks/state_io.py +122 -0
  126. package/scripts/hooks/windsurf-dispatcher.sh +123 -0
  127. package/scripts/hooks_status.py +146 -0
  128. package/scripts/install.py +728 -51
  129. package/scripts/install.sh +1 -1
  130. package/scripts/lint_examples.py +98 -0
  131. package/scripts/lint_hook_manifest.py +216 -0
  132. package/scripts/lint_one_off_age.py +184 -0
  133. package/scripts/lint_roadmap_complexity.py +127 -0
  134. package/scripts/lint_rule_tiers.py +78 -0
  135. package/scripts/lint_showcase_sessions.py +148 -0
  136. package/scripts/minimal_safe_diff_hook.py +245 -0
  137. package/scripts/onboarding_gate_hook.py +142 -0
  138. package/scripts/readme_linter.py +12 -3
  139. package/scripts/roadmap_progress_hook.py +5 -0
  140. package/scripts/schemas/rule.schema.json +5 -0
  141. package/scripts/sync_agent_settings.py +32 -129
  142. package/scripts/sync_yaml_rt.py +734 -0
  143. package/scripts/verify_before_complete_hook.py +216 -0
  144. /package/scripts/ai_council/{_one_off_2a4_acceptance.py → one_off_archive/2026-05/_one_off_2a4_acceptance.py} +0 -0
  145. /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
  146. /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
  147. /package/scripts/ai_council/{_one_off_followups_review.py → one_off_archive/2026-05/_one_off_followups_review.py} +0 -0
  148. /package/scripts/ai_council/{_one_off_nondestructive_inline_audit.py → one_off_archive/2026-05/_one_off_nondestructive_inline_audit.py} +0 -0
  149. /package/scripts/{_one_off_phase4_dispatch_latency.py → ai_council/one_off_archive/2026-05/_one_off_phase4_dispatch_latency.py} +0 -0
  150. /package/scripts/{_one_off_phase6_trigger_jaccard.py → ai_council/one_off_archive/2026-05/_one_off_phase6_trigger_jaccard.py} +0 -0
  151. /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
  152. /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
  153. /package/scripts/ai_council/{_one_off_rebalancing_audit.py → one_off_archive/2026-05/_one_off_rebalancing_audit.py} +0 -0
  154. /package/scripts/ai_council/{_one_off_rule_hardening_v1.py → one_off_archive/2026-05/_one_off_rule_hardening_v1.py} +0 -0
  155. /package/scripts/ai_council/{_one_off_structural_open_questions.py → one_off_archive/2026-05/_one_off_structural_open_questions.py} +0 -0
  156. /package/scripts/ai_council/{_one_off_structural_optimization.py → one_off_archive/2026-05/_one_off_structural_optimization.py} +0 -0
  157. /package/scripts/ai_council/{_one_off_structural_v3_gaps.py → one_off_archive/2026-05/_one_off_structural_v3_gaps.py} +0 -0
  158. /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,78 @@
1
+ #!/usr/bin/env python3
2
+ """Lint rule frontmatter for the `tier:` key.
3
+
4
+ Hard-fails CI if any rule under .agent-src.uncompressed/rules/ lacks a
5
+ `tier:` declaration or uses an unknown tier value. The valid tier set is
6
+ locked by agents/contexts/hardening-pattern.md and the matrix in
7
+ agents/contexts/rule-trigger-matrix.md.
8
+
9
+ Hooked into `task ci` after `task lint-skills`.
10
+
11
+ Exit codes:
12
+ 0 every rule declares a valid tier
13
+ 1 one or more rules missing or using an invalid tier
14
+ """
15
+ from __future__ import annotations
16
+
17
+ import sys
18
+ from pathlib import Path
19
+
20
+ REPO = Path(__file__).resolve().parents[1]
21
+ RULES_DIR = REPO / ".agent-src.uncompressed" / "rules"
22
+
23
+ VALID_TIERS = frozenset({"1", "2a", "2b", "3", "safety-floor", "mechanical-already"})
24
+
25
+
26
+ def parse_tier(text: str) -> str | None:
27
+ if not text.startswith("---\n"):
28
+ return None
29
+ end = text.find("\n---\n", 4)
30
+ if end == -1:
31
+ return None
32
+ for line in text[4:end].splitlines():
33
+ if ":" not in line:
34
+ continue
35
+ k, _, v = line.partition(":")
36
+ if k.strip() == "tier":
37
+ return v.strip().strip('"').strip("'")
38
+ return None
39
+
40
+
41
+ def main() -> int:
42
+ rules = sorted(RULES_DIR.glob("*.md"))
43
+ if not rules:
44
+ print(f"lint_rule_tiers: no rules found under {RULES_DIR}", file=sys.stderr)
45
+ return 1
46
+
47
+ missing: list[str] = []
48
+ invalid: list[tuple[str, str]] = []
49
+
50
+ for rule in rules:
51
+ tier = parse_tier(rule.read_text(encoding="utf-8"))
52
+ if tier is None:
53
+ missing.append(rule.name)
54
+ elif tier not in VALID_TIERS:
55
+ invalid.append((rule.name, tier))
56
+
57
+ if missing or invalid:
58
+ print(
59
+ f"❌ lint_rule_tiers: {len(missing)} missing, "
60
+ f"{len(invalid)} invalid (of {len(rules)} rules)",
61
+ file=sys.stderr,
62
+ )
63
+ for name in missing:
64
+ print(f" missing tier: {name}", file=sys.stderr)
65
+ for name, tier in invalid:
66
+ print(f" invalid tier '{tier}': {name}", file=sys.stderr)
67
+ print(
68
+ f" valid tiers: {sorted(VALID_TIERS)}",
69
+ file=sys.stderr,
70
+ )
71
+ return 1
72
+
73
+ print(f"✅ lint_rule_tiers: {len(rules)} rules, all tier values valid")
74
+ return 0
75
+
76
+
77
+ if __name__ == "__main__":
78
+ raise SystemExit(main())
@@ -0,0 +1,148 @@
1
+ #!/usr/bin/env python3
2
+ """lint_showcase_sessions.py — gate `docs/showcase.md` ↔ `docs/showcase/sessions/`.
3
+
4
+ Phase 1.6 deliverable for `road-to-feedback-consolidation.md`.
5
+
6
+ Rules:
7
+ 1. Every `docs/showcase/sessions/<slug>.log` reference inside
8
+ `docs/showcase.md` must point to an existing file.
9
+ 2. Every referenced session file must carry a YAML frontmatter block
10
+ containing at minimum `commit_sha` and a `metrics:` mapping (the
11
+ four outcome-baseline metrics; values may be null but the keys
12
+ must be present per `scripts/capture_showcase_session.py`).
13
+ 3. Every file under `docs/showcase/sessions/` must be referenced
14
+ from `docs/showcase.md` — orphans are not allowed.
15
+
16
+ When `docs/showcase/sessions/` is empty AND `docs/showcase.md` carries
17
+ no session references, the linter exits 0 (clean — Phase 1.3-1.5 are
18
+ deferred to manual host-agent runs).
19
+
20
+ Exit codes:
21
+ 0 clean
22
+ 1 one or more violations
23
+ """
24
+ from __future__ import annotations
25
+
26
+ import re
27
+ import sys
28
+ from pathlib import Path
29
+
30
+ ROOT = Path(__file__).resolve().parent.parent
31
+ SHOWCASE_MD = ROOT / "docs" / "showcase.md"
32
+ SESSIONS_DIR = ROOT / "docs" / "showcase" / "sessions"
33
+
34
+ REQUIRED_METRICS = {
35
+ "tool_call_count",
36
+ "reply_chars_mean",
37
+ "memory_hit_ratio",
38
+ "verify_pass_rate",
39
+ }
40
+
41
+ REF_RE = re.compile(r"docs/showcase/sessions/([A-Za-z0-9_\-]+)\.log")
42
+
43
+
44
+ def _parse_frontmatter(text: str) -> dict[str, str] | None:
45
+ if not text.startswith("---\n"):
46
+ return None
47
+ end = text.find("\n---\n", 4)
48
+ if end == -1:
49
+ return None
50
+ block: dict[str, str] = {}
51
+ current_key: str | None = None
52
+ nested: dict[str, str] = {}
53
+ for raw in text[4:end].splitlines():
54
+ if not raw.strip():
55
+ continue
56
+ if raw.startswith(" ") and current_key:
57
+ k, _, v = raw.strip().partition(":")
58
+ nested[k.strip()] = v.strip()
59
+ continue
60
+ if ":" not in raw:
61
+ continue
62
+ k, _, v = raw.partition(":")
63
+ v = v.strip()
64
+ if not v:
65
+ current_key = k.strip()
66
+ nested = {}
67
+ block[current_key] = ""
68
+ block[f"_{current_key}_nested"] = nested # type: ignore[assignment]
69
+ else:
70
+ block[k.strip()] = v
71
+ current_key = None
72
+ return block
73
+
74
+
75
+ def _validate_session(slug: str) -> list[str]:
76
+ errors: list[str] = []
77
+ path = SESSIONS_DIR / f"{slug}.log"
78
+ if not path.is_file():
79
+ errors.append(f"referenced session missing on disk: {path.relative_to(ROOT)}")
80
+ return errors
81
+ text = path.read_text(encoding="utf-8")
82
+ fm = _parse_frontmatter(text)
83
+ if fm is None:
84
+ errors.append(f"{slug}.log: no YAML frontmatter block")
85
+ return errors
86
+ if not fm.get("commit_sha"):
87
+ errors.append(f"{slug}.log: missing or empty `commit_sha`")
88
+ metrics = fm.get("_metrics_nested")
89
+ if not isinstance(metrics, dict):
90
+ errors.append(f"{slug}.log: missing `metrics:` mapping")
91
+ else:
92
+ missing = REQUIRED_METRICS - set(metrics.keys())
93
+ if missing:
94
+ errors.append(
95
+ f"{slug}.log: metrics block missing keys: {sorted(missing)}"
96
+ )
97
+ return errors
98
+
99
+
100
+ def main() -> int:
101
+ if not SHOWCASE_MD.is_file():
102
+ print(f"❌ {SHOWCASE_MD.relative_to(ROOT)} not found", file=sys.stderr)
103
+ return 1
104
+
105
+ text = SHOWCASE_MD.read_text(encoding="utf-8")
106
+ referenced = set(REF_RE.findall(text))
107
+
108
+ on_disk: set[str] = set()
109
+ if SESSIONS_DIR.is_dir():
110
+ on_disk = {p.stem for p in SESSIONS_DIR.glob("*.log")}
111
+
112
+ errors: list[str] = []
113
+
114
+ for slug in sorted(referenced):
115
+ errors.extend(_validate_session(slug))
116
+
117
+ orphans = on_disk - referenced
118
+ for slug in sorted(orphans):
119
+ errors.append(
120
+ f"orphan session: docs/showcase/sessions/{slug}.log "
121
+ f"is not referenced from docs/showcase.md"
122
+ )
123
+
124
+ if errors:
125
+ print(
126
+ f"❌ lint_showcase_sessions: {len(errors)} violation(s) "
127
+ f"({len(referenced)} referenced, {len(on_disk)} on disk)",
128
+ file=sys.stderr,
129
+ )
130
+ for err in errors:
131
+ print(f" {err}", file=sys.stderr)
132
+ return 1
133
+
134
+ if not referenced and not on_disk:
135
+ print(
136
+ "✅ lint_showcase_sessions: 0 sessions referenced, 0 on disk "
137
+ "(Phase 1.3-1.5 deferred to manual host-agent runs)"
138
+ )
139
+ else:
140
+ print(
141
+ f"✅ lint_showcase_sessions: {len(referenced)} session(s) "
142
+ f"valid ({len(on_disk)} on disk)"
143
+ )
144
+ return 0
145
+
146
+
147
+ if __name__ == "__main__":
148
+ raise SystemExit(main())
@@ -0,0 +1,245 @@
1
+ #!/usr/bin/env python3
2
+ """Platform-agnostic hook for the `minimal-safe-diff` rule.
3
+
4
+ Pre-edit gate: counts unique files touched in the current turn (or
5
+ session, when the platform lacks a turn-boundary signal) and warns
6
+ when the count exceeds the configured threshold. The hook never
7
+ blocks — it is observability infra. The rule body cites the resulting
8
+ state file when the agent prepares a diff for review.
9
+
10
+ Wired to multiple events via the manifest:
11
+ - session_start / user_prompt_submit → reset turn-scoped counters
12
+ - pre_tool_use → record the planned edit's path before execution
13
+
14
+ Output: `agents/state/minimal-safe-diff.json`
15
+ {
16
+ "schema_version": 1,
17
+ "session_id": "<str>",
18
+ "turn_started_at": "<iso8601|null>",
19
+ "files_touched_this_turn": ["a", "b", ...],
20
+ "count": <int>,
21
+ "threshold": <int>,
22
+ "warning": <bool>,
23
+ "checked_at": "<iso8601>"
24
+ }
25
+
26
+ Exit code is always 0.
27
+ """
28
+ from __future__ import annotations
29
+
30
+ import argparse
31
+ import datetime as _dt
32
+ import json
33
+ import re
34
+ import sys
35
+ from pathlib import Path
36
+
37
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
38
+ from hooks.state_io import atomic_write_json # noqa: E402
39
+
40
+ STATE_FILE = Path("agents") / "state" / "minimal-safe-diff.json"
41
+ SETTINGS_FILE = ".agent-settings.yml"
42
+ DEFAULT_THRESHOLD = 5
43
+ MAX_TRACKED_PATHS = 200 # hard cap to keep the state file bounded
44
+
45
+ # Edit-tool names across platforms whose successful invocation results
46
+ # in a file being modified, created, or deleted. Keep explicit so an
47
+ # unknown tool doesn't trigger a false positive.
48
+ EDIT_TOOLS = frozenset({
49
+ "str-replace-editor", "str_replace_editor", # Augment
50
+ "save-file", "save_file", # Augment
51
+ "remove-files", "remove_files", # Augment
52
+ "Edit", "Write", "MultiEdit", # Claude Code
53
+ "edit_file", "edit-file", # Cursor
54
+ "create_file", "create-file", "delete_file", # variants
55
+ })
56
+
57
+
58
+ def _now() -> str:
59
+ return _dt.datetime.now(_dt.timezone.utc).isoformat(timespec="seconds")
60
+
61
+
62
+ def _empty_state(threshold: int) -> dict:
63
+ return {
64
+ "schema_version": 1,
65
+ "session_id": "",
66
+ "turn_started_at": None,
67
+ "files_touched_this_turn": [],
68
+ "count": 0,
69
+ "threshold": threshold,
70
+ "warning": False,
71
+ "checked_at": _now(),
72
+ }
73
+
74
+
75
+ def _read_threshold(consumer_root: Path) -> int:
76
+ """Parse `hooks.minimal_safe_diff.threshold` from .agent-settings.yml.
77
+
78
+ Dependency-free YAML scan — we only need a single integer under a
79
+ nested block; pulling pyyaml in for this would be overkill.
80
+ """
81
+ settings = consumer_root / SETTINGS_FILE
82
+ if not settings.is_file():
83
+ return DEFAULT_THRESHOLD
84
+ try:
85
+ text = settings.read_text(encoding="utf-8")
86
+ except OSError:
87
+ return DEFAULT_THRESHOLD
88
+
89
+ in_hooks = False
90
+ in_msd = False
91
+ for raw in text.splitlines():
92
+ line = raw.rstrip()
93
+ if not line or line.lstrip().startswith("#"):
94
+ continue
95
+ # top-level key resets nested context
96
+ if line and not line.startswith((" ", "\t")):
97
+ in_hooks = re.match(r"^hooks\s*:\s*$", line) is not None
98
+ in_msd = False
99
+ continue
100
+ if in_hooks:
101
+ m = re.match(r"^\s+minimal_safe_diff\s*:\s*$", line)
102
+ if m:
103
+ in_msd = True
104
+ continue
105
+ # leaving the minimal_safe_diff block when indent decreases
106
+ if in_msd and re.match(r"^\s{0,3}\S", line):
107
+ in_msd = False
108
+ if in_msd:
109
+ m = re.match(r"^\s+threshold\s*:\s*(\d+)\s*(?:#.*)?$", line)
110
+ if m:
111
+ try:
112
+ val = int(m.group(1))
113
+ return val if val > 0 else DEFAULT_THRESHOLD
114
+ except ValueError:
115
+ return DEFAULT_THRESHOLD
116
+ return DEFAULT_THRESHOLD
117
+
118
+
119
+ def _load_state(target: Path, threshold: int) -> dict:
120
+ if not target.is_file():
121
+ return _empty_state(threshold)
122
+ try:
123
+ decoded = json.loads(target.read_text(encoding="utf-8"))
124
+ if isinstance(decoded, dict):
125
+ base = _empty_state(threshold)
126
+ base.update(decoded)
127
+ base["threshold"] = threshold # always reflect current setting
128
+ return base
129
+ except (OSError, json.JSONDecodeError):
130
+ pass
131
+ return _empty_state(threshold)
132
+
133
+
134
+ def _candidate_paths(payload: dict) -> list[str]:
135
+ out: list[str] = []
136
+ fc = payload.get("file_changes")
137
+ if isinstance(fc, list):
138
+ for entry in fc:
139
+ if isinstance(entry, dict):
140
+ p = entry.get("path")
141
+ if isinstance(p, str) and p:
142
+ out.append(p)
143
+ ti = payload.get("tool_input")
144
+ if isinstance(ti, dict):
145
+ for key in ("path", "file_path", "target_file", "filename"):
146
+ v = ti.get(key)
147
+ if isinstance(v, str) and v:
148
+ out.append(v)
149
+ return out
150
+
151
+
152
+
153
+ def _normalize(path: str) -> str:
154
+ return path.lstrip("./").replace("\\", "/")
155
+
156
+
157
+ def _reset_turn(state: dict, session_id: str) -> dict:
158
+ state["session_id"] = session_id or state.get("session_id") or ""
159
+ state["turn_started_at"] = _now()
160
+ state["files_touched_this_turn"] = []
161
+ state["count"] = 0
162
+ state["warning"] = False
163
+ return state
164
+
165
+
166
+ def _update(state: dict, event: str, envelope: dict, threshold: int) -> dict:
167
+ session_id = envelope.get("session_id") or state.get("session_id") or ""
168
+ if session_id and session_id != state.get("session_id"):
169
+ state = _reset_turn(state, session_id)
170
+
171
+ payload = envelope.get("payload") or {}
172
+ if not isinstance(payload, dict):
173
+ payload = {}
174
+
175
+ if event in ("session_start", "user_prompt_submit"):
176
+ state = _reset_turn(state, session_id)
177
+ elif event in ("pre_tool_use", "post_tool_use"):
178
+ tool = (payload.get("tool_name") or payload.get("toolName")
179
+ or payload.get("tool"))
180
+ if isinstance(tool, str) and tool in EDIT_TOOLS:
181
+ touched: list[str] = list(state.get("files_touched_this_turn") or [])
182
+ seen = set(touched)
183
+ for raw in _candidate_paths(payload):
184
+ norm = _normalize(raw)
185
+ if norm and norm not in seen:
186
+ seen.add(norm)
187
+ touched.append(norm)
188
+ if len(touched) > MAX_TRACKED_PATHS:
189
+ touched = touched[-MAX_TRACKED_PATHS:]
190
+ state["files_touched_this_turn"] = touched
191
+ state["count"] = len(touched)
192
+ state["warning"] = state["count"] > threshold
193
+
194
+ state["threshold"] = threshold
195
+ state["checked_at"] = _now()
196
+ return state
197
+
198
+
199
+ def run(stdin_text: str, *, consumer_root: Path, verbose: bool = False) -> int:
200
+ envelope: dict = {}
201
+ if stdin_text.strip():
202
+ try:
203
+ decoded = json.loads(stdin_text)
204
+ if isinstance(decoded, dict):
205
+ envelope = decoded
206
+ except json.JSONDecodeError:
207
+ envelope = {}
208
+
209
+ event = envelope.get("event") or ""
210
+ threshold = _read_threshold(consumer_root)
211
+ target = consumer_root / STATE_FILE
212
+ state = _load_state(target, threshold)
213
+ state = _update(state, event, envelope, threshold)
214
+
215
+ try:
216
+ atomic_write_json(target, state)
217
+ except OSError:
218
+ if verbose:
219
+ print("minimal-safe-diff-hook: state write failed",
220
+ file=sys.stderr)
221
+ return 0
222
+
223
+ if verbose:
224
+ print(
225
+ f"minimal-safe-diff-hook: event={event} "
226
+ f"count={state.get('count')} threshold={threshold} "
227
+ f"warning={state.get('warning')}",
228
+ file=sys.stderr,
229
+ )
230
+ return 0
231
+
232
+
233
+ def main(argv: list[str] | None = None) -> int:
234
+ parser = argparse.ArgumentParser(description=__doc__)
235
+ parser.add_argument("--platform", default="generic",
236
+ help="informational platform tag")
237
+ parser.add_argument("--verbose", action="store_true",
238
+ help="emit one stderr line per invocation")
239
+ args = parser.parse_args(argv)
240
+ return run(sys.stdin.read(), consumer_root=Path.cwd(),
241
+ verbose=args.verbose)
242
+
243
+
244
+ if __name__ == "__main__": # pragma: no cover
245
+ sys.exit(main())
@@ -0,0 +1,142 @@
1
+ #!/usr/bin/env python3
2
+ """Platform-agnostic hook for the `onboarding-gate` rule.
3
+
4
+ Reads `.agent-settings.yml` from the consumer repo and writes a
5
+ deterministic state file the rule body can cite as the source of
6
+ truth for "do I need to prompt the user about /onboard?".
7
+
8
+ Output is written to `agents/state/onboarding-gate.json` with:
9
+ {
10
+ "required": <bool>, // true → rule fires on first turn
11
+ "reason": "<string>", // why this state was set
12
+ "checked_at": "<iso8601>", // last hook run
13
+ "settings_present": <bool> // .agent-settings.yml exists
14
+ }
15
+
16
+ Exit code is **always 0**. Hooks must never block the agent loop.
17
+
18
+ Output discipline:
19
+ - stdout: nothing (Augment surfaces stdout to the user)
20
+ - stderr: one short line in --verbose mode, otherwise silent
21
+
22
+ CLI:
23
+ python3 scripts/onboarding_gate_hook.py [--platform NAME] [--verbose]
24
+ """
25
+ from __future__ import annotations
26
+
27
+ import argparse
28
+ import datetime as _dt
29
+ import re
30
+ import sys
31
+ from pathlib import Path
32
+
33
+ # Re-use the shared atomic-write helper so concerns honour the single
34
+ # `agents/state/.dispatcher.lock` discipline (hook-architecture-v1.md
35
+ # § Concurrency, Phase 7.4).
36
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
37
+ from hooks.state_io import atomic_write_json # noqa: E402
38
+
39
+ SETTINGS_FILE = ".agent-settings.yml"
40
+ STATE_DIR = Path("agents") / "state"
41
+ STATE_FILE = STATE_DIR / "onboarding-gate.json"
42
+
43
+
44
+ def _read_onboarded(settings_path: Path) -> tuple[bool, str]:
45
+ """Return (required, reason) — minimal, dependency-free YAML parsing.
46
+
47
+ We only need a single key under the `onboarding:` block. Full YAML is
48
+ overkill (and would pull in a runtime dep). We scan line-by-line for
49
+ `onboarded: <bool>` inside the `onboarding:` section.
50
+ """
51
+ if not settings_path.is_file():
52
+ return (False, "settings_file_missing") # legacy: do not block
53
+
54
+ try:
55
+ text = settings_path.read_text(encoding="utf-8")
56
+ except OSError:
57
+ return (False, "settings_file_unreadable")
58
+
59
+ in_onboarding = False
60
+ onboarded_value: str | None = None
61
+ for raw in text.splitlines():
62
+ line = raw.rstrip()
63
+ if not line or line.lstrip().startswith("#"):
64
+ continue
65
+ if re.match(r"^onboarding\s*:\s*$", line):
66
+ in_onboarding = True
67
+ continue
68
+ if in_onboarding:
69
+ # Section ends when a top-level (non-indented) key starts.
70
+ if line and not line.startswith((" ", "\t")):
71
+ break
72
+ m = re.match(r"^\s+onboarded\s*:\s*(\S+)\s*(?:#.*)?$", line)
73
+ if m:
74
+ onboarded_value = m.group(1).strip().lower()
75
+
76
+ if onboarded_value is None:
77
+ return (False, "key_missing") # legacy / pre-rule project
78
+ if onboarded_value in ("true", "yes", "on"):
79
+ return (False, "already_onboarded")
80
+ if onboarded_value in ("false", "no", "off"):
81
+ return (True, "explicit_false")
82
+ return (False, f"unknown_value:{onboarded_value}")
83
+
84
+
85
+ def _write_state(consumer_root: Path, required: bool, reason: str,
86
+ settings_present: bool) -> None:
87
+ """Write `agents/state/onboarding-gate.json` atomically.
88
+
89
+ Uses the shared `agents/state/.dispatcher.lock` so concurrent
90
+ dispatcher invocations across platforms cannot tear the file
91
+ (hook-architecture-v1.md § Concurrency, Phase 7.4).
92
+ """
93
+ payload = {
94
+ "required": required,
95
+ "reason": reason,
96
+ "checked_at": _dt.datetime.now(_dt.timezone.utc).isoformat(
97
+ timespec="seconds"),
98
+ "settings_present": settings_present,
99
+ }
100
+ atomic_write_json(consumer_root / STATE_FILE, payload)
101
+
102
+
103
+ def run(*, consumer_root: Path, verbose: bool = False) -> int:
104
+ settings_path = consumer_root / SETTINGS_FILE
105
+ settings_present = settings_path.is_file()
106
+ try:
107
+ required, reason = _read_onboarded(settings_path)
108
+ except Exception: # pragma: no cover — defensive
109
+ required, reason = (False, "hook_error")
110
+
111
+ try:
112
+ _write_state(consumer_root, required, reason, settings_present)
113
+ except OSError:
114
+ if verbose:
115
+ print("onboarding-gate-hook: state write failed",
116
+ file=sys.stderr)
117
+ return 0 # never block
118
+
119
+ if verbose:
120
+ print(f"onboarding-gate-hook: required={required} reason={reason}",
121
+ file=sys.stderr)
122
+ return 0
123
+
124
+
125
+ def main(argv: list[str] | None = None) -> int:
126
+ parser = argparse.ArgumentParser(description=__doc__)
127
+ parser.add_argument("--platform", default="generic",
128
+ help="informational platform tag")
129
+ parser.add_argument("--verbose", action="store_true",
130
+ help="emit one stderr line per invocation")
131
+ args = parser.parse_args(argv)
132
+ # Drain stdin so callers piping JSON don't block on a SIGPIPE on
133
+ # platforms that strictly require stdin to be consumed.
134
+ try:
135
+ sys.stdin.read()
136
+ except Exception:
137
+ pass
138
+ return run(consumer_root=Path.cwd(), verbose=args.verbose)
139
+
140
+
141
+ if __name__ == "__main__": # pragma: no cover
142
+ sys.exit(main())
@@ -193,15 +193,24 @@ def _detect_repo_type(root: Path, ctx: RepoContext) -> RepoType:
193
193
 
194
194
 
195
195
  def _extract_taskfile_tasks(root: Path) -> list[str]:
196
+ tasks: list[str] = []
197
+ pattern = r"^\s{2}([\w:-]+):"
196
198
  for name in ("Taskfile.yml", "Taskfile.yaml"):
197
199
  path = root / name
198
200
  if path.exists():
199
201
  try:
200
- text = path.read_text()
201
- return re.findall(r"^\s{2}([\w_-]+):", text, re.MULTILINE)
202
+ tasks.extend(re.findall(pattern, path.read_text(), re.MULTILINE))
202
203
  except OSError:
203
204
  pass
204
- return []
205
+ break
206
+ taskfiles_dir = root / "taskfiles"
207
+ if taskfiles_dir.is_dir():
208
+ for path in sorted(taskfiles_dir.glob("*.yml")):
209
+ try:
210
+ tasks.extend(re.findall(pattern, path.read_text(), re.MULTILINE))
211
+ except OSError:
212
+ pass
213
+ return tasks
205
214
 
206
215
 
207
216
  def _extract_npm_scripts(root: Path) -> list[str]:
@@ -114,6 +114,11 @@ def run(stdin_text: str, *, consumer_root: Path, verbose: bool = False) -> int:
114
114
  except json.JSONDecodeError:
115
115
  return 0 # malformed stdin → silent no-op, never block
116
116
 
117
+ # Unwrap dispatcher envelope (Phase 7.3, hook-architecture-v1.md).
118
+ if all(k in payload for k in ("schema_version", "platform", "event", "payload")):
119
+ inner = payload.get("payload")
120
+ payload = inner if isinstance(inner, dict) else {}
121
+
117
122
  tool = payload.get("tool_name") or payload.get("toolName") or payload.get("tool")
118
123
  if not isinstance(tool, str) or tool not in WRITE_TOOLS:
119
124
  return 0
@@ -33,6 +33,11 @@
33
33
  "type": "array",
34
34
  "items": {"type": "string", "pattern": "\\.md$"},
35
35
  "description": "Eager auto-loaded context references. Counts against the per-rule char budget; enforced by scripts/lint_load_context.py."
36
+ },
37
+ "tier": {
38
+ "type": "string",
39
+ "enum": ["1", "2a", "2b", "3", "safety-floor", "mechanical-already"],
40
+ "description": "Hardening tier per road-to-rule-hardening.md. Optional today, recommended for new rules. Tracked in agents/contexts/rule-trigger-matrix.md. Tier 3 rules also referenced in agents/contexts/tier-3-dispositions.md."
36
41
  }
37
42
  }
38
43
  }