briar-cli 1.1.1__py3-none-any.whl

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 (179) hide show
  1. briar/__init__.py +8 -0
  2. briar/__main__.py +11 -0
  3. briar/_registry.py +36 -0
  4. briar/agent/__init__.py +16 -0
  5. briar/agent/_llm.py +90 -0
  6. briar/agent/_llms/__init__.py +40 -0
  7. briar/agent/_llms/anthropic_llm.py +185 -0
  8. briar/agent/_llms/bedrock.py +154 -0
  9. briar/agent/_llms/gemini.py +152 -0
  10. briar/agent/_llms/openai_llm.py +129 -0
  11. briar/agent/runner.py +364 -0
  12. briar/agent/tools.py +355 -0
  13. briar/auth/__init__.py +42 -0
  14. briar/auth/_acquirer.py +121 -0
  15. briar/auth/_acquirers/__init__.py +62 -0
  16. briar/auth/_acquirers/aws_sso.py +185 -0
  17. briar/auth/_acquirers/aws_static.py +54 -0
  18. briar/auth/_acquirers/bitbucket.py +60 -0
  19. briar/auth/_acquirers/github_device.py +129 -0
  20. briar/auth/_acquirers/github_pat.py +44 -0
  21. briar/auth/_acquirers/infisical.py +80 -0
  22. briar/auth/_acquirers/jira_session.py +102 -0
  23. briar/auth/_acquirers/jira_token.py +61 -0
  24. briar/auth/_acquirers/linear.py +46 -0
  25. briar/auth/_prompt.py +155 -0
  26. briar/cli.py +149 -0
  27. briar/commands/__init__.py +48 -0
  28. briar/commands/agent.py +692 -0
  29. briar/commands/auth.py +281 -0
  30. briar/commands/base.py +46 -0
  31. briar/commands/context.py +143 -0
  32. briar/commands/dashboard.py +61 -0
  33. briar/commands/extract.py +79 -0
  34. briar/commands/iac.py +44 -0
  35. briar/commands/runbook.py +110 -0
  36. briar/commands/secrets.py +165 -0
  37. briar/commands/version.py +17 -0
  38. briar/credentials/__init__.py +48 -0
  39. briar/credentials/_bootstrap.py +93 -0
  40. briar/credentials/_bootstraps/__init__.py +80 -0
  41. briar/credentials/_bootstraps/infisical.py +115 -0
  42. briar/credentials/_store.py +63 -0
  43. briar/credentials/aws_secrets.py +102 -0
  44. briar/credentials/envfile.py +171 -0
  45. briar/credentials/infisical.py +183 -0
  46. briar/credentials/ssm.py +83 -0
  47. briar/credentials/vault.py +109 -0
  48. briar/dashboard/__init__.py +13 -0
  49. briar/dashboard/collectors.py +1354 -0
  50. briar/dashboard/server.py +154 -0
  51. briar/dashboard/templates/index.html +678 -0
  52. briar/decorators.py +48 -0
  53. briar/env_vars.py +92 -0
  54. briar/error_policy.py +273 -0
  55. briar/errors.py +48 -0
  56. briar/extract/__init__.py +52 -0
  57. briar/extract/_cloud.py +157 -0
  58. briar/extract/_clouds/__init__.py +39 -0
  59. briar/extract/_clouds/aws.py +190 -0
  60. briar/extract/_clouds/azure.py +152 -0
  61. briar/extract/_clouds/gcp.py +135 -0
  62. briar/extract/_gh.py +159 -0
  63. briar/extract/_provider.py +218 -0
  64. briar/extract/_providers/__init__.py +60 -0
  65. briar/extract/_providers/bitbucket.py +276 -0
  66. briar/extract/_providers/github.py +277 -0
  67. briar/extract/_tracker.py +124 -0
  68. briar/extract/_trackers/__init__.py +44 -0
  69. briar/extract/_trackers/_jira_auth.py +258 -0
  70. briar/extract/_trackers/bitbucket.py +131 -0
  71. briar/extract/_trackers/github_issues.py +139 -0
  72. briar/extract/_trackers/jira.py +191 -0
  73. briar/extract/_trackers/linear.py +172 -0
  74. briar/extract/_user_filter.py +150 -0
  75. briar/extract/active_tickets.py +71 -0
  76. briar/extract/active_work.py +87 -0
  77. briar/extract/aws_infra.py +79 -0
  78. briar/extract/aws_services/__init__.py +31 -0
  79. briar/extract/aws_services/base.py +25 -0
  80. briar/extract/aws_services/ecs.py +43 -0
  81. briar/extract/aws_services/lambda_.py +38 -0
  82. briar/extract/aws_services/logs.py +39 -0
  83. briar/extract/aws_services/rds.py +35 -0
  84. briar/extract/aws_services/sqs.py +25 -0
  85. briar/extract/base.py +290 -0
  86. briar/extract/code_hotspots.py +134 -0
  87. briar/extract/codebase_conventions.py +72 -0
  88. briar/extract/composer.py +85 -0
  89. briar/extract/github_deployments.py +106 -0
  90. briar/extract/language_detectors/__init__.py +24 -0
  91. briar/extract/language_detectors/base.py +29 -0
  92. briar/extract/language_detectors/go.py +26 -0
  93. briar/extract/language_detectors/node.py +42 -0
  94. briar/extract/language_detectors/python.py +41 -0
  95. briar/extract/pr_archaeology.py +131 -0
  96. briar/extract/pr_review_context.py +123 -0
  97. briar/extract/reviewer_profile.py +141 -0
  98. briar/extract/ticket_archaeology.py +115 -0
  99. briar/extract/ticket_context.py +95 -0
  100. briar/formatting/__init__.py +67 -0
  101. briar/formatting/base.py +25 -0
  102. briar/formatting/csv.py +34 -0
  103. briar/formatting/json.py +19 -0
  104. briar/formatting/quiet.py +29 -0
  105. briar/formatting/table.py +97 -0
  106. briar/formatting/yaml.py +35 -0
  107. briar/iac/__init__.py +18 -0
  108. briar/iac/config_file.py +114 -0
  109. briar/iac/models.py +232 -0
  110. briar/iac/reference_map.py +33 -0
  111. briar/iac/runbook/__init__.py +32 -0
  112. briar/iac/runbook/executor.py +365 -0
  113. briar/iac/runbook/models.py +156 -0
  114. briar/iac/runbook/scheduler.py +187 -0
  115. briar/iac/scaffold/__init__.py +25 -0
  116. briar/iac/scaffold/_composer.py +308 -0
  117. briar/iac/scaffold/_knowledge.py +119 -0
  118. briar/iac/scaffold/archetypes/__init__.py +26 -0
  119. briar/iac/scaffold/archetypes/base.py +85 -0
  120. briar/iac/scaffold/archetypes/engineer.py +64 -0
  121. briar/iac/scaffold/archetypes/pr_ci_fixer.py +100 -0
  122. briar/iac/scaffold/archetypes/pr_conflict_resolver.py +83 -0
  123. briar/iac/scaffold/archetypes/pr_fixer.py +62 -0
  124. briar/iac/scaffold/archetypes/triager.py +50 -0
  125. briar/iac/scaffold/base.py +19 -0
  126. briar/iac/scaffold/implementation.py +59 -0
  127. briar/iac/scaffold/pr_fixes.py +52 -0
  128. briar/iac/scaffold/rules/__init__.py +64 -0
  129. briar/iac/scaffold/rules/base.py +121 -0
  130. briar/iac/scaffold/rules/commit_as_human.md +22 -0
  131. briar/iac/scaffold/rules/minimum_correct_fix.md +20 -0
  132. briar/iac/scaffold/rules/no_force_push.md +19 -0
  133. briar/iac/scaffold/rules/no_new_pr_creation.md +16 -0
  134. briar/iac/scaffold/rules/no_workflow_file_edits.md +21 -0
  135. briar/iac/scaffold/rules/read_all_comments_first.md +20 -0
  136. briar/iac/scaffold/rules/skip_approved_green_prs.md +17 -0
  137. briar/iac/scaffold/shapes/__init__.py +38 -0
  138. briar/iac/scaffold/shapes/base.py +17 -0
  139. briar/iac/scaffold/shapes/one_shot.py +48 -0
  140. briar/iac/scaffold/shapes/plan_approve_act.py +134 -0
  141. briar/iac/scaffold/shapes/triage.py +49 -0
  142. briar/iac/scaffold/sources/__init__.py +26 -0
  143. briar/iac/scaffold/sources/aws.py +69 -0
  144. briar/iac/scaffold/sources/base.py +61 -0
  145. briar/iac/scaffold/sources/bitbucket.py +164 -0
  146. briar/iac/scaffold/sources/github.py +155 -0
  147. briar/iac/scaffold/sources/jira.py +147 -0
  148. briar/iac/scaffold/triggers/__init__.py +23 -0
  149. briar/iac/scaffold/triggers/base.py +24 -0
  150. briar/iac/scaffold/triggers/bitbucket_webhook.py +54 -0
  151. briar/iac/scaffold/triggers/github_webhook.py +52 -0
  152. briar/iac/scaffold/triggers/manual.py +16 -0
  153. briar/iac/scaffold/triggers/schedule_cron.py +37 -0
  154. briar/log_context.py +73 -0
  155. briar/logging.py +68 -0
  156. briar/messaging/__init__.py +66 -0
  157. briar/messaging/_writer.py +90 -0
  158. briar/messaging/bitbucket_pr_comment.py +93 -0
  159. briar/messaging/github_pr_comment.py +90 -0
  160. briar/messaging/jira_comment.py +63 -0
  161. briar/messaging/jira_transition.py +71 -0
  162. briar/messaging/slack_channel.py +73 -0
  163. briar/messaging/telegram_chat.py +68 -0
  164. briar/notify/__init__.py +44 -0
  165. briar/notify/_sink.py +27 -0
  166. briar/notify/email.py +59 -0
  167. briar/notify/pagerduty.py +67 -0
  168. briar/notify/slack.py +49 -0
  169. briar/notify/telegram.py +48 -0
  170. briar/pagination.py +37 -0
  171. briar/settings.py +3 -0
  172. briar/storage/__init__.py +73 -0
  173. briar/storage/base.py +137 -0
  174. briar/storage/file.py +111 -0
  175. briar/storage/postgres.py +353 -0
  176. briar_cli-1.1.1.dist-info/METADATA +1031 -0
  177. briar_cli-1.1.1.dist-info/RECORD +179 -0
  178. briar_cli-1.1.1.dist-info/WHEEL +4 -0
  179. briar_cli-1.1.1.dist-info/entry_points.txt +2 -0
@@ -0,0 +1,692 @@
1
+ """`briar agent` — autonomous agent runner.
2
+
3
+ Two ops today (`prfix`, `implement`); future ops register by adding a
4
+ subclass to `AGENT_OPS`. The dispatcher (`CommandAgent.run`) does a
5
+ registry lookup, NOT an if-chain — same Strategy + Registry shape as
6
+ every other plugin family in the codebase.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import argparse
12
+ import logging
13
+ import shutil
14
+ import subprocess
15
+ import tempfile
16
+ from abc import ABC, abstractmethod
17
+ from pathlib import Path
18
+ from typing import Any, ClassVar, Dict, Tuple
19
+
20
+ from briar._registry import build_registry
21
+ from briar.agent.runner import AgentRunner
22
+ from briar.commands.base import Command
23
+
24
+
25
+ log = logging.getLogger(__name__)
26
+
27
+
28
+ # ─── AgentOp Strategy + Registry ────────────────────────────────────────────
29
+ #
30
+ # Each op owns its own `add_arguments` + `run`. CommandAgent.add_arguments
31
+ # iterates AGENT_OPS to attach subparsers; CommandAgent.run does a single
32
+ # registry lookup. Adding a new op = one subclass + one entry in AGENT_OPS.
33
+
34
+
35
+ class AgentOp(ABC):
36
+ """One agent subcommand. Concrete subclasses (`PrfixOp`,
37
+ `ImplementOp`, …) declare the per-op argparse flags and run logic."""
38
+
39
+ name: ClassVar[str] = ""
40
+ help: ClassVar[str] = ""
41
+
42
+ @abstractmethod
43
+ def add_arguments(self, parser: argparse.ArgumentParser) -> None:
44
+ ...
45
+
46
+ @abstractmethod
47
+ def run(self, agent_cmd: "CommandAgent", args: argparse.Namespace) -> int:
48
+ ...
49
+
50
+
51
+ class PrfixOp(AgentOp):
52
+ name = "prfix"
53
+ help = "Address open review comments on a PR (pr-fixer archetype)."
54
+
55
+ def add_arguments(self, parser: argparse.ArgumentParser) -> None:
56
+ parser.add_argument("--company", required=True, help="Company key — must match a runbook YAML")
57
+ parser.add_argument("--owner", required=True, help="GitHub owner of the target repo")
58
+ parser.add_argument("--repo", required=True, help="GitHub repo name")
59
+ parser.add_argument("--pr", type=int, required=True, help="PR number to address")
60
+ parser.add_argument("--branch", required=True, help="PR head branch name")
61
+ parser.add_argument("--store", default="postgres", choices=["file", "postgres"], help="KnowledgeStore backend")
62
+ parser.add_argument("--knowledge", default="./knowledge", help="File-store root (ignored for postgres)")
63
+ parser.add_argument("--model", default="", help="Override Anthropic model (defaults to AgentRunner.DEFAULT_MODEL)")
64
+ parser.add_argument("--max-iter", type=int, default=0, help="Iteration ceiling (defaults to AgentRunner.DEFAULT_MAX_ITERATIONS)")
65
+ parser.add_argument("--git-user-name", default="", help="git config user.name on the worktree. If empty: falls back to the runbook YAML's company.git_identity.name, then to `iklobato`.")
66
+ parser.add_argument("--git-user-email", default="", help="git config user.email on the worktree. If empty: falls back to the runbook YAML's company.git_identity.email, then to `iklobato@gmail.com`.")
67
+ parser.add_argument("--keep-worktree", action="store_true", help="Leave the worktree in /tmp after the run for inspection")
68
+ parser.add_argument(
69
+ "--dry-run",
70
+ action="store_true",
71
+ help="Build + print the system prompt + user message + tool list, skip the LLM call. "
72
+ "Validates the JIT context wiring (pr-review-context) without spending tokens.",
73
+ )
74
+ parser.add_argument(
75
+ "--runbook",
76
+ default="",
77
+ help="Optional runbook YAML to read this company's `messages:` block from. "
78
+ "When set, the agent gets a `send_message` tool bound to the configured channels "
79
+ "instead of having to shell out via `gh` / `curl`.",
80
+ )
81
+
82
+ def run(self, agent_cmd: "CommandAgent", args: argparse.Namespace) -> int:
83
+ return agent_cmd._run_prfix(args)
84
+
85
+
86
+ class ImplementOp(AgentOp):
87
+ name = "implement"
88
+ help = "Implement one ticket end-to-end (engineer archetype). Clones default branch, fetches ticket-context, runs the agent."
89
+
90
+ def add_arguments(self, parser: argparse.ArgumentParser) -> None:
91
+ parser.add_argument("--company", required=True, help="Company key — must match a runbook YAML")
92
+ parser.add_argument("--owner", required=True, help="Repository owner (GitHub) or workspace (Bitbucket)")
93
+ parser.add_argument("--repo", required=True, help="Repository name / slug")
94
+ parser.add_argument("--ticket-project", required=True, help="Tracker project key (Jira: PROJ; Linear team: ENG; GH/BB Issues: owner/repo)")
95
+ parser.add_argument("--ticket-key", required=True, help="Ticket identifier (Jira: PROJ-123; GH/BB: #42; Linear: ENG-7)")
96
+ parser.add_argument("--tracker", default="jira", help="Tracker provider for the ticket (default: jira). One of: jira, github-issues, bitbucket-issues, linear.")
97
+ parser.add_argument("--provider", default="github", help="Repository provider (default: github). One of: github, bitbucket.")
98
+ parser.add_argument("--store", default="postgres", choices=["file", "postgres"], help="KnowledgeStore backend")
99
+ parser.add_argument("--knowledge", default="./knowledge", help="File-store root (ignored for postgres)")
100
+ parser.add_argument("--model", default="", help="Override Anthropic model (defaults to AgentRunner.DEFAULT_MODEL)")
101
+ parser.add_argument("--max-iter", type=int, default=0, help="Iteration ceiling (defaults to AgentRunner.DEFAULT_MAX_ITERATIONS)")
102
+ parser.add_argument("--git-user-name", default="", help="git config user.name on the worktree. If empty: falls back to the runbook YAML's company.git_identity.name, then to `iklobato`.")
103
+ parser.add_argument("--git-user-email", default="", help="git config user.email on the worktree. If empty: falls back to the runbook YAML's company.git_identity.email, then to `iklobato@gmail.com`.")
104
+ parser.add_argument("--keep-worktree", action="store_true", help="Leave the worktree in /tmp after the run for inspection")
105
+ parser.add_argument(
106
+ "--dry-run",
107
+ action="store_true",
108
+ help="Build + print the system prompt + user message + tool list, skip the LLM call. "
109
+ "Validates the JIT context wiring (ticket-context) without spending tokens.",
110
+ )
111
+ parser.add_argument(
112
+ "--runbook",
113
+ default="",
114
+ help="Optional runbook YAML to read this company's `messages:` block from.",
115
+ )
116
+
117
+ def run(self, agent_cmd: "CommandAgent", args: argparse.Namespace) -> int:
118
+ return agent_cmd._run_implement(args)
119
+
120
+
121
+ AGENT_OPS: Dict[str, AgentOp] = build_registry(
122
+ (PrfixOp(), ImplementOp()),
123
+ kind="agent op",
124
+ )
125
+
126
+
127
+ # ─── RepoCloner Strategy + Registry ─────────────────────────────────────────
128
+ #
129
+ # Per-provider git clone + per-provider PR-creation instructions. Adding a
130
+ # new repo provider (GitLab, Gitea, …) = one RepoCloner subclass + one
131
+ # entry in REPO_CLONERS. Zero edits to _run_implement or _clone_default
132
+ # call sites.
133
+
134
+
135
+ class RepoCloner(ABC):
136
+ """Vendor-specific git clone over HTTPS with a token embedded in
137
+ the URL (the standard CI/headless pattern when there's no git
138
+ credential helper). Also owns the PR-creation recipe the agent's
139
+ instruction string includes — different vendors have different
140
+ CLIs / APIs."""
141
+
142
+ kind: ClassVar[str] = ""
143
+
144
+ @abstractmethod
145
+ def resolve_token(self, *, company: str) -> str:
146
+ """Return the credential string to embed in the clone URL,
147
+ or empty when not configured. Caller logs + bails on empty."""
148
+
149
+ @abstractmethod
150
+ def clone_url(self, owner: str, repo: str) -> str:
151
+ """Canonical HTTPS clone URL (no auth embedded)."""
152
+
153
+ @abstractmethod
154
+ def authed_clone_url(self, owner: str, repo: str, token: str) -> str:
155
+ """The URL the actual `git clone` call uses, with the token
156
+ embedded per the provider's auth convention."""
157
+
158
+ @abstractmethod
159
+ def pr_creation_recipe(self, *, owner: str, repo: str, branch: str, company: str) -> str:
160
+ """Lines 6-7 of the agent's procedure instructions — how to
161
+ open a draft PR with this provider's tooling."""
162
+
163
+
164
+ class GithubRepoCloner(RepoCloner):
165
+ kind = "github"
166
+
167
+ def resolve_token(self, *, company: str) -> str:
168
+ import os
169
+
170
+ return os.environ.get("GITHUB_TOKEN", "").strip()
171
+
172
+ def clone_url(self, owner: str, repo: str) -> str:
173
+ return f"https://github.com/{owner}/{repo}.git"
174
+
175
+ def authed_clone_url(self, owner: str, repo: str, token: str) -> str:
176
+ # GitHub's HTTPS auth convention: `x-access-token` as the username.
177
+ return f"https://x-access-token:{token}@github.com/{owner}/{repo}.git"
178
+
179
+ def pr_creation_recipe(self, *, owner: str, repo: str, branch: str, company: str) -> str:
180
+ return (
181
+ " 6. Open a draft PR via `gh pr create --draft --title '<key>: <short>' "
182
+ "--body '<plan + test plan + risks>'`.\n"
183
+ " 7. End your output with the PR URL on its own line. No fictitious URLs "
184
+ "— if `gh pr create` fails, surface the error.\n"
185
+ )
186
+
187
+
188
+ class BitbucketRepoCloner(RepoCloner):
189
+ kind = "bitbucket"
190
+
191
+ def resolve_token(self, *, company: str) -> str:
192
+ if not company:
193
+ return ""
194
+ from briar.env_vars import CredEnv
195
+
196
+ return (CredEnv.BITBUCKET_APP_PASSWORD.read(company=company) or "").strip()
197
+
198
+ def clone_url(self, owner: str, repo: str) -> str:
199
+ return f"https://bitbucket.org/{owner}/{repo}.git"
200
+
201
+ def authed_clone_url(self, owner: str, repo: str, token: str) -> str:
202
+ # Bitbucket's workspace-token auth convention: `x-token-auth`.
203
+ return f"https://x-token-auth:{token}@bitbucket.org/{owner}/{repo}.git"
204
+
205
+ def pr_creation_recipe(self, *, owner: str, repo: str, branch: str, company: str) -> str:
206
+ env_token = f"BITBUCKET_{company.upper().replace('-', '_')}_APP_PASSWORD"
207
+ return (
208
+ " 6. Open a draft PR via the Bitbucket v2 API. The workspace access token is in env var "
209
+ f"`{env_token}`. Auth: `-u 'x-token-auth:${env_token}'`. Endpoint: "
210
+ f"`POST https://api.bitbucket.org/2.0/repositories/{owner}/{repo}/pullrequests`. "
211
+ f"Body JSON fields: `title`, `description`, `source.branch.name` (= `{branch}`), `draft: true`. "
212
+ "The response's `links.html.href` is the PR URL.\n"
213
+ " 7. End your output with the PR URL on its own line. No fictitious URLs — if the curl fails, "
214
+ "surface the error verbatim.\n"
215
+ )
216
+
217
+
218
+ REPO_CLONERS: Dict[str, RepoCloner] = build_registry(
219
+ (GithubRepoCloner(), BitbucketRepoCloner()),
220
+ kind="repo cloner",
221
+ name_attr="kind",
222
+ )
223
+
224
+
225
+ def _resolve_cloner(provider: str) -> RepoCloner:
226
+ cloner = REPO_CLONERS.get(provider)
227
+ if cloner is None:
228
+ known = ", ".join(sorted(REPO_CLONERS.keys()))
229
+ raise RuntimeError(f"unknown repo provider {provider!r}; known: {known}")
230
+ return cloner
231
+
232
+
233
+ class CommandAgent(Command):
234
+ name = "agent"
235
+ help = "Run an autonomous agent flow against a target (prfix / conflict-resolve / ci-fix)."
236
+
237
+ def add_arguments(self, parser: argparse.ArgumentParser) -> None:
238
+ sub = parser.add_subparsers(dest="agent_op", required=True, metavar="OP")
239
+ for op in AGENT_OPS.values():
240
+ op_parser = sub.add_parser(op.name, help=op.help)
241
+ op.add_arguments(op_parser)
242
+
243
+ def run(self, args: argparse.Namespace) -> int:
244
+ op = AGENT_OPS.get(args.agent_op)
245
+ if op is None:
246
+ known = ", ".join(sorted(AGENT_OPS.keys()))
247
+ log.error("unknown agent op: %s (known: %s)", args.agent_op, known)
248
+ return 2
249
+ return op.run(self, args)
250
+
251
+ def _run_prfix(self, args: argparse.Namespace) -> int:
252
+ from briar.storage import make_store
253
+
254
+ try:
255
+ store = make_store(args.store, file_root=Path(args.knowledge))
256
+ except Exception: # noqa: BLE001
257
+ log.exception("agent-prfix: failed to open store=%s", args.store)
258
+ return 3
259
+
260
+ target = f"{args.owner}/{args.repo}"
261
+ clone_url = f"https://github.com/{target}.git"
262
+
263
+ worktree = Path(tempfile.mkdtemp(prefix="briar-agent-prfix-"))
264
+ log.info(
265
+ "agent-prfix: target=%s pr=%d branch=%s worktree=%s dry_run=%s",
266
+ target,
267
+ args.pr,
268
+ args.branch,
269
+ worktree,
270
+ args.dry_run,
271
+ )
272
+
273
+ # Skip the clone in dry-run — the worktree path is only needed
274
+ # as a string in the system prompt (renders fine), the agent
275
+ # never executes against the filesystem.
276
+ if not args.dry_run:
277
+ if not self._clone_branch(clone_url, args.branch, worktree):
278
+ log.error("agent-prfix: clone failed; aborting")
279
+ self._cleanup_worktree(worktree, keep=args.keep_worktree)
280
+ return 4
281
+
282
+ git_name, git_email = self._resolve_git_identity(args)
283
+ log.info("agent-prfix: git identity user.name=%s user.email=%s", git_name, git_email)
284
+ if not self._set_git_identity(worktree, git_name, git_email):
285
+ log.error("agent-prfix: git identity setup failed; aborting")
286
+ self._cleanup_worktree(worktree, keep=args.keep_worktree)
287
+ return 5
288
+
289
+ # JIT-fetch the PR's review comments + failing-CI context. Spliced
290
+ # into the agent's system prompt below the archetype's persona.
291
+ # Failure here is non-fatal — the agent still has the worktree
292
+ # and the bash tool; the pr-review-context is enrichment.
293
+ task_sections = self._fetch_pr_context(
294
+ company=args.company,
295
+ owner=args.owner,
296
+ repo=args.repo,
297
+ pr=args.pr,
298
+ )
299
+
300
+ messages = self._load_messages_block(args)
301
+ runner = AgentRunner(
302
+ company=args.company,
303
+ task="prfix",
304
+ archetype_name="pr-fixer",
305
+ workdir=worktree,
306
+ knowledge_store=store,
307
+ target=target,
308
+ model=args.model,
309
+ max_iterations=args.max_iter,
310
+ extra_user_instructions=self._pr_specific_instructions(args.owner, args.repo, args.pr, args.branch),
311
+ task_context_sections=task_sections,
312
+ dry_run=args.dry_run,
313
+ messages=messages,
314
+ )
315
+ result = runner.run()
316
+
317
+ log.info(
318
+ "agent-prfix: done iterations=%d stop=%s commits=%d tool_calls=%d %s%s",
319
+ result.iterations,
320
+ result.stop_reason,
321
+ len(result.commits),
322
+ result.tool_calls,
323
+ result.cost_summary(),
324
+ f" error={result.error!r}" if result.error else "",
325
+ )
326
+ if result.final_text:
327
+ print("--- agent final text ---")
328
+ print(result.final_text)
329
+ if result.commits:
330
+ print(f"--- commits: {', '.join(result.commits)} ---")
331
+ self._cleanup_worktree(worktree, keep=args.keep_worktree or bool(result.error))
332
+ return 0 if not result.error else 6
333
+
334
+ def _run_implement(self, args: argparse.Namespace) -> int:
335
+ """Implement one ticket end-to-end via the engineer archetype.
336
+
337
+ Parallel to `_run_prfix` but anchored on a ticket key instead
338
+ of a PR number. Clones the default branch (the agent creates
339
+ its own feature branch + pushes + opens a PR); fetches the
340
+ full ticket body via the ticket-context task-scoped extractor;
341
+ splices it into the agent's system prompt."""
342
+ from briar.storage import make_store
343
+
344
+ try:
345
+ store = make_store(args.store, file_root=Path(args.knowledge))
346
+ except Exception: # noqa: BLE001
347
+ log.exception("agent-implement: failed to open store=%s", args.store)
348
+ return 3
349
+
350
+ target = f"{args.owner}/{args.repo}"
351
+
352
+ worktree = Path(tempfile.mkdtemp(prefix="briar-agent-implement-"))
353
+ log.info(
354
+ "agent-implement: target=%s ticket=%s tracker=%s provider=%s worktree=%s dry_run=%s",
355
+ target,
356
+ args.ticket_key,
357
+ args.tracker,
358
+ args.provider,
359
+ worktree,
360
+ args.dry_run,
361
+ )
362
+
363
+ if not args.dry_run:
364
+ if not self._clone_default(args.provider, args.owner, args.repo, worktree, company=args.company):
365
+ log.error("agent-implement: clone failed; aborting")
366
+ self._cleanup_worktree(worktree, keep=args.keep_worktree)
367
+ return 4
368
+
369
+ git_name, git_email = self._resolve_git_identity(args)
370
+ log.info("agent-implement: git identity user.name=%s user.email=%s", git_name, git_email)
371
+ if not self._set_git_identity(worktree, git_name, git_email):
372
+ log.error("agent-implement: git identity setup failed; aborting")
373
+ self._cleanup_worktree(worktree, keep=args.keep_worktree)
374
+ return 5
375
+
376
+ # JIT-fetch the ticket's full body + ACs + comments. Spliced
377
+ # into the agent's system prompt below the archetype's persona.
378
+ # Failure here is non-fatal — the agent can still proceed with
379
+ # whatever the runbook-blob's `active-tickets` summary had, but
380
+ # we log a warning since the ticket-context is the engineer
381
+ # archetype's #1 priority input.
382
+ task_sections = self._fetch_ticket_context(
383
+ company=args.company,
384
+ tracker=args.tracker,
385
+ ticket_project=args.ticket_project,
386
+ ticket_key=args.ticket_key,
387
+ )
388
+ if not task_sections:
389
+ log.warning("agent-implement: ticket-context was empty — agent will rely on ticket key alone")
390
+
391
+ runner = AgentRunner(
392
+ company=args.company,
393
+ task="implement",
394
+ archetype_name="engineer",
395
+ workdir=worktree,
396
+ knowledge_store=store,
397
+ target=target,
398
+ model=args.model,
399
+ max_iterations=args.max_iter,
400
+ extra_user_instructions=self._implement_specific_instructions(
401
+ provider=args.provider,
402
+ company=args.company,
403
+ owner=args.owner,
404
+ repo=args.repo,
405
+ ticket_key=args.ticket_key,
406
+ ),
407
+ task_context_sections=task_sections,
408
+ dry_run=args.dry_run,
409
+ messages=self._load_messages_block(args),
410
+ )
411
+ result = runner.run()
412
+
413
+ log.info(
414
+ "agent-implement: done iterations=%d stop=%s commits=%d tool_calls=%d %s%s",
415
+ result.iterations,
416
+ result.stop_reason,
417
+ len(result.commits),
418
+ result.tool_calls,
419
+ result.cost_summary(),
420
+ f" error={result.error!r}" if result.error else "",
421
+ )
422
+ if result.final_text:
423
+ print("--- agent final text ---")
424
+ print(result.final_text)
425
+ if result.commits:
426
+ print(f"--- commits: {', '.join(result.commits)} ---")
427
+ self._cleanup_worktree(worktree, keep=args.keep_worktree or bool(result.error))
428
+ return 0 if not result.error else 6
429
+
430
+ @staticmethod
431
+ def _clone_default(provider: str, owner: str, repo: str, dest: Path, *, company: str = "") -> bool:
432
+ """Clone the default branch (no `--branch` flag) via the
433
+ `RepoCloner` registered for `provider`. The cloner owns the
434
+ per-vendor token resolution + URL conventions; this method
435
+ only knows how to invoke `git clone` + scrub the embedded
436
+ token from the persisted remote afterwards."""
437
+ try:
438
+ cloner = _resolve_cloner(provider)
439
+ except RuntimeError as exc:
440
+ log.error("clone failed: %s", exc)
441
+ return False
442
+ token = cloner.resolve_token(company=company)
443
+ if not token:
444
+ log.error("clone failed: no token for provider=%s company=%r", provider, company)
445
+ return False
446
+ clone_url = cloner.clone_url(owner, repo)
447
+ authed_url = cloner.authed_clone_url(owner, repo, token)
448
+ log.debug("clone-default: provider=%s dest=%s (token redacted)", provider, dest)
449
+ proc = subprocess.run(
450
+ ["git", "clone", "--depth", "50", authed_url, str(dest)],
451
+ capture_output=True,
452
+ text=True,
453
+ timeout=180,
454
+ )
455
+ if proc.returncode != 0:
456
+ stderr = proc.stderr.replace(token, "<TOKEN>").strip()[:400]
457
+ log.error("clone failed: rc=%d stderr=%s", proc.returncode, stderr)
458
+ return False
459
+ reset = subprocess.run(
460
+ ["git", "-C", str(dest), "remote", "set-url", "origin", clone_url],
461
+ capture_output=True,
462
+ text=True,
463
+ timeout=10,
464
+ )
465
+ if reset.returncode != 0:
466
+ log.warning("clone-default: remote set-url cleanup failed (token may persist in .git/config) provider=%s", provider)
467
+ return True
468
+
469
+ @staticmethod
470
+ def _load_messages_block(args: argparse.Namespace):
471
+ """Read the company's `messages:` block from the optional
472
+ --runbook YAML. Returns an empty dict on any failure (the agent
473
+ runs fine without bound message channels — it falls back to
474
+ the bash escape hatch for `gh` / `curl`)."""
475
+ runbook_path = getattr(args, "runbook", "") or ""
476
+ if not runbook_path:
477
+ return {}
478
+ try:
479
+ from briar.iac.runbook import load_runbook_file
480
+
481
+ rb = load_runbook_file(Path(runbook_path))
482
+ except Exception: # noqa: BLE001
483
+ log.exception("failed to load runbook=%s for messages: block — continuing without send_message tool", runbook_path)
484
+ return {}
485
+ company = rb.companies.get(args.company)
486
+ if company is None:
487
+ log.warning("runbook=%s has no company=%s — agent will run without bound messages", runbook_path, args.company)
488
+ return {}
489
+ return dict(getattr(company, "messages", {}) or {})
490
+
491
+ @staticmethod
492
+ def _resolve_git_identity(args: argparse.Namespace) -> Tuple[str, str]:
493
+ """Resolve (user_name, user_email) for the worktree's commit identity.
494
+
495
+ Priority (per-field, independent):
496
+ 1. CLI flag — ``--git-user-name`` / ``--git-user-email`` (non-empty)
497
+ 2. Runbook YAML — ``companies.<name>.git_identity.{name, email}``
498
+ 3. Hardcoded legacy default — ``iklobato`` / ``iklobato@gmail.com``
499
+
500
+ Per-field resolution means you can set the name via CLI and let
501
+ the email fall through to YAML (or vice versa). YAML lookup
502
+ runs only when ``--runbook`` was passed; failures during YAML
503
+ load are non-fatal — the resolver logs and falls through."""
504
+ cli_name = (getattr(args, "git_user_name", "") or "").strip()
505
+ cli_email = (getattr(args, "git_user_email", "") or "").strip()
506
+
507
+ yaml_name = ""
508
+ yaml_email = ""
509
+ runbook_path = getattr(args, "runbook", "") or ""
510
+ if runbook_path:
511
+ try:
512
+ from briar.iac.runbook import load_runbook_file
513
+
514
+ rb = load_runbook_file(Path(runbook_path))
515
+ company = rb.companies.get(getattr(args, "company", ""))
516
+ if company is not None:
517
+ gi = getattr(company, "git_identity", None)
518
+ if gi is not None:
519
+ yaml_name = (gi.name or "").strip()
520
+ yaml_email = (gi.email or "").strip()
521
+ except Exception: # noqa: BLE001
522
+ log.exception("failed to load runbook=%s for git_identity — falling back", runbook_path)
523
+
524
+ resolved_name = cli_name or yaml_name or "iklobato"
525
+ resolved_email = cli_email or yaml_email or "iklobato@gmail.com"
526
+ return resolved_name, resolved_email
527
+
528
+ @staticmethod
529
+ def _fetch_ticket_context(*, company: str, tracker: str, ticket_project: str, ticket_key: str):
530
+ """Run the `ticket-context` task-scoped extractor. Returns a
531
+ list with one ExtractedSection or empty on failure. Symmetric
532
+ to `_fetch_pr_context` but for the engineer flow."""
533
+ import argparse as _ap
534
+
535
+ from briar.extract import TASK_SCOPED_EXTRACTORS
536
+
537
+ extractor = TASK_SCOPED_EXTRACTORS.get("ticket-context")
538
+ if extractor is None:
539
+ return []
540
+ ns = _ap.Namespace(
541
+ company=company,
542
+ tracker=tracker,
543
+ ticket_project=ticket_project,
544
+ ticket_key=ticket_key,
545
+ )
546
+ try:
547
+ section = extractor.fetch(ns)
548
+ except Exception: # noqa: BLE001
549
+ log.exception("ticket-context fetch failed; agent continues without it")
550
+ return []
551
+ if getattr(section, "is_empty", True):
552
+ return []
553
+ log.info("ticket-context: title=%r body_bytes=%d", section.title, len(section.body or ""))
554
+ return [section]
555
+
556
+ @staticmethod
557
+ def _implement_specific_instructions(*, provider: str, company: str, owner: str, repo: str, ticket_key: str) -> str:
558
+ """Compose the agent's procedure instructions. Lines 1-5 are
559
+ provider-agnostic; lines 6-7 (the PR-creation recipe) come
560
+ from the provider's `RepoCloner.pr_creation_recipe(...)` so
561
+ adding a new vendor doesn't touch this method."""
562
+ branch = f"briar/{ticket_key.lower().replace('#', '').replace(' ', '-')}"
563
+ common = (
564
+ f"The target ticket is {ticket_key} on {owner}/{repo}. The worktree is a fresh clone of the "
565
+ "default branch. Procedure:\n"
566
+ " 1. Read the ticket-context section above for the full body + acceptance criteria. Address EVERY AC.\n"
567
+ f" 2. Create a feature branch: `git checkout -b {branch}`.\n"
568
+ " 3. Make the change. Match codebase-conventions (test runner, linter, formatter, migration tool).\n"
569
+ " 4. Run the test command from codebase-conventions locally; only push when it's green.\n"
570
+ " 5. Push: `git push -u origin HEAD` (NEVER --force).\n"
571
+ )
572
+ try:
573
+ cloner = _resolve_cloner(provider)
574
+ except RuntimeError:
575
+ # Unknown provider — degrade gracefully with the GitHub
576
+ # recipe rather than crashing the instruction build.
577
+ cloner = REPO_CLONERS["github"]
578
+ recipe = cloner.pr_creation_recipe(owner=owner, repo=repo, branch=branch, company=company)
579
+ return common + recipe + (
580
+ "\n"
581
+ "Strict constraints: NEVER --force, --amend, rebase, squash. NEVER commit as a bot identity "
582
+ "(run `git config user.name` to verify it's a human). If an AC is ambiguous, stop and post a "
583
+ "clarifying comment on the ticket — do not guess."
584
+ )
585
+
586
+ @staticmethod
587
+ def _clone_branch(clone_url: str, branch: str, dest: Path) -> bool:
588
+ """Clone via HTTPS with the GITHUB_TOKEN embedded in the URL.
589
+
590
+ The droplet has no git credential helper configured for
591
+ github.com (and `gh` isn't installed), so plain `git clone
592
+ https://github.com/...` fails with `could not read Username for
593
+ 'https://github.com'`. Standard CI workaround: inject the token
594
+ as the username field. The token comes from $GITHUB_TOKEN — same
595
+ env var the rest of briar uses (sourced from /etc/briar/secrets.env
596
+ on the droplet via `set -a; . /etc/briar/secrets.env`).
597
+
598
+ The token is stripped from the resulting remote URL after clone
599
+ so it does not linger in .git/config on disk."""
600
+ import os
601
+
602
+ token = os.environ.get("GITHUB_TOKEN", "").strip()
603
+ if not token:
604
+ log.error("clone failed: GITHUB_TOKEN env var missing")
605
+ return False
606
+ authed_url = clone_url.replace("https://github.com/", f"https://x-access-token:{token}@github.com/")
607
+ log.debug("clone-branch: branch=%s dest=%s (token redacted)", branch, dest)
608
+ proc = subprocess.run(
609
+ ["git", "clone", "--depth", "50", "--branch", branch, authed_url, str(dest)],
610
+ capture_output=True,
611
+ text=True,
612
+ timeout=180,
613
+ )
614
+ if proc.returncode != 0:
615
+ # Redact the token from the URL in case it leaked into stderr.
616
+ stderr = proc.stderr.replace(token, "<TOKEN>").strip()[:400]
617
+ log.error("clone failed: rc=%d stderr=%s", proc.returncode, stderr)
618
+ return False
619
+ # Strip the embedded token from the persisted remote so anyone
620
+ # who looks at .git/config later doesn't see it.
621
+ reset = subprocess.run(
622
+ ["git", "-C", str(dest), "remote", "set-url", "origin", clone_url],
623
+ capture_output=True,
624
+ text=True,
625
+ timeout=10,
626
+ )
627
+ if reset.returncode != 0:
628
+ log.warning("clone: remote set-url cleanup failed (token may persist in .git/config)")
629
+ return True
630
+
631
+ @staticmethod
632
+ def _set_git_identity(worktree: Path, user_name: str, user_email: str) -> bool:
633
+ for key, value in (("user.name", user_name), ("user.email", user_email), ("commit.gpgsign", "false")):
634
+ proc = subprocess.run(
635
+ ["git", "-C", str(worktree), "config", key, value],
636
+ capture_output=True,
637
+ text=True,
638
+ timeout=10,
639
+ )
640
+ if proc.returncode != 0:
641
+ log.error("git config %s failed: %s", key, proc.stderr.strip())
642
+ return False
643
+ return True
644
+
645
+ @staticmethod
646
+ def _fetch_pr_context(*, company: str, owner: str, repo: str, pr: int):
647
+ """Run the `pr-review-context` task-scoped extractor for this
648
+ PR. Returns a list with one ExtractedSection or empty on failure.
649
+ Defensive — the agent should still run if this fetch fails."""
650
+ import argparse as _ap
651
+
652
+ from briar.extract import TASK_SCOPED_EXTRACTORS
653
+
654
+ extractor = TASK_SCOPED_EXTRACTORS.get("pr-review-context")
655
+ if extractor is None:
656
+ return []
657
+ ns = _ap.Namespace(
658
+ company=company,
659
+ provider="github", # CommandAgent.prfix is GitHub-only today; Bitbucket variant would override.
660
+ pr_target_repo=f"{owner}/{repo}",
661
+ pr_target_number=pr,
662
+ )
663
+ try:
664
+ section = extractor.fetch(ns)
665
+ except Exception: # noqa: BLE001
666
+ log.exception("pr-review-context fetch failed; agent continues without it")
667
+ return []
668
+ if getattr(section, "is_empty", True):
669
+ return []
670
+ log.info("pr-review-context: title=%r body_bytes=%d", section.title, len(section.body or ""))
671
+ return [section]
672
+
673
+ @staticmethod
674
+ def _pr_specific_instructions(owner: str, repo: str, pr: int, branch: str) -> str:
675
+ return (
676
+ f"The target is PR #{pr} on {owner}/{repo}, branch {branch}. The worktree is a fresh clone at "
677
+ "the branch HEAD. Use `gh pr view {pr} --repo {owner}/{repo}` for state, "
678
+ "`gh api repos/{owner}/{repo}/pulls/{pr}/comments` for inline threads, "
679
+ "`gh api repos/{owner}/{repo}/issues/{pr}/comments` for PR-level comments. "
680
+ "Push with `git push origin HEAD:{branch}` (NEVER --force). Reply to threads "
681
+ "via `gh api -X POST repos/{owner}/{repo}/pulls/{pr}/comments/<id>/replies -f body=...`."
682
+ ).format(owner=owner, repo=repo, pr=pr, branch=branch)
683
+
684
+ @staticmethod
685
+ def _cleanup_worktree(path: Path, *, keep: bool) -> None:
686
+ if keep:
687
+ log.info("worktree kept at %s for inspection", path)
688
+ return
689
+ try:
690
+ shutil.rmtree(path)
691
+ except OSError:
692
+ log.exception("worktree cleanup failed: %s", path)