@event4u/agent-config 1.15.0 → 1.16.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 (244) hide show
  1. package/.agent-src/commands/bug-fix.md +1 -1
  2. package/.agent-src/commands/bug-investigate.md +2 -2
  3. package/.agent-src/commands/chat-history-checkpoint.md +1 -1
  4. package/.agent-src/commands/chat-history-clear.md +1 -1
  5. package/.agent-src/commands/chat-history.md +1 -1
  6. package/.agent-src/commands/check-current-md.md +1 -1
  7. package/.agent-src/commands/council-design.md +96 -0
  8. package/.agent-src/commands/council-optimize.md +115 -0
  9. package/.agent-src/commands/council-pr.md +123 -0
  10. package/.agent-src/commands/council.md +219 -0
  11. package/.agent-src/commands/create-pr.md +23 -0
  12. package/.agent-src/commands/do-and-judge.md +3 -3
  13. package/.agent-src/commands/do-in-steps.md +4 -4
  14. package/.agent-src/commands/e2e-heal.md +1 -1
  15. package/.agent-src/commands/e2e-plan.md +1 -1
  16. package/.agent-src/commands/feature-dev.md +8 -0
  17. package/.agent-src/commands/feature-explore.md +6 -1
  18. package/.agent-src/commands/feature-plan.md +33 -2
  19. package/.agent-src/commands/feature-refactor.md +5 -0
  20. package/.agent-src/commands/feature-roadmap.md +6 -1
  21. package/.agent-src/commands/feature.md +58 -0
  22. package/.agent-src/commands/fix-ci.md +5 -0
  23. package/.agent-src/commands/fix-portability.md +5 -0
  24. package/.agent-src/commands/fix-pr-bot-comments.md +5 -0
  25. package/.agent-src/commands/fix-pr-comments.md +5 -0
  26. package/.agent-src/commands/fix-pr-developer-comments.md +5 -0
  27. package/.agent-src/commands/fix-references.md +5 -0
  28. package/.agent-src/commands/fix-seeder.md +5 -0
  29. package/.agent-src/commands/fix.md +60 -0
  30. package/.agent-src/commands/jira-ticket.md +1 -1
  31. package/.agent-src/commands/judge.md +1 -1
  32. package/.agent-src/commands/memory-add.md +3 -3
  33. package/.agent-src/commands/memory-full.md +2 -2
  34. package/.agent-src/commands/memory-promote.md +2 -2
  35. package/.agent-src/commands/mode.md +5 -5
  36. package/.agent-src/commands/onboard.md +3 -3
  37. package/.agent-src/commands/optimize-agents.md +6 -1
  38. package/.agent-src/commands/optimize-augmentignore.md +5 -0
  39. package/.agent-src/commands/optimize-rtk-filters.md +5 -0
  40. package/.agent-src/commands/optimize-skills.md +6 -1
  41. package/.agent-src/commands/optimize.md +54 -0
  42. package/.agent-src/commands/propose-memory.md +2 -2
  43. package/.agent-src/commands/review-changes.md +26 -1
  44. package/.agent-src/commands/review-routing.md +1 -1
  45. package/.agent-src/commands/roadmap-create.md +29 -2
  46. package/.agent-src/commands/set-cost-profile.md +3 -3
  47. package/.agent-src/commands/sync-agent-settings.md +2 -2
  48. package/.agent-src/commands/tests-create.md +1 -1
  49. package/.agent-src/commands/upstream-contribute.md +1 -1
  50. package/.agent-src/contexts/authority/commit-mechanics.md +57 -0
  51. package/.agent-src/contexts/authority/destructive-mechanics.md +66 -0
  52. package/.agent-src/contexts/authority/scope-mechanics.md +87 -0
  53. package/.agent-src/contexts/execution/autonomy-detection.md +54 -0
  54. package/.agent-src/contexts/execution/autonomy-examples.md +90 -0
  55. package/.agent-src/contexts/execution/autonomy-mechanics.md +29 -0
  56. package/.agent-src/contexts/execution/verification-mechanics.md +80 -0
  57. package/.agent-src/personas/README.md +1 -1
  58. package/.agent-src/rules/agent-authority.md +24 -0
  59. package/.agent-src/rules/architecture.md +1 -1
  60. package/.agent-src/rules/artifact-drafting-protocol.md +1 -1
  61. package/.agent-src/rules/artifact-engagement-recording.md +1 -1
  62. package/.agent-src/rules/ask-when-uncertain.md +1 -1
  63. package/.agent-src/rules/autonomous-execution.md +78 -114
  64. package/.agent-src/rules/capture-learnings.md +1 -1
  65. package/.agent-src/rules/chat-history-cadence.md +3 -3
  66. package/.agent-src/rules/chat-history-ownership.md +3 -3
  67. package/.agent-src/rules/chat-history-visibility.md +3 -3
  68. package/.agent-src/rules/{command-suggestion.md → command-suggestion-policy.md} +7 -7
  69. package/.agent-src/rules/commit-conventions.md +1 -1
  70. package/.agent-src/rules/commit-policy.md +14 -42
  71. package/.agent-src/rules/context-hygiene.md +3 -3
  72. package/.agent-src/rules/direct-answers.md +1 -1
  73. package/.agent-src/rules/docs-sync.md +1 -1
  74. package/.agent-src/rules/e2e-testing.md +1 -1
  75. package/.agent-src/rules/guidelines.md +4 -4
  76. package/.agent-src/rules/improve-before-implement.md +2 -2
  77. package/.agent-src/rules/language-and-tone.md +37 -96
  78. package/.agent-src/rules/minimal-safe-diff.md +3 -3
  79. package/.agent-src/rules/model-recommendation.md +4 -4
  80. package/.agent-src/rules/no-cheap-questions.md +89 -0
  81. package/.agent-src/rules/non-destructive-by-default.md +15 -49
  82. package/.agent-src/rules/onboarding-gate.md +5 -5
  83. package/.agent-src/rules/review-routing-awareness.md +9 -9
  84. package/.agent-src/rules/roadmap-progress-sync.md +26 -33
  85. package/.agent-src/rules/role-mode-adherence.md +2 -2
  86. package/.agent-src/rules/scope-control.md +65 -46
  87. package/.agent-src/rules/security-sensitive-stop.md +2 -2
  88. package/.agent-src/rules/size-enforcement.md +1 -1
  89. package/.agent-src/rules/think-before-action.md +5 -5
  90. package/.agent-src/rules/token-efficiency.md +4 -4
  91. package/.agent-src/rules/{ui-audit-before-build.md → ui-audit-gate.md} +3 -3
  92. package/.agent-src/rules/user-interaction.md +3 -3
  93. package/.agent-src/rules/verify-before-complete.md +12 -67
  94. package/.agent-src/scripts/update_roadmap_progress.py +9 -4
  95. package/.agent-src/skills/ai-council/SKILL.md +333 -0
  96. package/.agent-src/skills/api-endpoint/SKILL.md +2 -2
  97. package/.agent-src/skills/blade-ui/SKILL.md +1 -1
  98. package/.agent-src/skills/blast-radius-analyzer/SKILL.md +1 -1
  99. package/.agent-src/skills/bug-analyzer/SKILL.md +1 -1
  100. package/.agent-src/skills/command-routing/SKILL.md +1 -1
  101. package/.agent-src/skills/command-writing/SKILL.md +1 -1
  102. package/.agent-src/skills/conventional-commits-writing/SKILL.md +1 -1
  103. package/.agent-src/skills/copilot-agents-optimization/SKILL.md +2 -2
  104. package/.agent-src/skills/developer-like-execution/SKILL.md +2 -2
  105. package/.agent-src/skills/flux/SKILL.md +1 -1
  106. package/.agent-src/skills/git-workflow/SKILL.md +1 -1
  107. package/.agent-src/skills/guideline-writing/SKILL.md +11 -11
  108. package/.agent-src/skills/learning-to-rule-or-skill/SKILL.md +4 -4
  109. package/.agent-src/skills/livewire/SKILL.md +1 -1
  110. package/.agent-src/skills/override-management/SKILL.md +2 -2
  111. package/.agent-src/skills/php-coder/SKILL.md +1 -1
  112. package/.agent-src/skills/playwright-testing/SKILL.md +2 -2
  113. package/.agent-src/skills/readme-reviewer/SKILL.md +1 -1
  114. package/.agent-src/skills/readme-writing/SKILL.md +1 -1
  115. package/.agent-src/skills/readme-writing-package/SKILL.md +1 -1
  116. package/.agent-src/skills/receiving-code-review/SKILL.md +1 -1
  117. package/.agent-src/skills/review-routing/SKILL.md +2 -2
  118. package/.agent-src/skills/rule-writing/SKILL.md +1 -1
  119. package/.agent-src/skills/skill-reviewer/SKILL.md +1 -1
  120. package/.agent-src/skills/skill-writing/SKILL.md +3 -3
  121. package/.agent-src/skills/subagent-orchestration/SKILL.md +1 -0
  122. package/.agent-src/skills/systematic-debugging/SKILL.md +1 -1
  123. package/.agent-src/skills/upstream-contribute/SKILL.md +1 -1
  124. package/.agent-src/skills/validate-feature-fit/SKILL.md +2 -2
  125. package/.agent-src/skills/{verify-before-complete → verify-completion-evidence}/SKILL.md +2 -2
  126. package/.agent-src/templates/agent-settings.md +8 -8
  127. package/.agent-src/templates/contexts/auth-model.md +1 -1
  128. package/.agent-src/templates/scripts/README.md +2 -2
  129. package/.agent-src/templates/scripts/telemetry/aggregator.py +16 -1
  130. package/.agent-src/templates/scripts/telemetry/engagement.py +59 -0
  131. package/.agent-src/templates/scripts/telemetry/report_renderer.py +28 -1
  132. package/.agent-src/templates/scripts/telemetry_record.py +14 -1
  133. package/.claude-plugin/marketplace.json +10 -2
  134. package/AGENTS.md +11 -9
  135. package/CHANGELOG.md +123 -1
  136. package/README.md +28 -30
  137. package/config/agent-settings.template.yml +58 -1
  138. package/config/gitignore-block.txt +3 -0
  139. package/docs/architecture.md +4 -4
  140. package/docs/catalog.md +331 -0
  141. package/docs/contracts/STABILITY.md +39 -0
  142. package/docs/contracts/adr-command-suggestion.md +3 -3
  143. package/docs/contracts/adr-product-ui-track.md +2 -2
  144. package/docs/contracts/agent-memory-contract.md +2 -2
  145. package/docs/contracts/artifact-engagement-flow.md +1 -1
  146. package/docs/contracts/command-clusters.md +2 -2
  147. package/docs/contracts/command-suggestion-flow.md +3 -3
  148. package/docs/contracts/implement-ticket-flow.md +2 -2
  149. package/docs/contracts/linear-ai-rules-inclusion.md +1 -1
  150. package/docs/contracts/load-context-schema.md +186 -0
  151. package/docs/contracts/rule-interactions.yml +96 -0
  152. package/docs/contracts/rule-priority-hierarchy.md +87 -0
  153. package/docs/contracts/ui-track-flow.md +1 -1
  154. package/docs/customization.md +14 -0
  155. package/docs/end-to-end-walkthroughs.md +165 -0
  156. package/docs/getting-started.md +26 -8
  157. package/docs/github-topics.md +12 -3
  158. package/docs/guidelines/agent-infra/language-and-tone-examples.md +79 -0
  159. package/{.agent-src → docs}/guidelines/docs/readme-size-and-splitting.md +26 -25
  160. package/docs/guidelines/php/git.md +164 -0
  161. package/docs/migrations/commands-1.15.0.md +1 -1
  162. package/docs/showcase.md +9 -4
  163. package/docs/skills-catalog.md +14 -8
  164. package/docs/ui-track-mental-model.md +2 -2
  165. package/llms.txt +13 -7
  166. package/package.json +1 -1
  167. package/scripts/agent-config +23 -0
  168. package/scripts/ai_council/__init__.py +39 -0
  169. package/scripts/ai_council/_default_prices.py +41 -0
  170. package/scripts/ai_council/_one_off_rebalancing_audit.py +149 -0
  171. package/scripts/ai_council/_one_off_roundtrip.py +106 -0
  172. package/scripts/ai_council/budget_guard.py +172 -0
  173. package/scripts/ai_council/bundler.py +261 -0
  174. package/scripts/ai_council/clients.py +381 -0
  175. package/scripts/ai_council/modes.py +127 -0
  176. package/scripts/ai_council/orchestrator.py +350 -0
  177. package/scripts/ai_council/pricing.py +213 -0
  178. package/scripts/ai_council/project_context.py +159 -0
  179. package/scripts/ai_council/prompts.py +232 -0
  180. package/scripts/ai_council/session.py +144 -0
  181. package/scripts/check_always_budget.py +126 -0
  182. package/scripts/check_augmentignore.py +69 -0
  183. package/scripts/check_command_count_messaging.py +120 -0
  184. package/scripts/check_portability.py +55 -0
  185. package/scripts/check_public_catalog_links.py +122 -0
  186. package/scripts/check_references.py +4 -1
  187. package/scripts/check_roadmap_trackable.py +111 -0
  188. package/scripts/command_suggester/cooldown.py +1 -1
  189. package/scripts/generate_index.py +266 -0
  190. package/scripts/install_anthropic_key.sh +5 -0
  191. package/scripts/install_openai_key.sh +106 -0
  192. package/scripts/lint_load_context.py +163 -0
  193. package/scripts/schemas/command.schema.json +20 -0
  194. package/scripts/schemas/rule.schema.json +10 -0
  195. package/scripts/skill_linter.py +12 -4
  196. package/scripts/sync_agent_settings.py +1 -1
  197. package/scripts/update_counts.py +9 -4
  198. package/scripts/update_prices.py +124 -0
  199. package/.agent-src/guidelines/php/git.md +0 -96
  200. /package/.agent-src/rules/{slash-commands.md → slash-command-routing-policy.md} +0 -0
  201. /package/{.agent-src → docs}/guidelines/agent-infra/agent-interaction-and-decision-quality.md +0 -0
  202. /package/{.agent-src → docs}/guidelines/agent-infra/break-glass-usage.md +0 -0
  203. /package/{.agent-src → docs}/guidelines/agent-infra/developer-judgment.md +0 -0
  204. /package/{.agent-src → docs}/guidelines/agent-infra/engineering-memory-data-format.md +0 -0
  205. /package/{.agent-src → docs}/guidelines/agent-infra/layered-settings.md +0 -0
  206. /package/{.agent-src → docs}/guidelines/agent-infra/memory-access.md +0 -0
  207. /package/{.agent-src → docs}/guidelines/agent-infra/naming.md +0 -0
  208. /package/{.agent-src → docs}/guidelines/agent-infra/output-patterns.md +0 -0
  209. /package/{.agent-src → docs}/guidelines/agent-infra/review-routing-data-format.md +0 -0
  210. /package/{.agent-src → docs}/guidelines/agent-infra/role-contracts.md +0 -0
  211. /package/{.agent-src → docs}/guidelines/agent-infra/role-mode-router.md +0 -0
  212. /package/{.agent-src → docs}/guidelines/agent-infra/runtime-layer.md +0 -0
  213. /package/{.agent-src → docs}/guidelines/agent-infra/self-improvement-pipeline.md +0 -0
  214. /package/{.agent-src → docs}/guidelines/agent-infra/size-and-scope.md +0 -0
  215. /package/{.agent-src → docs}/guidelines/agent-infra/tool-integration.md +0 -0
  216. /package/{.agent-src → docs}/guidelines/e2e/playwright.md +0 -0
  217. /package/{.agent-src → docs}/guidelines/php/api-design.md +0 -0
  218. /package/{.agent-src → docs}/guidelines/php/artisan-commands.md +0 -0
  219. /package/{.agent-src → docs}/guidelines/php/blade-ui.md +0 -0
  220. /package/{.agent-src → docs}/guidelines/php/controllers.md +0 -0
  221. /package/{.agent-src → docs}/guidelines/php/database.md +0 -0
  222. /package/{.agent-src → docs}/guidelines/php/eloquent.md +0 -0
  223. /package/{.agent-src → docs}/guidelines/php/flux.md +0 -0
  224. /package/{.agent-src → docs}/guidelines/php/general.md +0 -0
  225. /package/{.agent-src → docs}/guidelines/php/jobs.md +0 -0
  226. /package/{.agent-src → docs}/guidelines/php/livewire.md +0 -0
  227. /package/{.agent-src → docs}/guidelines/php/logging.md +0 -0
  228. /package/{.agent-src → docs}/guidelines/php/naming.md +0 -0
  229. /package/{.agent-src → docs}/guidelines/php/patterns/dependency-injection.md +0 -0
  230. /package/{.agent-src → docs}/guidelines/php/patterns/dtos.md +0 -0
  231. /package/{.agent-src → docs}/guidelines/php/patterns/events.md +0 -0
  232. /package/{.agent-src → docs}/guidelines/php/patterns/factory.md +0 -0
  233. /package/{.agent-src → docs}/guidelines/php/patterns/pipelines.md +0 -0
  234. /package/{.agent-src → docs}/guidelines/php/patterns/policies.md +0 -0
  235. /package/{.agent-src → docs}/guidelines/php/patterns/repositories.md +0 -0
  236. /package/{.agent-src → docs}/guidelines/php/patterns/service-layer.md +0 -0
  237. /package/{.agent-src → docs}/guidelines/php/patterns/strategy.md +0 -0
  238. /package/{.agent-src → docs}/guidelines/php/patterns.md +0 -0
  239. /package/{.agent-src → docs}/guidelines/php/performance.md +0 -0
  240. /package/{.agent-src → docs}/guidelines/php/resources.md +0 -0
  241. /package/{.agent-src → docs}/guidelines/php/security.md +0 -0
  242. /package/{.agent-src → docs}/guidelines/php/sql.md +0 -0
  243. /package/{.agent-src → docs}/guidelines/php/validations.md +0 -0
  244. /package/{.agent-src → docs}/guidelines/php/websocket.md +0 -0
@@ -0,0 +1,261 @@
1
+ """Context bundling for council consultations.
2
+
3
+ Takes a raw artefact (free-form prompt, roadmap path, diff range, or
4
+ file set) and produces a `CouncilContext` — a redacted, size-bounded
5
+ text bundle plus a manifest describing exactly what was included.
6
+
7
+ Hard rules:
8
+ - Redaction is fail-closed. If a redaction pattern fires, the line is
9
+ scrubbed *before* the bundle is built.
10
+ - Size guard is fail-loud. > MAX_BUNDLE_BYTES → raises BundleTooLarge,
11
+ never silently truncates (would mislead council members).
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import re
17
+ import subprocess
18
+ from dataclasses import dataclass, field
19
+ from pathlib import Path
20
+
21
+ MAX_BUNDLE_BYTES = 50 * 1024 # 50 KB hard ceiling; user must narrow scope on hit.
22
+
23
+
24
+ class BundleTooLarge(RuntimeError):
25
+ """Raised when the assembled bundle exceeds MAX_BUNDLE_BYTES."""
26
+
27
+
28
+ @dataclass
29
+ class CouncilContext:
30
+ mode: str # one of: prompt, roadmap, diff, files
31
+ text: str
32
+ manifest: list[str] = field(default_factory=list)
33
+ excluded: list[str] = field(default_factory=list)
34
+
35
+
36
+ # ── redaction patterns ───────────────────────────────────────────────────
37
+ # Each pattern is matched line-wise; matching lines are replaced with the
38
+ # placeholder. Order matters — the most specific pattern goes first.
39
+
40
+ _REDACTION_LINE_PATTERNS: list[tuple[re.Pattern[str], str]] = [
41
+ (re.compile(r".*~?/?\.config/agent-config/[^/\s]+\.key.*"),
42
+ "[redacted: agent-config key path]"),
43
+ (re.compile(r"^\s*Authorization:\s.*", re.IGNORECASE),
44
+ "[redacted: Authorization header]"),
45
+ (re.compile(r"(?i).*(api[_-]?key|secret|token|password)\s*[:=].*"),
46
+ "[redacted: secret-like assignment]"),
47
+ (re.compile(r"sk-ant-[A-Za-z0-9_\-]{8,}"), "[redacted: anthropic-key-like token]"),
48
+ (re.compile(r"sk-[A-Za-z0-9_\-]{20,}"), "[redacted: openai-key-like token]"),
49
+ ]
50
+
51
+
52
+ def redact(text: str) -> str:
53
+ """Apply redaction patterns to a multi-line text buffer."""
54
+ out: list[str] = []
55
+ for line in text.splitlines():
56
+ replaced = line
57
+ for pattern, placeholder in _REDACTION_LINE_PATTERNS:
58
+ if pattern.search(replaced):
59
+ replaced = placeholder
60
+ break
61
+ out.append(replaced)
62
+ return "\n".join(out)
63
+
64
+
65
+ def _enforce_size(text: str, mode: str) -> str:
66
+ encoded = text.encode("utf-8")
67
+ if len(encoded) > MAX_BUNDLE_BYTES:
68
+ raise BundleTooLarge(
69
+ f"Bundle for {mode!r} mode is {len(encoded)} bytes "
70
+ f"(> {MAX_BUNDLE_BYTES} hard ceiling). "
71
+ "Narrow the scope (smaller diff, fewer files, shorter prompt)."
72
+ )
73
+ return text
74
+
75
+
76
+ def bundle_prompt(text: str) -> CouncilContext:
77
+ redacted = redact(text)
78
+ return CouncilContext(
79
+ mode="prompt",
80
+ text=_enforce_size(redacted, "prompt"),
81
+ manifest=["<inline prompt>"],
82
+ )
83
+
84
+
85
+ def bundle_roadmap(path: str | Path) -> CouncilContext:
86
+ p = Path(path)
87
+ if not p.exists():
88
+ raise FileNotFoundError(f"Roadmap not found: {p}")
89
+ raw = p.read_text(encoding="utf-8")
90
+ redacted = redact(raw)
91
+ return CouncilContext(
92
+ mode="roadmap",
93
+ text=_enforce_size(redacted, "roadmap"),
94
+ manifest=[str(p)],
95
+ excluded=["<linked contracts/skills not included by default>"],
96
+ )
97
+
98
+
99
+ def bundle_diff(base_ref: str, head_ref: str = "HEAD", cwd: str | Path | None = None) -> CouncilContext:
100
+ cmd = ["git", "diff", f"{base_ref}..{head_ref}"]
101
+ try:
102
+ proc = subprocess.run(
103
+ cmd, cwd=cwd, check=True, capture_output=True, text=True,
104
+ )
105
+ except subprocess.CalledProcessError as exc:
106
+ raise RuntimeError(
107
+ f"git diff {base_ref}..{head_ref} failed: {exc.stderr.strip()}"
108
+ ) from exc
109
+ redacted = redact(proc.stdout)
110
+ return CouncilContext(
111
+ mode="diff",
112
+ text=_enforce_size(redacted, "diff"),
113
+ manifest=[f"git diff {base_ref}..{head_ref}"],
114
+ )
115
+
116
+
117
+ # ── smart diff context (D4) ─────────────────────────────────────────────────
118
+ # Language-agnostic signature detection. Order matters — most specific first.
119
+
120
+ _SIGNATURE_PATTERNS: list[re.Pattern[str]] = [
121
+ re.compile(r"^\s*(?:async\s+)?def\s+\w+\s*\("), # Python
122
+ re.compile(r"^\s*class\s+\w+\b"), # Python / PHP / JS class
123
+ re.compile(r"^\s*(?:public|protected|private|static|abstract|final)\s+(?:static\s+)?function\s+\w+"), # PHP method
124
+ re.compile(r"^\s*function\s+\w+\s*\("), # PHP free function / JS
125
+ re.compile(r"^\s*export\s+(?:default\s+)?(?:async\s+)?function\s+\w+"), # TS/JS export fn
126
+ re.compile(r"^\s*export\s+(?:default\s+)?class\s+\w+"), # TS/JS export class
127
+ re.compile(r"^\s*(?:export\s+)?(?:const|let)\s+\w+\s*=\s*(?:async\s+)?\("), # TS arrow fn
128
+ re.compile(r"^\s*(?:public|private|protected)\s+\w+\s*\("), # TS method
129
+ ]
130
+
131
+ _HUNK_HEADER = re.compile(r"^@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@")
132
+ _DIFF_FILE = re.compile(r"^\+\+\+ b/(.+)$")
133
+
134
+
135
+ def _parse_diff_hunks(diff_text: str) -> list[tuple[str, int]]:
136
+ """Return [(file_path, new_start_line), ...] per hunk in input order."""
137
+ out: list[tuple[str, int]] = []
138
+ current_file: str | None = None
139
+ for line in diff_text.splitlines():
140
+ m = _DIFF_FILE.match(line)
141
+ if m:
142
+ current_file = m.group(1)
143
+ continue
144
+ h = _HUNK_HEADER.match(line)
145
+ if h and current_file and current_file != "/dev/null":
146
+ out.append((current_file, int(h.group(1))))
147
+ return out
148
+
149
+
150
+ def _enclosing_signature(
151
+ file_text: str, target_line: int,
152
+ ) -> tuple[int, str] | None:
153
+ """Walk backwards from `target_line` (1-based) to nearest signature."""
154
+ lines = file_text.splitlines()
155
+ start = min(target_line - 1, len(lines) - 1)
156
+ for idx in range(start, -1, -1):
157
+ line = lines[idx]
158
+ for pat in _SIGNATURE_PATTERNS:
159
+ if pat.match(line):
160
+ return (idx + 1, line.rstrip())
161
+ return None
162
+
163
+
164
+ def bundle_diff_with_context(
165
+ base_ref: str,
166
+ head_ref: str = "HEAD",
167
+ cwd: str | Path | None = None,
168
+ *,
169
+ max_context_bytes: int = 8 * 1024,
170
+ ) -> CouncilContext:
171
+ """Bundle a diff plus the nearest enclosing signatures for each hunk.
172
+
173
+ Appends a `## Surrounding signatures` section after the raw diff.
174
+ Signatures are detected by regex across PY / PHP / JS / TS. Reads
175
+ files from the working tree (correct when `head_ref` == HEAD); if
176
+ a touched file is missing on disk it is silently dropped from the
177
+ context section (the diff itself still shows the change).
178
+
179
+ Hard cap: `max_context_bytes` for the signature section. Combined
180
+ output still goes through `_enforce_size`, so the `BundleTooLarge`
181
+ behaviour is unchanged.
182
+ """
183
+ base = bundle_diff(base_ref, head_ref, cwd=cwd)
184
+ hunks = _parse_diff_hunks(base.text)
185
+ if not hunks:
186
+ return base
187
+
188
+ root = Path(cwd) if cwd else Path(".")
189
+ seen: set[tuple[str, int]] = set() # (file, signature_line)
190
+ by_file: dict[str, list[tuple[int, str]]] = {}
191
+
192
+ for file_path, new_start in hunks:
193
+ target = root / file_path
194
+ try:
195
+ file_text = target.read_text(encoding="utf-8")
196
+ except (OSError, UnicodeDecodeError):
197
+ continue
198
+ sig = _enclosing_signature(file_text, new_start)
199
+ if sig is None:
200
+ continue
201
+ key = (file_path, sig[0])
202
+ if key in seen:
203
+ continue
204
+ seen.add(key)
205
+ by_file.setdefault(file_path, []).append(sig)
206
+
207
+ if not by_file:
208
+ return base
209
+
210
+ out_lines: list[str] = ["", "## Surrounding signatures", ""]
211
+ truncated = False
212
+ used = 0
213
+ for file_path, sigs in by_file.items():
214
+ header = f"### {file_path}"
215
+ sig_block = "\n".join(f" L{ln}: {text}" for ln, text in sorted(sigs))
216
+ chunk = f"{header}\n\n{sig_block}\n\n"
217
+ if used + len(chunk.encode("utf-8")) > max_context_bytes:
218
+ truncated = True
219
+ break
220
+ out_lines.append(header)
221
+ out_lines.append("")
222
+ out_lines.append(sig_block)
223
+ out_lines.append("")
224
+ used += len(chunk.encode("utf-8"))
225
+
226
+ if truncated:
227
+ out_lines.append(f"[truncated: signature section capped at {max_context_bytes} bytes]")
228
+
229
+ combined = base.text + "\n" + "\n".join(out_lines)
230
+ redacted = redact(combined)
231
+ return CouncilContext(
232
+ mode="diff",
233
+ text=_enforce_size(redacted, "diff"),
234
+ manifest=base.manifest + [f"+ surrounding signatures for {len(by_file)} file(s)"],
235
+ )
236
+
237
+
238
+ def bundle_files(paths: list[str | Path]) -> CouncilContext:
239
+ parts: list[str] = []
240
+ manifest: list[str] = []
241
+ excluded: list[str] = []
242
+ for raw_path in paths:
243
+ p = Path(raw_path)
244
+ if not p.exists():
245
+ excluded.append(f"{p} (not found)")
246
+ continue
247
+ try:
248
+ content = p.read_text(encoding="utf-8")
249
+ except (OSError, UnicodeDecodeError) as exc:
250
+ excluded.append(f"{p} ({type(exc).__name__})")
251
+ continue
252
+ parts.append(f"### {p}\n\n{content}\n")
253
+ manifest.append(str(p))
254
+ bundled = "\n".join(parts)
255
+ redacted = redact(bundled)
256
+ return CouncilContext(
257
+ mode="files",
258
+ text=_enforce_size(redacted, "files"),
259
+ manifest=manifest,
260
+ excluded=excluded,
261
+ )
@@ -0,0 +1,381 @@
1
+ """External-AI clients for the council.
2
+
3
+ Mirrors the contract from `scripts/skill_trigger_eval.py`:
4
+ - Tokens come exclusively from ~/.config/agent-config/<provider>.key.
5
+ - File mode must be exactly 0o600. Drift is a hard abort.
6
+ - No environment-variable fallback. No keychain fallback.
7
+ - Real SDKs (`anthropic`, `openai`) are *soft* dependencies — the
8
+ module imports cleanly without them; only `ask()` requires them.
9
+
10
+ Tests inject mock clients via the `client=` constructor argument and
11
+ never hit the real API.
12
+
13
+ Mode contract (Phase 2b):
14
+ - `billable=True` clients (AnthropicClient, OpenAIClient) participate
15
+ in the cost gate — projected USD spend is checked before each call.
16
+ - `billable=False` clients (ManualClient, future PlaywrightClient)
17
+ skip the cost gate entirely. Spend = $0 to us; provider-side rate
18
+ limits are the user's concern.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import stat
24
+ import sys
25
+ import time
26
+ from abc import ABC, abstractmethod
27
+ from dataclasses import dataclass, field
28
+ from pathlib import Path
29
+ from typing import TextIO
30
+
31
+ ANTHROPIC_KEY_PATH = Path.home() / ".config" / "agent-config" / "anthropic.key"
32
+ OPENAI_KEY_PATH = Path.home() / ".config" / "agent-config" / "openai.key"
33
+
34
+ DEFAULT_ANTHROPIC_MODEL = "claude-sonnet-4-5"
35
+ DEFAULT_OPENAI_MODEL = "gpt-4o"
36
+
37
+
38
+ class KeyGateError(RuntimeError):
39
+ """Raised when a provider key file violates the 0600 contract."""
40
+
41
+
42
+ @dataclass
43
+ class CouncilResponse:
44
+ """Normalised output from a single council member."""
45
+
46
+ provider: str
47
+ model: str
48
+ text: str
49
+ input_tokens: int = 0
50
+ output_tokens: int = 0
51
+ latency_ms: int = 0
52
+ error: str | None = None
53
+ metadata: dict[str, object] = field(default_factory=dict)
54
+
55
+
56
+ def _load_key(path: Path, prefix: str, install_script: str) -> str:
57
+ """Shared 0600-gated key loader. Refuses anything outside the contract."""
58
+ if not path.exists():
59
+ raise KeyGateError(
60
+ f"Key not found at {path}.\n"
61
+ f" Install it with: bash {install_script}"
62
+ )
63
+ st = path.stat()
64
+ mode = stat.S_IMODE(st.st_mode)
65
+ if mode != 0o600:
66
+ raise KeyGateError(
67
+ f"Unsafe permissions on {path}: got {oct(mode)}, expected 0o600.\n"
68
+ f" Fix: chmod 600 {path}"
69
+ )
70
+ key = path.read_text(encoding="utf-8").strip()
71
+ if not key:
72
+ raise KeyGateError(f"{path} is empty.")
73
+ if not key.startswith(prefix):
74
+ raise KeyGateError(
75
+ f"{path} does not look like a {prefix!r}-prefixed key."
76
+ )
77
+ return key
78
+
79
+
80
+ def load_anthropic_key(path: Path = ANTHROPIC_KEY_PATH) -> str:
81
+ return _load_key(path, "sk-ant-", "scripts/install_anthropic_key.sh")
82
+
83
+
84
+ def load_openai_key(path: Path = OPENAI_KEY_PATH) -> str:
85
+ return _load_key(path, "sk-", "scripts/install_openai_key.sh")
86
+
87
+
88
+ class ExternalAIClient(ABC):
89
+ """Abstract base for council members."""
90
+
91
+ name: str = ""
92
+ model: str = ""
93
+ billable: bool = True # API-mode subclasses spend money; manual/playwright don't.
94
+
95
+ @abstractmethod
96
+ def ask(
97
+ self,
98
+ system_prompt: str,
99
+ user_prompt: str,
100
+ max_tokens: int = 1024,
101
+ ) -> CouncilResponse:
102
+ """Send one independent query. Must never raise on network/API
103
+ failure — return a `CouncilResponse` with `error` set instead.
104
+ Other members should not be blocked by one failure."""
105
+
106
+
107
+ class AnthropicClient(ExternalAIClient):
108
+ name = "anthropic"
109
+ billable = True
110
+
111
+ def __init__(
112
+ self,
113
+ model: str = DEFAULT_ANTHROPIC_MODEL,
114
+ client: object = None,
115
+ api_key: str | None = None,
116
+ ):
117
+ self.model = model
118
+ if client is not None:
119
+ self._client = client
120
+ return
121
+ if api_key is None:
122
+ raise RuntimeError(
123
+ "AnthropicClient requires explicit api_key or injected client. "
124
+ "Use load_anthropic_key() — no env-var fallback."
125
+ )
126
+ try:
127
+ import anthropic # type: ignore[import-not-found]
128
+ except ImportError as exc: # pragma: no cover - exercised only with real SDK
129
+ raise RuntimeError(
130
+ "anthropic package not installed. `pip install anthropic`."
131
+ ) from exc
132
+ self._client = anthropic.Anthropic(api_key=api_key)
133
+
134
+ def ask(self, system_prompt: str, user_prompt: str, max_tokens: int = 1024) -> CouncilResponse:
135
+ t0 = time.monotonic()
136
+ try:
137
+ response = self._client.messages.create(
138
+ model=self.model,
139
+ max_tokens=max_tokens,
140
+ system=system_prompt,
141
+ messages=[{"role": "user", "content": user_prompt}],
142
+ )
143
+ except Exception as exc: # noqa: BLE001 - normalise all SDK errors
144
+ return CouncilResponse(
145
+ provider=self.name, model=self.model, text="",
146
+ latency_ms=int((time.monotonic() - t0) * 1000),
147
+ error=f"{type(exc).__name__}: {exc}",
148
+ )
149
+ latency_ms = int((time.monotonic() - t0) * 1000)
150
+ text = ""
151
+ content = getattr(response, "content", None)
152
+ if content:
153
+ text = getattr(content[0], "text", "") or ""
154
+ usage = getattr(response, "usage", None)
155
+ return CouncilResponse(
156
+ provider=self.name, model=self.model, text=text,
157
+ input_tokens=getattr(usage, "input_tokens", 0) if usage else 0,
158
+ output_tokens=getattr(usage, "output_tokens", 0) if usage else 0,
159
+ latency_ms=latency_ms,
160
+ )
161
+
162
+
163
+ class OpenAIClient(ExternalAIClient):
164
+ name = "openai"
165
+ billable = True
166
+
167
+ def __init__(
168
+ self,
169
+ model: str = DEFAULT_OPENAI_MODEL,
170
+ client: object = None,
171
+ api_key: str | None = None,
172
+ ):
173
+ self.model = model
174
+ if client is not None:
175
+ self._client = client
176
+ return
177
+ if api_key is None:
178
+ raise RuntimeError(
179
+ "OpenAIClient requires explicit api_key or injected client. "
180
+ "Use load_openai_key() — no env-var fallback."
181
+ )
182
+ try:
183
+ import openai # type: ignore[import-not-found]
184
+ except ImportError as exc: # pragma: no cover - exercised only with real SDK
185
+ raise RuntimeError(
186
+ "openai package not installed. `pip install openai`."
187
+ ) from exc
188
+ self._client = openai.OpenAI(api_key=api_key)
189
+
190
+ def ask(self, system_prompt: str, user_prompt: str, max_tokens: int = 1024) -> CouncilResponse:
191
+ t0 = time.monotonic()
192
+ try:
193
+ response = self._client.chat.completions.create(
194
+ model=self.model,
195
+ max_tokens=max_tokens,
196
+ messages=[
197
+ {"role": "system", "content": system_prompt},
198
+ {"role": "user", "content": user_prompt},
199
+ ],
200
+ )
201
+ except Exception as exc: # noqa: BLE001 - normalise all SDK errors
202
+ return CouncilResponse(
203
+ provider=self.name, model=self.model, text="",
204
+ latency_ms=int((time.monotonic() - t0) * 1000),
205
+ error=f"{type(exc).__name__}: {exc}",
206
+ )
207
+ latency_ms = int((time.monotonic() - t0) * 1000)
208
+ text = ""
209
+ choices = getattr(response, "choices", None)
210
+ if choices:
211
+ msg = getattr(choices[0], "message", None)
212
+ text = getattr(msg, "content", "") if msg else ""
213
+ usage = getattr(response, "usage", None)
214
+ return CouncilResponse(
215
+ provider=self.name, model=self.model, text=text or "",
216
+ input_tokens=getattr(usage, "prompt_tokens", 0) if usage else 0,
217
+ output_tokens=getattr(usage, "completion_tokens", 0) if usage else 0,
218
+ latency_ms=latency_ms,
219
+ )
220
+
221
+
222
+ # ── Manual mode (Phase 2b) ───────────────────────────────────────────
223
+
224
+
225
+ MANUAL_END_MARKER = "END" # line containing only this terminates a paste block.
226
+
227
+
228
+ def _read_until_marker(stream: TextIO, marker: str) -> str:
229
+ """Read lines from `stream` until a line equal to `marker` (after strip).
230
+
231
+ Returns the joined body without the marker line. EOF before the
232
+ marker is treated as end-of-input — the body collected so far is
233
+ returned; callers decide whether that counts as abort.
234
+ """
235
+ body: list[str] = []
236
+ for raw in stream:
237
+ line = raw.rstrip("\n")
238
+ if line.strip() == marker:
239
+ break
240
+ body.append(line)
241
+ return "\n".join(body).strip()
242
+
243
+
244
+ class ManualClient(ExternalAIClient):
245
+ """Copy-paste council member — user is the transport.
246
+
247
+ `ask()` renders the system prompt + artefact as one Markdown block,
248
+ prints it to `stdout`, and reads pasted replies from `stdin`. After
249
+ each pasted reply, surfaces a 1/2/3 menu (more · next · abort) per
250
+ `user-interaction`. Loops until the user picks 2 or 3.
251
+
252
+ Spend is $0 — `billable=False` makes the orchestrator skip the cost
253
+ gate for this member regardless of the price table.
254
+
255
+ Tests inject `stdin` / `stdout` `TextIO` streams. Production usage
256
+ falls back to `sys.stdin` / `sys.stdout`.
257
+ """
258
+
259
+ billable = False
260
+
261
+ def __init__(
262
+ self,
263
+ *,
264
+ name: str = "manual",
265
+ model: str = "manual",
266
+ provider_label: str = "your LLM web UI",
267
+ stdin: TextIO | None = None,
268
+ stdout: TextIO | None = None,
269
+ end_marker: str = MANUAL_END_MARKER,
270
+ ):
271
+ self.name = name
272
+ self.model = model
273
+ self.provider_label = provider_label
274
+ self._stdin = stdin if stdin is not None else sys.stdin
275
+ self._stdout = stdout if stdout is not None else sys.stdout
276
+ self._end_marker = end_marker
277
+
278
+ def ask(
279
+ self,
280
+ system_prompt: str,
281
+ user_prompt: str,
282
+ max_tokens: int = 1024, # noqa: ARG002 — accepted for ABC parity
283
+ ) -> CouncilResponse:
284
+ t0 = time.monotonic()
285
+ rounds: list[str] = []
286
+ block = self._render_block(system_prompt, user_prompt, follow_up=None)
287
+ self._emit(block)
288
+
289
+ try:
290
+ while True:
291
+ reply = _read_until_marker(self._stdin, self._end_marker)
292
+ rounds.append(reply)
293
+ choice = self._ask_menu(reply_chars=len(reply))
294
+
295
+ if choice == "2": # done with this member
296
+ break
297
+ if choice == "3": # abort the council run
298
+ return CouncilResponse(
299
+ provider=self.name, model=self.model, text="",
300
+ latency_ms=int((time.monotonic() - t0) * 1000),
301
+ error="manual_aborted",
302
+ metadata={"rounds": len(rounds), "manual": True},
303
+ )
304
+ # choice == "1": collect follow-up, re-emit context block.
305
+ follow_up = self._read_follow_up()
306
+ if not follow_up:
307
+ break # empty follow-up → treat as "done with this member"
308
+ rounds.append(f"[follow-up sent]\n{follow_up}")
309
+ block = self._render_block(system_prompt, user_prompt, follow_up=follow_up)
310
+ self._emit(block)
311
+ except Exception as exc: # noqa: BLE001 — never break the council on a stdin glitch
312
+ return CouncilResponse(
313
+ provider=self.name, model=self.model, text="\n\n".join(rounds),
314
+ latency_ms=int((time.monotonic() - t0) * 1000),
315
+ error=f"{type(exc).__name__}: {exc}",
316
+ metadata={"rounds": len(rounds), "manual": True},
317
+ )
318
+
319
+ text = "\n\n---\n\n".join(rounds).strip()
320
+ return CouncilResponse(
321
+ provider=self.name, model=self.model, text=text,
322
+ latency_ms=int((time.monotonic() - t0) * 1000),
323
+ metadata={"rounds": len(rounds), "manual": True},
324
+ )
325
+
326
+ # ── helpers ──────────────────────────────────────────────────────
327
+
328
+ def _emit(self, text: str) -> None:
329
+ self._stdout.write(text)
330
+ self._stdout.write("\n")
331
+ self._stdout.flush()
332
+
333
+ def _render_block(
334
+ self,
335
+ system_prompt: str,
336
+ user_prompt: str,
337
+ *,
338
+ follow_up: str | None,
339
+ ) -> str:
340
+ bar = "═" * 67
341
+ head = (
342
+ f"{bar}\n"
343
+ f"Manual council member: {self.provider_label}\n"
344
+ f"Paste this block into the web UI · then paste the reply below.\n"
345
+ f"{bar}"
346
+ )
347
+ if follow_up is not None:
348
+ body = (
349
+ f"[Follow-up — paste this into the SAME chat thread]\n\n"
350
+ f"{follow_up}"
351
+ )
352
+ else:
353
+ body = f"{system_prompt}\n\n---\n\n{user_prompt}"
354
+ tail = (
355
+ f"{bar}\n"
356
+ f"End your pasted reply with a line containing only: {self._end_marker}\n"
357
+ f"{bar}"
358
+ )
359
+ return f"{head}\n\n{body}\n\n{tail}"
360
+
361
+ def _ask_menu(self, *, reply_chars: int) -> str:
362
+ prompt = (
363
+ f"\nReply received ({reply_chars} chars). Now what?\n"
364
+ f" 1. More feedback for this member (continue this thread)\n"
365
+ f" 2. Done with this member, move to the next\n"
366
+ f" 3. Abort the council run\n\n"
367
+ f"Choose 1/2/3: "
368
+ )
369
+ self._stdout.write(prompt)
370
+ self._stdout.flush()
371
+ line = self._stdin.readline().strip()
372
+ if line in {"1", "2", "3"}:
373
+ return line
374
+ # unknown input → treat as "next" so we never block forever in tests / piped runs.
375
+ return "2"
376
+
377
+ def _read_follow_up(self) -> str:
378
+ self._emit(
379
+ f"\nType your follow-up question, end with a line containing only: {self._end_marker}"
380
+ )
381
+ return _read_until_marker(self._stdin, self._end_marker)