@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.
- package/.agent-src/commands/council/default.md +74 -76
- package/.agent-src/commands/feature/roadmap.md +22 -0
- package/.agent-src/commands/roadmap/create.md +38 -6
- package/.agent-src/commands/roadmap/execute.md +36 -9
- package/.agent-src/rules/agent-authority.md +1 -0
- package/.agent-src/rules/agent-docs.md +1 -0
- package/.agent-src/rules/analysis-skill-routing.md +1 -0
- package/.agent-src/rules/architecture.md +1 -0
- package/.agent-src/rules/artifact-drafting-protocol.md +1 -0
- package/.agent-src/rules/artifact-engagement-recording.md +1 -0
- package/.agent-src/rules/ask-when-uncertain.md +1 -0
- package/.agent-src/rules/augment-portability.md +1 -0
- package/.agent-src/rules/augment-source-of-truth.md +1 -0
- package/.agent-src/rules/autonomous-execution.md +1 -0
- package/.agent-src/rules/capture-learnings.md +1 -0
- package/.agent-src/rules/chat-history-cadence.md +34 -0
- package/.agent-src/rules/chat-history-ownership.md +1 -0
- package/.agent-src/rules/chat-history-visibility.md +1 -0
- package/.agent-src/rules/cli-output-handling.md +2 -2
- package/.agent-src/rules/command-suggestion-policy.md +1 -0
- package/.agent-src/rules/commit-conventions.md +1 -0
- package/.agent-src/rules/commit-policy.md +1 -0
- package/.agent-src/rules/context-hygiene.md +28 -0
- package/.agent-src/rules/direct-answers.md +18 -26
- package/.agent-src/rules/docker-commands.md +1 -0
- package/.agent-src/rules/docs-sync.md +1 -0
- package/.agent-src/rules/downstream-changes.md +1 -0
- package/.agent-src/rules/e2e-testing.md +1 -0
- package/.agent-src/rules/guidelines.md +1 -0
- package/.agent-src/rules/improve-before-implement.md +1 -0
- package/.agent-src/rules/language-and-tone.md +1 -0
- package/.agent-src/rules/laravel-translations.md +1 -0
- package/.agent-src/rules/markdown-safe-codeblocks.md +1 -0
- package/.agent-src/rules/minimal-safe-diff.md +1 -0
- package/.agent-src/rules/missing-tool-handling.md +1 -0
- package/.agent-src/rules/model-recommendation.md +1 -0
- package/.agent-src/rules/no-cheap-questions.md +15 -21
- package/.agent-src/rules/no-roadmap-references.md +1 -0
- package/.agent-src/rules/non-destructive-by-default.md +1 -0
- package/.agent-src/rules/onboarding-gate.md +33 -0
- package/.agent-src/rules/package-ci-checks.md +1 -0
- package/.agent-src/rules/php-coding.md +1 -0
- package/.agent-src/rules/preservation-guard.md +1 -0
- package/.agent-src/rules/review-routing-awareness.md +1 -0
- package/.agent-src/rules/reviewer-awareness.md +1 -0
- package/.agent-src/rules/roadmap-progress-sync.md +49 -0
- package/.agent-src/rules/role-mode-adherence.md +2 -2
- package/.agent-src/rules/rule-type-governance.md +29 -0
- package/.agent-src/rules/runtime-safety.md +1 -0
- package/.agent-src/rules/scope-control.md +1 -0
- package/.agent-src/rules/security-sensitive-stop.md +1 -0
- package/.agent-src/rules/size-enforcement.md +1 -0
- package/.agent-src/rules/skill-improvement-trigger.md +1 -0
- package/.agent-src/rules/skill-quality.md +1 -0
- package/.agent-src/rules/slash-command-routing-policy.md +39 -0
- package/.agent-src/rules/think-before-action.md +1 -0
- package/.agent-src/rules/token-efficiency.md +1 -0
- package/.agent-src/rules/tool-safety.md +1 -0
- package/.agent-src/rules/ui-audit-gate.md +1 -0
- package/.agent-src/rules/upstream-proposal.md +1 -0
- package/.agent-src/rules/user-interaction.md +1 -0
- package/.agent-src/rules/verify-before-complete.md +1 -0
- package/.agent-src/skills/roadmap-management/SKILL.md +29 -4
- package/.agent-src/skills/verify-completion-evidence/SKILL.md +8 -1
- package/.agent-src/templates/agent-settings.md +16 -0
- package/.agent-src/templates/roadmaps.md +12 -3
- package/.agent-src/templates/scripts/work_engine/hook_bootstrap.py +9 -0
- package/.agent-src/templates/scripts/work_engine/hooks/__init__.py +4 -0
- package/.agent-src/templates/scripts/work_engine/hooks/builtin/__init__.py +4 -0
- package/.agent-src/templates/scripts/work_engine/hooks/builtin/decision_trace.py +163 -0
- package/.agent-src/templates/scripts/work_engine/hooks/builtin/memory_visibility.py +111 -0
- package/.agent-src/templates/scripts/work_engine/hooks/settings.py +36 -0
- package/.agent-src/templates/scripts/work_engine/scoring/decision_trace.py +141 -0
- package/.agent-src/templates/scripts/work_engine/scoring/memory_visibility.py +125 -0
- package/.claude-plugin/marketplace.json +1 -1
- package/CHANGELOG.md +97 -0
- package/README.md +20 -20
- package/config/agent-settings.template.yml +23 -0
- package/docs/architecture.md +1 -1
- package/docs/catalog.md +5 -2
- package/docs/contracts/adr-settings-sync-engine.md +127 -0
- package/docs/contracts/decision-trace-v1.md +146 -0
- package/docs/contracts/file-ownership-matrix.json +7 -0
- package/docs/contracts/hook-architecture-v1.md +213 -0
- package/docs/contracts/load-context-budget-model.md +80 -0
- package/docs/contracts/load-context-schema.md +20 -0
- package/docs/contracts/memory-visibility-v1.md +138 -0
- package/docs/contracts/one-off-script-lifecycle.md +109 -0
- package/docs/contracts/roadmap-complexity-standard.md +137 -0
- package/docs/contracts/rule-interactions.yml +22 -0
- package/docs/customization.md +1 -0
- package/docs/development.md +4 -1
- 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/layered-settings.md +32 -13
- package/docs/guidelines/agent-infra/verify-before-complete-demos.md +128 -0
- package/package.json +1 -1
- package/scripts/agent-config +64 -0
- package/scripts/ai_council/bundler.py +3 -3
- package/scripts/ai_council/clients.py +24 -8
- package/scripts/ai_council/one_off_archive/2026-05/README.md +67 -0
- package/scripts/ai_council/one_off_archive/2026-05/_one_off_budget_v2_audit.py +206 -0
- package/scripts/ai_council/{_one_off_roundtrip.py → one_off_archive/2026-05/_one_off_roundtrip.py} +13 -8
- package/scripts/ai_council/one_off_archive/2026-05/_one_off_tier_retrofit.py +180 -0
- package/scripts/ai_council/session.py +92 -0
- package/scripts/build_rule_trigger_matrix.py +360 -0
- package/scripts/capture_showcase_session.py +361 -0
- package/scripts/chat_history.py +11 -1
- package/scripts/check_always_budget.py +46 -2
- 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 +181 -0
- package/scripts/council_cli.py +357 -0
- package/scripts/hook_manifest.yaml +184 -0
- package/scripts/hooks/__init__.py +1 -0
- package/scripts/hooks/augment-context-hygiene.sh +55 -0
- package/scripts/hooks/augment-dispatcher.sh +72 -0
- package/scripts/hooks/augment-onboarding-gate.sh +55 -0
- package/scripts/hooks/cline-dispatcher.sh +86 -0
- package/scripts/hooks/cursor-dispatcher.sh +76 -0
- package/scripts/hooks/dispatch_hook.py +348 -0
- package/scripts/hooks/envelope.py +98 -0
- package/scripts/hooks/gemini-dispatcher.sh +117 -0
- package/scripts/hooks/state_io.py +122 -0
- package/scripts/hooks/windsurf-dispatcher.sh +123 -0
- package/scripts/hooks_status.py +146 -0
- package/scripts/install.py +728 -51
- package/scripts/install.sh +1 -1
- package/scripts/lint_examples.py +98 -0
- package/scripts/lint_hook_manifest.py +216 -0
- package/scripts/lint_one_off_age.py +184 -0
- package/scripts/lint_roadmap_complexity.py +127 -0
- package/scripts/lint_rule_tiers.py +78 -0
- package/scripts/lint_showcase_sessions.py +148 -0
- package/scripts/minimal_safe_diff_hook.py +245 -0
- package/scripts/onboarding_gate_hook.py +142 -0
- package/scripts/readme_linter.py +12 -3
- package/scripts/roadmap_progress_hook.py +5 -0
- package/scripts/schemas/rule.schema.json +5 -0
- package/scripts/sync_agent_settings.py +32 -129
- package/scripts/sync_yaml_rt.py +734 -0
- package/scripts/verify_before_complete_hook.py +216 -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_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,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())
|
package/scripts/readme_linter.py
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
}
|