@event4u/agent-config 1.18.0 → 1.20.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 (181) hide show
  1. package/.agent-src/commands/agent-handoff.md +14 -10
  2. package/.agent-src/commands/chat-history/import.md +170 -0
  3. package/.agent-src/commands/chat-history/learn.md +178 -0
  4. package/.agent-src/commands/chat-history/show.md +17 -18
  5. package/.agent-src/commands/chat-history.md +26 -25
  6. package/.agent-src/commands/council/default.md +77 -82
  7. package/.agent-src/commands/create-pr.md +28 -8
  8. package/.agent-src/commands/feature/roadmap.md +22 -0
  9. package/.agent-src/commands/roadmap/create.md +38 -6
  10. package/.agent-src/commands/roadmap/execute.md +36 -9
  11. package/.agent-src/commands/sync-gitignore.md +1 -1
  12. package/.agent-src/contexts/communication/rules-auto/skill-quality-mechanics.md +76 -0
  13. package/.agent-src/contexts/communication/rules-auto/slash-command-routing-policy-mechanics.md +3 -3
  14. package/.agent-src/contexts/communication/rules-auto/user-interaction-mechanics.md +5 -12
  15. package/.agent-src/rules/agent-authority.md +1 -0
  16. package/.agent-src/rules/agent-docs.md +1 -0
  17. package/.agent-src/rules/analysis-skill-routing.md +1 -0
  18. package/.agent-src/rules/architecture.md +1 -0
  19. package/.agent-src/rules/artifact-drafting-protocol.md +1 -0
  20. package/.agent-src/rules/artifact-engagement-recording.md +1 -0
  21. package/.agent-src/rules/ask-when-uncertain.md +1 -0
  22. package/.agent-src/rules/augment-portability.md +1 -0
  23. package/.agent-src/rules/augment-source-of-truth.md +1 -0
  24. package/.agent-src/rules/autonomous-execution.md +1 -0
  25. package/.agent-src/rules/capture-learnings.md +1 -0
  26. package/.agent-src/rules/cli-output-handling.md +2 -2
  27. package/.agent-src/rules/command-suggestion-policy.md +1 -0
  28. package/.agent-src/rules/commit-conventions.md +1 -0
  29. package/.agent-src/rules/commit-policy.md +1 -0
  30. package/.agent-src/rules/context-hygiene.md +22 -0
  31. package/.agent-src/rules/direct-answers.md +11 -2
  32. package/.agent-src/rules/docker-commands.md +1 -0
  33. package/.agent-src/rules/docs-sync.md +1 -0
  34. package/.agent-src/rules/downstream-changes.md +1 -0
  35. package/.agent-src/rules/e2e-testing.md +1 -0
  36. package/.agent-src/rules/guidelines.md +1 -0
  37. package/.agent-src/rules/improve-before-implement.md +1 -0
  38. package/.agent-src/rules/language-and-tone.md +38 -6
  39. package/.agent-src/rules/laravel-translations.md +1 -0
  40. package/.agent-src/rules/markdown-safe-codeblocks.md +1 -0
  41. package/.agent-src/rules/minimal-safe-diff.md +1 -0
  42. package/.agent-src/rules/missing-tool-handling.md +1 -0
  43. package/.agent-src/rules/model-recommendation.md +1 -0
  44. package/.agent-src/rules/no-attribution-footers.md +48 -0
  45. package/.agent-src/rules/no-cheap-questions.md +1 -0
  46. package/.agent-src/rules/no-roadmap-references.md +2 -1
  47. package/.agent-src/rules/non-destructive-by-default.md +1 -0
  48. package/.agent-src/rules/onboarding-gate.md +26 -0
  49. package/.agent-src/rules/package-ci-checks.md +1 -0
  50. package/.agent-src/rules/php-coding.md +1 -0
  51. package/.agent-src/rules/preservation-guard.md +1 -0
  52. package/.agent-src/rules/review-routing-awareness.md +1 -0
  53. package/.agent-src/rules/reviewer-awareness.md +1 -0
  54. package/.agent-src/rules/roadmap-progress-sync.md +22 -0
  55. package/.agent-src/rules/role-mode-adherence.md +2 -2
  56. package/.agent-src/rules/rule-type-governance.md +1 -0
  57. package/.agent-src/rules/runtime-safety.md +1 -0
  58. package/.agent-src/rules/scope-control.md +1 -0
  59. package/.agent-src/rules/security-sensitive-stop.md +1 -0
  60. package/.agent-src/rules/size-enforcement.md +1 -0
  61. package/.agent-src/rules/skill-improvement-trigger.md +1 -0
  62. package/.agent-src/rules/skill-quality.md +50 -0
  63. package/.agent-src/rules/slash-command-routing-policy.md +39 -0
  64. package/.agent-src/rules/think-before-action.md +1 -0
  65. package/.agent-src/rules/token-efficiency.md +1 -0
  66. package/.agent-src/rules/tool-safety.md +1 -0
  67. package/.agent-src/rules/ui-audit-gate.md +1 -0
  68. package/.agent-src/rules/upstream-proposal.md +1 -0
  69. package/.agent-src/rules/user-interaction.md +22 -5
  70. package/.agent-src/rules/verify-before-complete.md +1 -0
  71. package/.agent-src/skills/ai-council/SKILL.md +4 -5
  72. package/.agent-src/skills/dcf-modeling/SKILL.md +89 -0
  73. package/.agent-src/skills/funnel-analysis/SKILL.md +100 -0
  74. package/.agent-src/skills/md-language-check/SKILL.md +1 -1
  75. package/.agent-src/skills/okr-tree-modeling/SKILL.md +93 -0
  76. package/.agent-src/skills/rice-prioritization/SKILL.md +100 -0
  77. package/.agent-src/skills/roadmap-management/SKILL.md +29 -4
  78. package/.agent-src/skills/subagent-orchestration/SKILL.md +34 -2
  79. package/.agent-src/skills/unit-economics-modeling/SKILL.md +104 -0
  80. package/.agent-src/skills/using-git-worktrees/SKILL.md +1 -0
  81. package/.agent-src/skills/verify-completion-evidence/SKILL.md +8 -1
  82. package/.agent-src/templates/agent-settings.md +21 -26
  83. package/.agent-src/templates/roadmaps.md +8 -3
  84. package/.agent-src/templates/scripts/work_engine/hook_bootstrap.py +16 -5
  85. package/.agent-src/templates/scripts/work_engine/hooks/__init__.py +4 -4
  86. package/.agent-src/templates/scripts/work_engine/hooks/builtin/__init__.py +4 -4
  87. package/.agent-src/templates/scripts/work_engine/hooks/builtin/_chat_history_base.py +7 -51
  88. package/.agent-src/templates/scripts/work_engine/hooks/builtin/chat_history_append.py +1 -2
  89. package/.agent-src/templates/scripts/work_engine/hooks/builtin/chat_history_halt_append.py +1 -2
  90. package/.agent-src/templates/scripts/work_engine/hooks/builtin/decision_trace.py +163 -0
  91. package/.agent-src/templates/scripts/work_engine/hooks/builtin/memory_visibility.py +110 -0
  92. package/.agent-src/templates/scripts/work_engine/hooks/settings.py +36 -0
  93. package/.agent-src/templates/scripts/work_engine/scoring/decision_trace.py +141 -0
  94. package/.agent-src/templates/scripts/work_engine/scoring/memory_visibility.py +125 -0
  95. package/.agent-src/templates/skill.md +30 -1
  96. package/.claude-plugin/marketplace.json +8 -4
  97. package/AGENTS.md +44 -3
  98. package/CHANGELOG.md +173 -0
  99. package/README.md +22 -22
  100. package/config/agent-settings.template.yml +42 -13
  101. package/config/gitignore-block.txt +4 -4
  102. package/docs/architecture.md +3 -3
  103. package/docs/catalog.md +18 -13
  104. package/docs/contracts/adr-chat-history-split.md +10 -1
  105. package/docs/contracts/adr-settings-sync-engine.md +127 -0
  106. package/docs/contracts/command-clusters.md +1 -1
  107. package/docs/contracts/cross-wing-handoff.md +133 -0
  108. package/docs/contracts/decision-trace-v1.md +146 -0
  109. package/docs/contracts/file-ownership-matrix.json +348 -126
  110. package/docs/contracts/hook-architecture-v1.md +220 -0
  111. package/docs/contracts/memory-visibility-v1.md +122 -0
  112. package/docs/contracts/one-off-script-lifecycle.md +109 -0
  113. package/docs/contracts/rule-interactions.yml +22 -0
  114. package/docs/customization.md +2 -1
  115. package/docs/development.md +4 -1
  116. package/docs/getting-started.md +21 -29
  117. package/docs/guidelines/agent-infra/ask-when-uncertain-demos.md +1 -1
  118. package/docs/guidelines/agent-infra/layered-settings.md +32 -13
  119. package/docs/hook-payload-capture.md +221 -0
  120. package/docs/migrations/commands-1.15.0.md +17 -12
  121. package/docs/skills-catalog.md +5 -4
  122. package/llms.txt +4 -3
  123. package/package.json +1 -1
  124. package/scripts/agent-config +45 -1
  125. package/scripts/ai_council/_default_prices.py +4 -4
  126. package/scripts/ai_council/bundler.py +3 -3
  127. package/scripts/ai_council/clients.py +25 -9
  128. package/scripts/ai_council/modes.py +3 -4
  129. package/scripts/ai_council/one_off_archive/2026-05/README.md +22 -0
  130. package/scripts/ai_council/one_off_archive/2026-05/_one_off_roundtrip.py +13 -8
  131. package/scripts/ai_council/one_off_archive/2026-05/_one_off_tier_retrofit.py +180 -0
  132. package/scripts/ai_council/pricing.py +10 -9
  133. package/scripts/ai_council/session.py +92 -0
  134. package/scripts/build_rule_trigger_matrix.py +1 -9
  135. package/scripts/capture_showcase_session.py +361 -0
  136. package/scripts/chat_history.py +963 -597
  137. package/scripts/check_always_budget.py +7 -2
  138. package/scripts/check_references.py +12 -2
  139. package/scripts/context_hygiene_hook.py +14 -6
  140. package/scripts/council_cli.py +407 -0
  141. package/scripts/hook_manifest.yaml +217 -0
  142. package/scripts/hooks/__init__.py +1 -0
  143. package/scripts/hooks/augment-chat-history.sh +10 -0
  144. package/scripts/hooks/augment-dispatcher.sh +72 -0
  145. package/scripts/hooks/cline-dispatcher.sh +86 -0
  146. package/scripts/hooks/cowork-dispatcher.sh +98 -0
  147. package/scripts/hooks/cursor-dispatcher.sh +76 -0
  148. package/scripts/hooks/dispatch_hook.py +383 -0
  149. package/scripts/hooks/envelope.py +98 -0
  150. package/scripts/hooks/gemini-dispatcher.sh +117 -0
  151. package/scripts/hooks/state_io.py +122 -0
  152. package/scripts/hooks/windsurf-dispatcher.sh +123 -0
  153. package/scripts/hooks_status.py +157 -0
  154. package/scripts/install-hooks.sh +2 -2
  155. package/scripts/install.py +725 -87
  156. package/scripts/install.sh +38 -1
  157. package/scripts/lint_handoffs.py +214 -0
  158. package/scripts/lint_hook_manifest.py +217 -0
  159. package/scripts/lint_one_off_age.py +184 -0
  160. package/scripts/lint_rule_tiers.py +78 -0
  161. package/scripts/lint_showcase_sessions.py +148 -0
  162. package/scripts/minimal_safe_diff_hook.py +245 -0
  163. package/scripts/onboarding_gate_hook.py +13 -8
  164. package/scripts/readme_linter.py +12 -3
  165. package/scripts/redact_hook_capture.py +148 -0
  166. package/scripts/roadmap_progress_hook.py +5 -0
  167. package/scripts/schemas/skill.schema.json +5 -0
  168. package/scripts/skill_linter.py +163 -1
  169. package/scripts/sync_agent_settings.py +32 -129
  170. package/scripts/sync_yaml_rt.py +734 -0
  171. package/scripts/update_prices.py +3 -3
  172. package/scripts/verify_before_complete_hook.py +216 -0
  173. package/.agent-src/commands/chat-history/checkpoint.md +0 -126
  174. package/.agent-src/commands/chat-history/clear.md +0 -103
  175. package/.agent-src/commands/chat-history/resume.md +0 -183
  176. package/.agent-src/rules/chat-history-cadence.md +0 -109
  177. package/.agent-src/rules/chat-history-ownership.md +0 -123
  178. package/.agent-src/rules/chat-history-visibility.md +0 -96
  179. package/.agent-src/templates/scripts/work_engine/hooks/builtin/chat_history_heartbeat.py +0 -50
  180. package/.agent-src/templates/scripts/work_engine/hooks/builtin/chat_history_turn_check.py +0 -49
  181. package/scripts/check_phase_coupling.py +0 -148
@@ -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())
@@ -26,11 +26,16 @@ from __future__ import annotations
26
26
 
27
27
  import argparse
28
28
  import datetime as _dt
29
- import json
30
29
  import re
31
30
  import sys
32
31
  from pathlib import Path
33
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
+
34
39
  SETTINGS_FILE = ".agent-settings.yml"
35
40
  STATE_DIR = Path("agents") / "state"
36
41
  STATE_FILE = STATE_DIR / "onboarding-gate.json"
@@ -79,9 +84,12 @@ def _read_onboarded(settings_path: Path) -> tuple[bool, str]:
79
84
 
80
85
  def _write_state(consumer_root: Path, required: bool, reason: str,
81
86
  settings_present: bool) -> None:
82
- """Write `agents/state/onboarding-gate.json` atomically."""
83
- state_dir = consumer_root / STATE_DIR
84
- state_dir.mkdir(parents=True, exist_ok=True)
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
+ """
85
93
  payload = {
86
94
  "required": required,
87
95
  "reason": reason,
@@ -89,10 +97,7 @@ def _write_state(consumer_root: Path, required: bool, reason: str,
89
97
  timespec="seconds"),
90
98
  "settings_present": settings_present,
91
99
  }
92
- target = consumer_root / STATE_FILE
93
- tmp = target.with_suffix(".json.tmp")
94
- tmp.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8")
95
- tmp.replace(target)
100
+ atomic_write_json(consumer_root / STATE_FILE, payload)
96
101
 
97
102
 
98
103
  def run(*, consumer_root: Path, verbose: bool = False) -> int:
@@ -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]:
@@ -0,0 +1,148 @@
1
+ #!/usr/bin/env python3
2
+ """Redact captured hook payloads for the verified-platforms roadmap.
3
+
4
+ Reads JSON capture files written by ``dispatch_hook.py`` (when
5
+ ``AGENT_HOOK_CAPTURE_DIR`` is set) and produces a redacted version
6
+ suitable for pasting into
7
+ ``agents/roadmaps/road-to-verified-chat-history-platforms.md``.
8
+
9
+ Redaction policy (per the roadmap's Capture-and-redact protocol):
10
+
11
+ - Replace string values at known user-content paths with
12
+ ``<REDACTED>``. Default field allowlist mirrors the fallback list
13
+ in ``scripts/chat_history.py::_extract_hook_text`` plus Augment's
14
+ nested ``conversation.*`` shape.
15
+ - Preserve envelope keys (``hook_event_name``, ``session_id``,
16
+ ``platform``, ``event``, ``cwd``, ``workspace_roots``,
17
+ ``transcript_path``, ``model``, ``cursor_version``, …) so the
18
+ schema is reviewable.
19
+ - ``--strict`` redacts any string longer than ``--max-len`` (default
20
+ 120) chars regardless of key, as a safety net for unknown fields.
21
+
22
+ Usage:
23
+
24
+ python3 scripts/redact_hook_capture.py <input> [--out <path>] [--strict]
25
+
26
+ Input may be a single JSON file or a directory; with a directory,
27
+ every ``*.json`` is redacted and written next to the original with
28
+ the suffix ``.redacted.json``.
29
+ """
30
+ from __future__ import annotations
31
+
32
+ import argparse
33
+ import json
34
+ import sys
35
+ from pathlib import Path
36
+ from typing import Any
37
+
38
+ REDACTED = "<REDACTED>"
39
+
40
+ # Field names that carry user / agent content (from
41
+ # scripts/chat_history.py::_extract_hook_text fallback list +
42
+ # nested Augment shape). Matched case-insensitively against the
43
+ # leaf key.
44
+ _USER_CONTENT_KEYS = {
45
+ "prompt", "user_prompt", "userprompt", "first_user_msg",
46
+ "firstusermsg", "usermessage", "user_message", "text",
47
+ "response", "message", "content",
48
+ # Augment Code with includeConversationData
49
+ "agenttextresponse", "agent_text_response",
50
+ "agentcoderesponse", "agent_code_response",
51
+ # Cursor / generic
52
+ "submitted_prompt", "submittedprompt",
53
+ # Free-form transcript bodies (path stays — content is in another file)
54
+ "transcript", "transcript_text",
55
+ }
56
+
57
+ # Keys whose value is a structural / schema marker — keep as-is even
58
+ # when --strict would otherwise redact long values.
59
+ _ENVELOPE_KEYS_KEEP = {
60
+ "hook_event_name", "session_id", "transcript_path", "transcriptpath",
61
+ "platform", "event", "native_event", "captured_at", "cwd",
62
+ "workspace_roots", "model", "cursor_version", "user_email",
63
+ "conversation_id", "generation_id", "agent", "type",
64
+ "schema_version", "started_at", "completed_at", "_raw_text",
65
+ "path", "changetype", "change_type",
66
+ }
67
+
68
+
69
+ def _redact_value(val: Any, *, key: str | None, strict: bool,
70
+ max_len: int) -> Any:
71
+ """Recursively redact a value."""
72
+ norm_key = (key or "").lower().replace("-", "_")
73
+ if isinstance(val, dict):
74
+ return {k: _redact_value(v, key=k, strict=strict, max_len=max_len)
75
+ for k, v in val.items()}
76
+ if isinstance(val, list):
77
+ return [_redact_value(item, key=key, strict=strict, max_len=max_len)
78
+ for item in val]
79
+ if isinstance(val, str):
80
+ if norm_key in _ENVELOPE_KEYS_KEEP:
81
+ return val
82
+ if norm_key in _USER_CONTENT_KEYS:
83
+ return REDACTED
84
+ if strict and len(val) > max_len:
85
+ return REDACTED
86
+ return val
87
+ return val
88
+
89
+
90
+ def redact(record: dict, *, strict: bool = False, max_len: int = 120) -> dict:
91
+ """Redact a single capture record. Top-level envelope is preserved."""
92
+ out: dict = {}
93
+ for k, v in record.items():
94
+ if k == "raw_payload":
95
+ out[k] = _redact_value(v, key=None, strict=strict,
96
+ max_len=max_len)
97
+ else:
98
+ out[k] = _redact_value(v, key=k, strict=strict,
99
+ max_len=max_len)
100
+ return out
101
+
102
+
103
+ def _process_file(path: Path, *, out: Path | None, strict: bool,
104
+ max_len: int) -> Path:
105
+ record = json.loads(path.read_text(encoding="utf-8"))
106
+ redacted = redact(record, strict=strict, max_len=max_len)
107
+ target = out or path.with_suffix(".redacted.json")
108
+ target.write_text(json.dumps(redacted, indent=2) + "\n",
109
+ encoding="utf-8")
110
+ return target
111
+
112
+
113
+ def main(argv: list[str] | None = None) -> int:
114
+ parser = argparse.ArgumentParser(description=__doc__)
115
+ parser.add_argument("input", help="capture file or directory")
116
+ parser.add_argument("--out", default=None,
117
+ help="output path (single-file mode only)")
118
+ parser.add_argument("--strict", action="store_true",
119
+ help="redact any string longer than --max-len")
120
+ parser.add_argument("--max-len", type=int, default=120,
121
+ help="strict-mode length threshold (default 120)")
122
+ args = parser.parse_args(argv)
123
+
124
+ src = Path(args.input).expanduser()
125
+ if not src.exists():
126
+ sys.stderr.write(f"redact: input not found: {src}\n")
127
+ return 2
128
+
129
+ if src.is_dir():
130
+ if args.out:
131
+ sys.stderr.write("redact: --out is single-file only\n")
132
+ return 2
133
+ files = sorted(p for p in src.glob("*.json")
134
+ if not p.name.endswith(".redacted.json"))
135
+ for path in files:
136
+ target = _process_file(path, out=None, strict=args.strict,
137
+ max_len=args.max_len)
138
+ print(f"redacted: {target}")
139
+ return 0
140
+
141
+ target = _process_file(src, out=Path(args.out) if args.out else None,
142
+ strict=args.strict, max_len=args.max_len)
143
+ print(f"redacted: {target}")
144
+ return 0
145
+
146
+
147
+ if __name__ == "__main__":
148
+ raise SystemExit(main())