@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.
- package/.agent-src/commands/bug-fix.md +1 -1
- package/.agent-src/commands/bug-investigate.md +2 -2
- package/.agent-src/commands/chat-history-checkpoint.md +1 -1
- package/.agent-src/commands/chat-history-clear.md +1 -1
- package/.agent-src/commands/chat-history.md +1 -1
- package/.agent-src/commands/check-current-md.md +1 -1
- package/.agent-src/commands/council-design.md +96 -0
- package/.agent-src/commands/council-optimize.md +115 -0
- package/.agent-src/commands/council-pr.md +123 -0
- package/.agent-src/commands/council.md +219 -0
- package/.agent-src/commands/create-pr.md +23 -0
- package/.agent-src/commands/do-and-judge.md +3 -3
- package/.agent-src/commands/do-in-steps.md +4 -4
- package/.agent-src/commands/e2e-heal.md +1 -1
- package/.agent-src/commands/e2e-plan.md +1 -1
- package/.agent-src/commands/feature-dev.md +8 -0
- package/.agent-src/commands/feature-explore.md +6 -1
- package/.agent-src/commands/feature-plan.md +33 -2
- package/.agent-src/commands/feature-refactor.md +5 -0
- package/.agent-src/commands/feature-roadmap.md +6 -1
- package/.agent-src/commands/feature.md +58 -0
- package/.agent-src/commands/fix-ci.md +5 -0
- package/.agent-src/commands/fix-portability.md +5 -0
- package/.agent-src/commands/fix-pr-bot-comments.md +5 -0
- package/.agent-src/commands/fix-pr-comments.md +5 -0
- package/.agent-src/commands/fix-pr-developer-comments.md +5 -0
- package/.agent-src/commands/fix-references.md +5 -0
- package/.agent-src/commands/fix-seeder.md +5 -0
- package/.agent-src/commands/fix.md +60 -0
- package/.agent-src/commands/jira-ticket.md +1 -1
- package/.agent-src/commands/judge.md +1 -1
- package/.agent-src/commands/memory-add.md +3 -3
- package/.agent-src/commands/memory-full.md +2 -2
- package/.agent-src/commands/memory-promote.md +2 -2
- package/.agent-src/commands/mode.md +5 -5
- package/.agent-src/commands/onboard.md +3 -3
- package/.agent-src/commands/optimize-agents.md +6 -1
- package/.agent-src/commands/optimize-augmentignore.md +5 -0
- package/.agent-src/commands/optimize-rtk-filters.md +5 -0
- package/.agent-src/commands/optimize-skills.md +6 -1
- package/.agent-src/commands/optimize.md +54 -0
- package/.agent-src/commands/propose-memory.md +2 -2
- package/.agent-src/commands/review-changes.md +26 -1
- package/.agent-src/commands/review-routing.md +1 -1
- package/.agent-src/commands/roadmap-create.md +29 -2
- package/.agent-src/commands/set-cost-profile.md +3 -3
- package/.agent-src/commands/sync-agent-settings.md +2 -2
- package/.agent-src/commands/tests-create.md +1 -1
- package/.agent-src/commands/upstream-contribute.md +1 -1
- package/.agent-src/contexts/authority/commit-mechanics.md +57 -0
- package/.agent-src/contexts/authority/destructive-mechanics.md +66 -0
- package/.agent-src/contexts/authority/scope-mechanics.md +87 -0
- package/.agent-src/contexts/execution/autonomy-detection.md +54 -0
- package/.agent-src/contexts/execution/autonomy-examples.md +90 -0
- package/.agent-src/contexts/execution/autonomy-mechanics.md +29 -0
- package/.agent-src/contexts/execution/verification-mechanics.md +80 -0
- package/.agent-src/personas/README.md +1 -1
- package/.agent-src/rules/agent-authority.md +24 -0
- package/.agent-src/rules/architecture.md +1 -1
- package/.agent-src/rules/artifact-drafting-protocol.md +1 -1
- package/.agent-src/rules/artifact-engagement-recording.md +1 -1
- package/.agent-src/rules/ask-when-uncertain.md +1 -1
- package/.agent-src/rules/autonomous-execution.md +78 -114
- package/.agent-src/rules/capture-learnings.md +1 -1
- package/.agent-src/rules/chat-history-cadence.md +3 -3
- package/.agent-src/rules/chat-history-ownership.md +3 -3
- package/.agent-src/rules/chat-history-visibility.md +3 -3
- package/.agent-src/rules/{command-suggestion.md → command-suggestion-policy.md} +7 -7
- package/.agent-src/rules/commit-conventions.md +1 -1
- package/.agent-src/rules/commit-policy.md +14 -42
- package/.agent-src/rules/context-hygiene.md +3 -3
- package/.agent-src/rules/direct-answers.md +1 -1
- package/.agent-src/rules/docs-sync.md +1 -1
- package/.agent-src/rules/e2e-testing.md +1 -1
- package/.agent-src/rules/guidelines.md +4 -4
- package/.agent-src/rules/improve-before-implement.md +2 -2
- package/.agent-src/rules/language-and-tone.md +37 -96
- package/.agent-src/rules/minimal-safe-diff.md +3 -3
- package/.agent-src/rules/model-recommendation.md +4 -4
- package/.agent-src/rules/no-cheap-questions.md +89 -0
- package/.agent-src/rules/non-destructive-by-default.md +15 -49
- package/.agent-src/rules/onboarding-gate.md +5 -5
- package/.agent-src/rules/review-routing-awareness.md +9 -9
- package/.agent-src/rules/roadmap-progress-sync.md +26 -33
- package/.agent-src/rules/role-mode-adherence.md +2 -2
- package/.agent-src/rules/scope-control.md +65 -46
- package/.agent-src/rules/security-sensitive-stop.md +2 -2
- package/.agent-src/rules/size-enforcement.md +1 -1
- package/.agent-src/rules/think-before-action.md +5 -5
- package/.agent-src/rules/token-efficiency.md +4 -4
- package/.agent-src/rules/{ui-audit-before-build.md → ui-audit-gate.md} +3 -3
- package/.agent-src/rules/user-interaction.md +3 -3
- package/.agent-src/rules/verify-before-complete.md +12 -67
- package/.agent-src/scripts/update_roadmap_progress.py +9 -4
- package/.agent-src/skills/ai-council/SKILL.md +333 -0
- package/.agent-src/skills/api-endpoint/SKILL.md +2 -2
- package/.agent-src/skills/blade-ui/SKILL.md +1 -1
- package/.agent-src/skills/blast-radius-analyzer/SKILL.md +1 -1
- package/.agent-src/skills/bug-analyzer/SKILL.md +1 -1
- package/.agent-src/skills/command-routing/SKILL.md +1 -1
- package/.agent-src/skills/command-writing/SKILL.md +1 -1
- package/.agent-src/skills/conventional-commits-writing/SKILL.md +1 -1
- package/.agent-src/skills/copilot-agents-optimization/SKILL.md +2 -2
- package/.agent-src/skills/developer-like-execution/SKILL.md +2 -2
- package/.agent-src/skills/flux/SKILL.md +1 -1
- package/.agent-src/skills/git-workflow/SKILL.md +1 -1
- package/.agent-src/skills/guideline-writing/SKILL.md +11 -11
- package/.agent-src/skills/learning-to-rule-or-skill/SKILL.md +4 -4
- package/.agent-src/skills/livewire/SKILL.md +1 -1
- package/.agent-src/skills/override-management/SKILL.md +2 -2
- package/.agent-src/skills/php-coder/SKILL.md +1 -1
- package/.agent-src/skills/playwright-testing/SKILL.md +2 -2
- package/.agent-src/skills/readme-reviewer/SKILL.md +1 -1
- package/.agent-src/skills/readme-writing/SKILL.md +1 -1
- package/.agent-src/skills/readme-writing-package/SKILL.md +1 -1
- package/.agent-src/skills/receiving-code-review/SKILL.md +1 -1
- package/.agent-src/skills/review-routing/SKILL.md +2 -2
- package/.agent-src/skills/rule-writing/SKILL.md +1 -1
- package/.agent-src/skills/skill-reviewer/SKILL.md +1 -1
- package/.agent-src/skills/skill-writing/SKILL.md +3 -3
- package/.agent-src/skills/subagent-orchestration/SKILL.md +1 -0
- package/.agent-src/skills/systematic-debugging/SKILL.md +1 -1
- package/.agent-src/skills/upstream-contribute/SKILL.md +1 -1
- package/.agent-src/skills/validate-feature-fit/SKILL.md +2 -2
- package/.agent-src/skills/{verify-before-complete → verify-completion-evidence}/SKILL.md +2 -2
- package/.agent-src/templates/agent-settings.md +8 -8
- package/.agent-src/templates/contexts/auth-model.md +1 -1
- package/.agent-src/templates/scripts/README.md +2 -2
- package/.agent-src/templates/scripts/telemetry/aggregator.py +16 -1
- package/.agent-src/templates/scripts/telemetry/engagement.py +59 -0
- package/.agent-src/templates/scripts/telemetry/report_renderer.py +28 -1
- package/.agent-src/templates/scripts/telemetry_record.py +14 -1
- package/.claude-plugin/marketplace.json +10 -2
- package/AGENTS.md +11 -9
- package/CHANGELOG.md +123 -1
- package/README.md +28 -30
- package/config/agent-settings.template.yml +58 -1
- package/config/gitignore-block.txt +3 -0
- package/docs/architecture.md +4 -4
- package/docs/catalog.md +331 -0
- package/docs/contracts/STABILITY.md +39 -0
- package/docs/contracts/adr-command-suggestion.md +3 -3
- package/docs/contracts/adr-product-ui-track.md +2 -2
- package/docs/contracts/agent-memory-contract.md +2 -2
- package/docs/contracts/artifact-engagement-flow.md +1 -1
- package/docs/contracts/command-clusters.md +2 -2
- package/docs/contracts/command-suggestion-flow.md +3 -3
- package/docs/contracts/implement-ticket-flow.md +2 -2
- package/docs/contracts/linear-ai-rules-inclusion.md +1 -1
- package/docs/contracts/load-context-schema.md +186 -0
- package/docs/contracts/rule-interactions.yml +96 -0
- package/docs/contracts/rule-priority-hierarchy.md +87 -0
- package/docs/contracts/ui-track-flow.md +1 -1
- package/docs/customization.md +14 -0
- package/docs/end-to-end-walkthroughs.md +165 -0
- package/docs/getting-started.md +26 -8
- package/docs/github-topics.md +12 -3
- package/docs/guidelines/agent-infra/language-and-tone-examples.md +79 -0
- package/{.agent-src → docs}/guidelines/docs/readme-size-and-splitting.md +26 -25
- package/docs/guidelines/php/git.md +164 -0
- package/docs/migrations/commands-1.15.0.md +1 -1
- package/docs/showcase.md +9 -4
- package/docs/skills-catalog.md +14 -8
- package/docs/ui-track-mental-model.md +2 -2
- package/llms.txt +13 -7
- package/package.json +1 -1
- package/scripts/agent-config +23 -0
- package/scripts/ai_council/__init__.py +39 -0
- package/scripts/ai_council/_default_prices.py +41 -0
- package/scripts/ai_council/_one_off_rebalancing_audit.py +149 -0
- package/scripts/ai_council/_one_off_roundtrip.py +106 -0
- package/scripts/ai_council/budget_guard.py +172 -0
- package/scripts/ai_council/bundler.py +261 -0
- package/scripts/ai_council/clients.py +381 -0
- package/scripts/ai_council/modes.py +127 -0
- package/scripts/ai_council/orchestrator.py +350 -0
- package/scripts/ai_council/pricing.py +213 -0
- package/scripts/ai_council/project_context.py +159 -0
- package/scripts/ai_council/prompts.py +232 -0
- package/scripts/ai_council/session.py +144 -0
- package/scripts/check_always_budget.py +126 -0
- package/scripts/check_augmentignore.py +69 -0
- package/scripts/check_command_count_messaging.py +120 -0
- package/scripts/check_portability.py +55 -0
- package/scripts/check_public_catalog_links.py +122 -0
- package/scripts/check_references.py +4 -1
- package/scripts/check_roadmap_trackable.py +111 -0
- package/scripts/command_suggester/cooldown.py +1 -1
- package/scripts/generate_index.py +266 -0
- package/scripts/install_anthropic_key.sh +5 -0
- package/scripts/install_openai_key.sh +106 -0
- package/scripts/lint_load_context.py +163 -0
- package/scripts/schemas/command.schema.json +20 -0
- package/scripts/schemas/rule.schema.json +10 -0
- package/scripts/skill_linter.py +12 -4
- package/scripts/sync_agent_settings.py +1 -1
- package/scripts/update_counts.py +9 -4
- package/scripts/update_prices.py +124 -0
- package/.agent-src/guidelines/php/git.md +0 -96
- /package/.agent-src/rules/{slash-commands.md → slash-command-routing-policy.md} +0 -0
- /package/{.agent-src → docs}/guidelines/agent-infra/agent-interaction-and-decision-quality.md +0 -0
- /package/{.agent-src → docs}/guidelines/agent-infra/break-glass-usage.md +0 -0
- /package/{.agent-src → docs}/guidelines/agent-infra/developer-judgment.md +0 -0
- /package/{.agent-src → docs}/guidelines/agent-infra/engineering-memory-data-format.md +0 -0
- /package/{.agent-src → docs}/guidelines/agent-infra/layered-settings.md +0 -0
- /package/{.agent-src → docs}/guidelines/agent-infra/memory-access.md +0 -0
- /package/{.agent-src → docs}/guidelines/agent-infra/naming.md +0 -0
- /package/{.agent-src → docs}/guidelines/agent-infra/output-patterns.md +0 -0
- /package/{.agent-src → docs}/guidelines/agent-infra/review-routing-data-format.md +0 -0
- /package/{.agent-src → docs}/guidelines/agent-infra/role-contracts.md +0 -0
- /package/{.agent-src → docs}/guidelines/agent-infra/role-mode-router.md +0 -0
- /package/{.agent-src → docs}/guidelines/agent-infra/runtime-layer.md +0 -0
- /package/{.agent-src → docs}/guidelines/agent-infra/self-improvement-pipeline.md +0 -0
- /package/{.agent-src → docs}/guidelines/agent-infra/size-and-scope.md +0 -0
- /package/{.agent-src → docs}/guidelines/agent-infra/tool-integration.md +0 -0
- /package/{.agent-src → docs}/guidelines/e2e/playwright.md +0 -0
- /package/{.agent-src → docs}/guidelines/php/api-design.md +0 -0
- /package/{.agent-src → docs}/guidelines/php/artisan-commands.md +0 -0
- /package/{.agent-src → docs}/guidelines/php/blade-ui.md +0 -0
- /package/{.agent-src → docs}/guidelines/php/controllers.md +0 -0
- /package/{.agent-src → docs}/guidelines/php/database.md +0 -0
- /package/{.agent-src → docs}/guidelines/php/eloquent.md +0 -0
- /package/{.agent-src → docs}/guidelines/php/flux.md +0 -0
- /package/{.agent-src → docs}/guidelines/php/general.md +0 -0
- /package/{.agent-src → docs}/guidelines/php/jobs.md +0 -0
- /package/{.agent-src → docs}/guidelines/php/livewire.md +0 -0
- /package/{.agent-src → docs}/guidelines/php/logging.md +0 -0
- /package/{.agent-src → docs}/guidelines/php/naming.md +0 -0
- /package/{.agent-src → docs}/guidelines/php/patterns/dependency-injection.md +0 -0
- /package/{.agent-src → docs}/guidelines/php/patterns/dtos.md +0 -0
- /package/{.agent-src → docs}/guidelines/php/patterns/events.md +0 -0
- /package/{.agent-src → docs}/guidelines/php/patterns/factory.md +0 -0
- /package/{.agent-src → docs}/guidelines/php/patterns/pipelines.md +0 -0
- /package/{.agent-src → docs}/guidelines/php/patterns/policies.md +0 -0
- /package/{.agent-src → docs}/guidelines/php/patterns/repositories.md +0 -0
- /package/{.agent-src → docs}/guidelines/php/patterns/service-layer.md +0 -0
- /package/{.agent-src → docs}/guidelines/php/patterns/strategy.md +0 -0
- /package/{.agent-src → docs}/guidelines/php/patterns.md +0 -0
- /package/{.agent-src → docs}/guidelines/php/performance.md +0 -0
- /package/{.agent-src → docs}/guidelines/php/resources.md +0 -0
- /package/{.agent-src → docs}/guidelines/php/security.md +0 -0
- /package/{.agent-src → docs}/guidelines/php/sql.md +0 -0
- /package/{.agent-src → docs}/guidelines/php/validations.md +0 -0
- /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)
|