py-harness-cli 0.3.0__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 (92) hide show
  1. finetune/__init__.py +1 -0
  2. finetune/agent_system.py +41 -0
  3. finetune/agent_traces.py +157 -0
  4. finetune/everyday.py +30 -0
  5. finetune/hf_ollama.py +158 -0
  6. finetune/huggingface_store.py +144 -0
  7. finetune/models.py +74 -0
  8. finetune/paths.py +11 -0
  9. finetune/python_vibe.py +788 -0
  10. finetune/splits.py +54 -0
  11. finetune/systems.py +9 -0
  12. harness/__init__.py +42 -0
  13. harness/__main__.py +8 -0
  14. harness/act/__init__.py +6 -0
  15. harness/act/autofix/__init__.py +110 -0
  16. harness/act/autofix/additions.py +217 -0
  17. harness/act/autofix/conflicts.py +124 -0
  18. harness/act/autofix/cover.py +419 -0
  19. harness/act/autofix/mechanical.py +151 -0
  20. harness/act/autofix/missing_imports.py +50 -0
  21. harness/act/autofix/moves.py +439 -0
  22. harness/act/autofix/names.py +339 -0
  23. harness/act/autofix/scaffold.py +224 -0
  24. harness/act/code.py +157 -0
  25. harness/act/gate.py +229 -0
  26. harness/act/parse.py +247 -0
  27. harness/act/patch_fix.py +138 -0
  28. harness/act/tools.py +244 -0
  29. harness/agent/__init__.py +11 -0
  30. harness/agent/dispatch.py +235 -0
  31. harness/agent/loop.py +699 -0
  32. harness/agent/options.py +144 -0
  33. harness/agent/policy.py +856 -0
  34. harness/agent/prompt.py +170 -0
  35. harness/cli.py +393 -0
  36. harness/editor_kit.py +265 -0
  37. harness/guard/__init__.py +6 -0
  38. harness/guard/fallbacks.py +6 -0
  39. harness/guard/loop_guard.py +57 -0
  40. harness/guard/python_vibe.py +68 -0
  41. harness/guard/run.py +41 -0
  42. harness/guard/types.py +19 -0
  43. harness/locate.py +767 -0
  44. harness/mcp_stdio.py +306 -0
  45. harness/memory/__init__.py +5 -0
  46. harness/memory/conversation.py +104 -0
  47. harness/model/__init__.py +6 -0
  48. harness/model/chat_backend.py +100 -0
  49. harness/model/engine.py +165 -0
  50. harness/model/ollama_generate.py +60 -0
  51. harness/model/openai_generate.py +156 -0
  52. harness/model/outbound.py +83 -0
  53. harness/model/route.py +90 -0
  54. harness/observe/__init__.py +6 -0
  55. harness/observe/eval_gate.py +80 -0
  56. harness/observe/eval_loop.py +185 -0
  57. harness/observe/eval_tasks.py +399 -0
  58. harness/observe/report_md.py +102 -0
  59. harness/observe/trace_record.py +79 -0
  60. harness/openai_api.py +81 -0
  61. harness/paths.py +88 -0
  62. harness/py.typed +0 -0
  63. harness/scan/__init__.py +6 -0
  64. harness/scan/app_spec.py +338 -0
  65. harness/scan/design.py +112 -0
  66. harness/scan/existing.py +131 -0
  67. harness/scan/layout.py +254 -0
  68. harness/scan/names.py +308 -0
  69. harness/scan/project_brief.py +287 -0
  70. harness/scan/project_docs.py +42 -0
  71. harness/scan/project_scan.py +49 -0
  72. harness/scan/repo_map.py +101 -0
  73. harness/secrets.py +39 -0
  74. harness/server.py +199 -0
  75. harness/ship/__init__.py +1 -0
  76. harness/ship/bot_pr.py +221 -0
  77. harness/ship/git_ship.py +262 -0
  78. harness/ship/identity.py +62 -0
  79. harness/ship/ticket.py +251 -0
  80. harness/skillkit/__init__.py +6 -0
  81. harness/skillkit/catalog.py +241 -0
  82. harness/skillkit/refuse_change.py +640 -0
  83. harness/skillkit/refuse_finish.py +295 -0
  84. harness/skillkit/target.py +238 -0
  85. harness/task.py +717 -0
  86. py_harness_cli-0.3.0.dist-info/METADATA +177 -0
  87. py_harness_cli-0.3.0.dist-info/RECORD +92 -0
  88. py_harness_cli-0.3.0.dist-info/WHEEL +5 -0
  89. py_harness_cli-0.3.0.dist-info/entry_points.txt +3 -0
  90. py_harness_cli-0.3.0.dist-info/licenses/LICENSE +202 -0
  91. py_harness_cli-0.3.0.dist-info/licenses/NOTICE +6 -0
  92. py_harness_cli-0.3.0.dist-info/top_level.txt +2 -0
harness/server.py ADDED
@@ -0,0 +1,199 @@
1
+ """Serve the agent over HTTP on the local machine only.
2
+
3
+ An HTTP request that reaches the agent can change files on the machine the
4
+ server runs on. Because of that, file changes are disabled unless the
5
+ person starting the server passes `--allow-writes`. The path restriction,
6
+ the draft guard and the `.bak` backup all still apply; this flag is an
7
+ additional outer control, not a replacement for them.
8
+
9
+ python -m harness serve --project ~/app
10
+ curl -s localhost:8090/health
11
+ curl -s localhost:8090/v1/layout -d '{}'
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import json
17
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
18
+ from pathlib import Path
19
+
20
+ from harness.agent import Agent, AgentOptions
21
+ from harness.openai_api import (
22
+ chat_completion_payload,
23
+ last_user_text,
24
+ models_payload,
25
+ parse_chat_body,
26
+ )
27
+ from harness.scan.layout import render_layout
28
+ from harness.scan.project_brief import classify_project, render_brief
29
+ from harness.task import looks_like_add_feature, looks_like_bugfix
30
+
31
+ HOST = "127.0.0.1"
32
+ MAX_BODY = 64 * 1024
33
+ READ_ONLY_ROUTES = ("/v1/brief", "/v1/layout", "/v1/ask")
34
+ WRITE_ROUTES = ("/v1/run",)
35
+ CHAT_ROUTES = ("/v1/chat/completions", "/chat/completions")
36
+
37
+
38
+ def make_handler(project: Path, *, allow_writes: bool, model: str):
39
+ class Handler(BaseHTTPRequestHandler):
40
+ server_version = "py-harness"
41
+ protocol_version = "HTTP/1.1"
42
+
43
+ def _send(self, code: int, payload: dict) -> None:
44
+ body = json.dumps(payload).encode("utf-8")
45
+ self.send_response(code)
46
+ self.send_header("content-type", "application/json")
47
+ self.send_header("content-length", str(len(body)))
48
+ # Say the connection is finished. A client left waiting for more
49
+ # blocks until its own timeout, which is what happened on Windows.
50
+ self.send_header("connection", "close")
51
+ self.end_headers()
52
+ self.wfile.write(body)
53
+ self.wfile.flush()
54
+ self.close_connection = True
55
+
56
+ def _body(self) -> dict | None:
57
+ length = int(self.headers.get("content-length") or 0)
58
+ if length > MAX_BODY:
59
+ self._send(413, {"error": "body too large"})
60
+ return None
61
+ raw = self.rfile.read(length) if length else b"{}"
62
+ try:
63
+ parsed = json.loads(raw or b"{}")
64
+ except json.JSONDecodeError:
65
+ self._send(400, {"error": "invalid json"})
66
+ return None
67
+ if not isinstance(parsed, dict):
68
+ self._send(400, {"error": "object required"})
69
+ return None
70
+ return parsed
71
+
72
+ def do_GET(self) -> None: # noqa: N802
73
+ path = self.path.split("?", 1)[0]
74
+ if path in {"/v1/models", "/models"}:
75
+ self._send(200, models_payload(model or "llama3.1:8b"))
76
+ return
77
+ if path != "/health":
78
+ self._send(404, {"error": "no such route"})
79
+ return
80
+ self._send(
81
+ 200,
82
+ {
83
+ "ok": True,
84
+ "project": str(project),
85
+ "allow_writes": allow_writes,
86
+ "model": model,
87
+ "routes": list(READ_ONLY_ROUTES)
88
+ + list(CHAT_ROUTES)
89
+ + (list(WRITE_ROUTES) if allow_writes else []),
90
+ },
91
+ )
92
+
93
+ def do_POST(self) -> None: # noqa: N802
94
+ path = self.path.split("?", 1)[0]
95
+ if path in CHAT_ROUTES:
96
+ self._chat()
97
+ return
98
+ if path not in READ_ONLY_ROUTES + WRITE_ROUTES:
99
+ self._send(404, {"error": "no such route"})
100
+ return
101
+ if path in WRITE_ROUTES and not allow_writes:
102
+ self._send(
103
+ 403,
104
+ {
105
+ "error": "this server is read-only",
106
+ "fix": "restart it with --allow-writes",
107
+ },
108
+ )
109
+ return
110
+ payload = self._body()
111
+ if payload is None:
112
+ return
113
+ scope = str(payload.get("scope") or "")
114
+ if path == "/v1/brief":
115
+ brief = classify_project(project, scope)
116
+ self._send(200, {"brief": render_brief(brief, scope=scope)})
117
+ return
118
+ if path == "/v1/layout":
119
+ self._send(200, {"layout": render_layout(project)})
120
+ return
121
+ task = str(payload.get("task") or "").strip()
122
+ if not task:
123
+ self._send(400, {"error": "task required"})
124
+ return
125
+ options = AgentOptions(
126
+ project=project,
127
+ task=task,
128
+ model=str(payload.get("model") or model),
129
+ scope=scope,
130
+ steps=int(payload.get("steps") or 20),
131
+ allow_writes=allow_writes and path in WRITE_ROUTES,
132
+ )
133
+ try:
134
+ result = Agent(options).run()
135
+ except (ValueError, OSError) as exc:
136
+ self._send(400, {"error": str(exc)})
137
+ return
138
+ self._send(200, result.as_dict())
139
+
140
+ def _chat(self) -> None:
141
+ payload = self._body()
142
+ if payload is None:
143
+ return
144
+ try:
145
+ parsed = parse_chat_body(json.dumps(payload).encode("utf-8"))
146
+ except ValueError as exc:
147
+ self._send(400, {"error": str(exc)})
148
+ return
149
+ if parsed["stream"]:
150
+ self._send(400, {"error": "stream is not supported; set stream false"})
151
+ return
152
+ task = last_user_text(parsed["messages"])
153
+ if not task:
154
+ self._send(400, {"error": "messages required"})
155
+ return
156
+ wants_write = looks_like_add_feature(task) or looks_like_bugfix(task)
157
+ if wants_write and not allow_writes:
158
+ self._send(
159
+ 403,
160
+ {
161
+ "error": "this server is read-only",
162
+ "fix": "restart it with --allow-writes, or run "
163
+ "py-harness run <project> \"<task>\" in the editor terminal",
164
+ },
165
+ )
166
+ return
167
+ options = AgentOptions(
168
+ project=project,
169
+ task=task,
170
+ model=str(parsed["model"] or model),
171
+ allow_writes=allow_writes and wants_write,
172
+ )
173
+ try:
174
+ result = Agent(options).run()
175
+ except (ValueError, OSError) as exc:
176
+ self._send(400, {"error": str(exc)})
177
+ return
178
+ self._send(200, chat_completion_payload(result.summary, options.model))
179
+
180
+ def log_message(self, fmt: str, *args) -> None:
181
+ print(f"{self.address_string()} - {fmt % args}")
182
+
183
+ return Handler
184
+
185
+
186
+ def serve(
187
+ project: Path, *, port: int = 8090, allow_writes: bool = False, model: str = ""
188
+ ) -> int:
189
+ handler = make_handler(project, allow_writes=allow_writes, model=model)
190
+ httpd = ThreadingHTTPServer((HOST, port), handler)
191
+ mode = "read-write" if allow_writes else "read-only"
192
+ print(f"py-harness on http://{HOST}:{port} project {project} {mode}")
193
+ try:
194
+ httpd.serve_forever()
195
+ except KeyboardInterrupt:
196
+ pass
197
+ finally:
198
+ httpd.server_close()
199
+ return 0
@@ -0,0 +1 @@
1
+ """Layer: limited git / gh. Deterministic. No model."""
harness/ship/bot_pr.py ADDED
@@ -0,0 +1,221 @@
1
+ """Whether a pull request opened by a bot may be merged without a reader.
2
+
3
+ Dependency bots open the same pull request shape every week, and most of
4
+ them are dull enough to merge on sight. The one that is not dull looks
5
+ exactly like the ones that are.
6
+
7
+ `Bump actions/github-script from 7 to 9` reads like the rest. Its
8
+ release notes say `require('@actions/github')` stops working and that
9
+ `getOctokit` becomes an injected parameter, so a workflow that used
10
+ either breaks on merge. The title carries none of that. What it does
11
+ carry is `7` and `9`, and a first number that changed is the whole
12
+ signal: a major bump is where breaking changes are allowed to live.
13
+
14
+ So this refuses on the shape of the version, on a red or unfinished
15
+ check, and on anything GitHub already says is not mergeable. It never
16
+ approves; it only fails to object.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import re
22
+ from pathlib import Path
23
+
24
+ # The bot writes two shapes, and reading only one of them missed a
25
+ # major bump entirely. Actions get "bump x from 7 to 9"; Python
26
+ # requirements get "update x requirement from >=0.26.0 to >=1.29.0".
27
+ _BUMP = re.compile(
28
+ r"\b(?:bump|update)\s+(?P<what>\S+)"
29
+ r"(?:\s+requirement)?"
30
+ r"\s+from\s+(?P<old>[<>=~^!]*\s*v?[\w.\-]+)"
31
+ r"\s+to\s+(?P<new>[<>=~^!]*\s*v?[\w.\-]+)",
32
+ re.I,
33
+ )
34
+ # A requirement carries its comparator: `>=0.26.0` is version 0, not a
35
+ # string starting with a bracket.
36
+ _LEADING_NUMBER = re.compile(r"^[<>=~^!\s]*v?(\d+)")
37
+
38
+
39
+ def bump_in(title: str) -> tuple[str, str, str] | None:
40
+ """(what, old version, new version) from a bot's title, or None."""
41
+ found = _BUMP.search(title or "")
42
+ if not found:
43
+ return None
44
+ return found.group("what"), found.group("old"), found.group("new")
45
+
46
+
47
+ def is_a_major_bump(old: str, new: str) -> bool:
48
+ """Did the first number change?
49
+
50
+ Unparseable versions are not called major. Refusing everything this
51
+ cannot read would make the check about the parser rather than about
52
+ the risk, and the other reasons still apply.
53
+ """
54
+ before, after = _LEADING_NUMBER.match(old), _LEADING_NUMBER.match(new)
55
+ if not before or not after:
56
+ return False
57
+ return before.group(1) != after.group(1)
58
+
59
+
60
+ # CANCELLED, SKIPPED and STALE are deliberately absent from both sets
61
+ # below. A cancelled run did not fail; it gave no answer, usually
62
+ # because a newer push superseded it. Naming it as a failure refused two
63
+ # pull requests whose checks had passed.
64
+ _FAILED = {"FAILURE", "TIMED_OUT", "ACTION_REQUIRED", "STARTUP_FAILURE"}
65
+ _RUNNING = {"PENDING", "IN_PROGRESS", "QUEUED", "EXPECTED", "WAITING", ""}
66
+
67
+
68
+ def latest_of_each(pull: dict) -> list[dict]:
69
+ """One entry per check name: the most recent run of it.
70
+
71
+ A pull request keeps every run of a check, not just the current one.
72
+ Pushing again cancels the run in flight and starts another, so the
73
+ rollup holds a cancelled entry and a successful entry under the same
74
+ name. Reading them all as equal reported "checks are failing: readme"
75
+ on two pull requests whose readme check had passed forty seconds
76
+ after being superseded.
77
+ """
78
+ newest: dict[str, dict] = {}
79
+ for check in pull.get("statusCheckRollup") or []:
80
+ name = str(check.get("name") or check.get("context") or "a check")
81
+ when = str(check.get("completedAt") or check.get("startedAt") or "")
82
+ seen = newest.get(name)
83
+ if seen is None or when >= str(
84
+ seen.get("completedAt") or seen.get("startedAt") or ""
85
+ ):
86
+ newest[name] = check
87
+ return [newest[name] for name in sorted(newest)]
88
+
89
+
90
+ def _check_state(pull: dict) -> tuple[list[str], list[str]]:
91
+ """(failing, unfinished) check names, counting each check once."""
92
+ failing, unfinished = [], []
93
+ for check in latest_of_each(pull):
94
+ name = str(check.get("name") or check.get("context") or "a check")
95
+ conclusion = (check.get("conclusion") or "").upper()
96
+ state = (check.get("state") or check.get("status") or "").upper()
97
+ if conclusion in _FAILED:
98
+ failing.append(name)
99
+ elif conclusion in {"", "NEUTRAL"} and state in _RUNNING:
100
+ unfinished.append(name)
101
+ return sorted(set(failing)), sorted(set(unfinished))
102
+
103
+
104
+ def refuse_bot_merge(pull: dict, project: Path | None = None) -> str:
105
+ """Why this pull request needs a person. "" when nothing objects.
106
+
107
+ `project` lets a major bump be answered rather than only refused.
108
+ Refusing every one of them scored nought for five here: five action
109
+ bumps were merged, every workflow stayed green, and the rule caught
110
+ nothing. A refusal nobody needs is a refusal that gets switched off.
111
+
112
+ What the pull request's own checks prove is that the workflows they
113
+ ran still work. If every workflow using the bumped action ran green
114
+ here, the thing that would break has already been exercised. If some
115
+ did not — `Pages` is skipped on a pull request, `Celebrate merge`
116
+ only runs on merge — then nothing was proved and a person reads it.
117
+ """
118
+ failing, unfinished = _check_state(pull)
119
+ if failing:
120
+ return f"checks are failing: {', '.join(failing)}"
121
+ if unfinished:
122
+ return f"checks have not finished: {', '.join(unfinished)}"
123
+ mergeable = (pull.get("mergeable") or "").upper()
124
+ if mergeable == "CONFLICTING":
125
+ return "this branch conflicts with the base branch"
126
+ # The durable reason comes before the transient one. A major bump is
127
+ # true whatever GitHub is still working out, and it is the answer the
128
+ # person needs; being told to ask again would waste the trip.
129
+ bump = bump_in(str(pull.get("title") or ""))
130
+ if bump and is_a_major_bump(bump[1], bump[2]):
131
+ what, was, now = bump
132
+ unproven = (
133
+ unproven_workflows(project, pull, what) if project is not None else None
134
+ )
135
+ if unproven == []:
136
+ # Every workflow that uses it ran green on this very pull
137
+ # request. There is nothing left for a person to check.
138
+ return ""
139
+ where = f" It is used by {', '.join(unproven)}, which this pull " \
140
+ "request did not run." if unproven else ""
141
+ return (
142
+ f"{what} {was} to {now} is a major version bump. That is where "
143
+ "breaking changes are allowed to live, and the title never "
144
+ f"says so.{where} Read the release notes and merge it by hand."
145
+ )
146
+ state = (pull.get("mergeStateStatus") or "").upper()
147
+ if state in {"BLOCKED", "DIRTY", "BEHIND", "DRAFT"}:
148
+ return f"GitHub will not merge it yet: {state.lower()}"
149
+ # GitHub works mergeability out when asked, not in advance, so the
150
+ # first read of a fresh pull request often says nothing. Nothing is
151
+ # not the same as yes.
152
+ if mergeable in {"", "UNKNOWN"} or state in {"", "UNKNOWN"}:
153
+ return "GitHub has not worked out yet whether this merges; ask again"
154
+ return ""
155
+
156
+
157
+ # Where a repository keeps the workflows a bumped action might appear in.
158
+ WORKFLOW_DIR = Path(".github/workflows")
159
+ _WORKFLOW_NAME = re.compile(r"^name:\s*(.+?)\s*$", re.M)
160
+
161
+
162
+ def workflows_using(project: Path, action: str) -> dict[str, str]:
163
+ """{workflow name: file name} for every workflow naming this action.
164
+
165
+ An action is bumped by its repository path, `actions/checkout`, and
166
+ that is exactly the string a workflow writes in its `uses:` line.
167
+ """
168
+ found: dict[str, str] = {}
169
+ root = Path(project) / WORKFLOW_DIR
170
+ if not root.is_dir():
171
+ return found
172
+ for path in sorted(root.glob("*.y*ml")):
173
+ try:
174
+ text = path.read_text(encoding="utf-8")
175
+ except OSError:
176
+ continue
177
+ if action not in text:
178
+ continue
179
+ named = _WORKFLOW_NAME.search(text)
180
+ found[named.group(1) if named else path.stem] = path.name
181
+ return found
182
+
183
+
184
+ def workflows_that_passed(pull: dict) -> set[str]:
185
+ """Workflows this pull request actually ran green.
186
+
187
+ A check that was skipped or cancelled proves nothing about the
188
+ workflow it belongs to, which is the whole point: `Pages / build`
189
+ comes back SKIPPED on a pull request, so a green run says nothing
190
+ about whether the page still deploys.
191
+ """
192
+ passed: set[str] = set()
193
+ failed: set[str] = set()
194
+ for check in latest_of_each(pull):
195
+ name = str(check.get("workflowName") or "")
196
+ if not name:
197
+ continue
198
+ if (check.get("conclusion") or "").upper() == "SUCCESS":
199
+ passed.add(name)
200
+ else:
201
+ failed.add(name)
202
+ return passed - failed
203
+
204
+
205
+ def unproven_workflows(project: Path, pull: dict, action: str) -> list[str] | None:
206
+ """Workflows using this action that the pull request did not exercise.
207
+
208
+ Empty means every workflow touching the action ran green here, so
209
+ the bump is already tested by the thing that would break.
210
+
211
+ **None means no workflow uses it at all**, which is not the same
212
+ thing and must not be read as one. A Python dependency appears in no
213
+ workflow, so an empty list would have waved through every
214
+ `huggingface-hub 0 to 1` on the grounds that nothing it touches
215
+ failed — when nothing it touches was looked at.
216
+ """
217
+ using = workflows_using(project, action)
218
+ if not using:
219
+ return None
220
+ green = workflows_that_passed(pull)
221
+ return sorted(name for name in using if name not in green)
@@ -0,0 +1,262 @@
1
+ """Limited git and gh. Deterministic. No model. No force. No main."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import re
8
+ import subprocess
9
+ from pathlib import Path
10
+
11
+ from harness.paths import SECRET_NAMES
12
+ from harness.ship.bot_pr import refuse_bot_merge
13
+ from harness.ship.identity import co_author_line, with_co_author
14
+ from harness.ship.ticket import identity_from_user_json, parse_ticket, render_ticket
15
+
16
+ PROTECTED = frozenset({"main", "master"})
17
+ _BRANCH = re.compile(r"^[A-Za-z0-9._][A-Za-z0-9._/-]{0,79}$")
18
+ CO_AUTHOR = co_author_line()
19
+ # Says on the pull request itself which tool did the work, the way a
20
+ # commit trailer does for a commit.
21
+ PR_FOOTER = (
22
+ "\n\n---\nOpened with [py-harness](https://github.com/YauhenBichel/py-harness).\n"
23
+ f"{CO_AUTHOR}\n"
24
+ )
25
+ _TIMEOUT = 45
26
+
27
+
28
+ def _run(
29
+ project: Path,
30
+ argv: list[str],
31
+ *,
32
+ env: dict[str, str] | None = None,
33
+ keep_all: bool = False,
34
+ ) -> tuple[int, str]:
35
+ merged = os.environ.copy()
36
+ if env:
37
+ merged.update(env)
38
+ # The person stays the author, so the commit is theirs and appears in
39
+ # their history. python-vibe is recorded as a co-author instead, which
40
+ # GitHub renders on the commit, so it is visible where it was used.
41
+ try:
42
+ proc = subprocess.run(
43
+ argv,
44
+ cwd=project,
45
+ capture_output=True,
46
+ text=True,
47
+ timeout=_TIMEOUT,
48
+ check=False,
49
+ env=merged,
50
+ )
51
+ except FileNotFoundError:
52
+ return 127, f"{argv[0]} is not on PATH"
53
+ except subprocess.TimeoutExpired:
54
+ return 124, "timed out"
55
+ out = ((proc.stdout or "") + (proc.stderr or "")).strip()
56
+ if keep_all:
57
+ return proc.returncode, out
58
+ # Command output is cut from the front, because the end of a git or gh
59
+ # message is the part that says what happened. JSON has to be kept
60
+ # whole or it will not parse, which is what `keep_all` is for.
61
+ return proc.returncode, out[-4000:]
62
+
63
+
64
+ def git_root(project: Path) -> Path | None:
65
+ code, out = _run(project, ["git", "rev-parse", "--show-toplevel"])
66
+ if code != 0:
67
+ return None
68
+ try:
69
+ return Path(out.splitlines()[0]).resolve()
70
+ except (IndexError, OSError):
71
+ return None
72
+
73
+
74
+ def _in_project(project: Path) -> str:
75
+ root = git_root(project)
76
+ if root is None:
77
+ return "not a git repository"
78
+ if root != project.resolve():
79
+ return f"git root is {root}, not {project} — refuse"
80
+ return ""
81
+
82
+
83
+ def current_branch(project: Path) -> str:
84
+ code, out = _run(project, ["git", "rev-parse", "--abbrev-ref", "HEAD"])
85
+ return out.splitlines()[0] if code == 0 and out else ""
86
+
87
+
88
+ def github_viewer(project: Path) -> str:
89
+ """The signed-in `gh` login, or empty when gh is missing or logged out."""
90
+ code, out = _run(project, ["gh", "api", "user"], keep_all=True)
91
+ if code != 0:
92
+ return ""
93
+ login, _name, _email = identity_from_user_json(out)
94
+ return login
95
+
96
+
97
+ def _view(project: Path, kind: str, number: str) -> str:
98
+ """Read one issue or pull request, and say where it points in this project.
99
+
100
+ Uses the signed-in gh user so comments from other users on the same
101
+ ticket are visible — the same account that can see them in the browser.
102
+ """
103
+ if not number.isdigit():
104
+ return f"{kind} needs Number: (digits)"
105
+ fields = "number,title,body,state,comments"
106
+ if kind == "pr":
107
+ fields += ",files,reviews"
108
+ code, out = _run(
109
+ project, ["gh", kind, "view", number, "--json", fields], keep_all=True
110
+ )
111
+ if code != 0:
112
+ return out or f"gh {kind} view {number} failed"
113
+ ticket = parse_ticket(
114
+ out,
115
+ project,
116
+ kind="pull request" if kind == "pr" else "issue",
117
+ viewer=github_viewer(project),
118
+ )
119
+ if ticket is None:
120
+ return out[:3500]
121
+ return render_ticket(ticket)[:3500]
122
+
123
+
124
+ def read_issue(project: Path, number: str) -> str:
125
+ return _view(project, "issue", number)
126
+
127
+
128
+ def read_pr(project: Path, number: str) -> str:
129
+ return _view(project, "pr", number)
130
+
131
+
132
+ def read_ticket(project: Path, number: str, *, prefer: str = "issue") -> str:
133
+ """Read an issue, or a pull request when the task named a PR."""
134
+ first = read_pr if prefer == "pr" else read_issue
135
+ second = read_issue if prefer == "pr" else read_pr
136
+ out = first(project, number)
137
+ failed = "failed" in out.lower() or "could not" in out.lower() or "needs Number" in out
138
+ if failed:
139
+ other = second(project, number)
140
+ if "failed" not in other.lower() and "could not" not in other.lower():
141
+ return other
142
+ return out
143
+
144
+
145
+ def make_branch(project: Path, name: str) -> str:
146
+ blocked = _in_project(project)
147
+ if blocked:
148
+ return blocked
149
+ name = name.strip().lstrip("/")
150
+ if not _BRANCH.match(name) or name in PROTECTED or name.startswith("-"):
151
+ return (
152
+ "bad branch name. Use proceed/short-slug "
153
+ "(letters, digits, . _ / -). Not main or master."
154
+ )
155
+ code, out = _run(project, ["git", "checkout", "-B", name])
156
+ return out or f"now on {name}" if code == 0 else out
157
+
158
+
159
+ def commit_changes(project: Path, summary: str) -> str:
160
+ blocked = _in_project(project)
161
+ if blocked:
162
+ return blocked
163
+ message = " ".join(summary.strip().split())
164
+ if len(message) < 8:
165
+ return "commit needs Summary: of at least 8 characters (why, not what)"
166
+ _run(project, ["git", "add", "-A"])
167
+ for rel in SECRET_NAMES:
168
+ path = project / rel
169
+ if path.exists() or path.is_symlink():
170
+ _run(project, ["git", "reset", "-q", "--", rel])
171
+ code, staged = _run(project, ["git", "diff", "--cached", "--name-only"])
172
+ names = [line for line in staged.splitlines() if line.strip()] if code == 0 else []
173
+ if any(Path(name).name in SECRET_NAMES for name in names):
174
+ return "refusing to commit secret filenames"
175
+ if not names:
176
+ return "nothing to commit"
177
+ code, out = _run(project, ["git", "commit", "-m", with_co_author(message)])
178
+ return out or "committed" if code == 0 else out
179
+
180
+
181
+ def push_branch(project: Path) -> str:
182
+ blocked = _in_project(project)
183
+ if blocked:
184
+ return blocked
185
+ branch = current_branch(project)
186
+ if not branch or branch in PROTECTED:
187
+ return f"refusing to push {branch or 'detached'} (not main/master)"
188
+ code, remotes = _run(project, ["git", "remote"])
189
+ if code != 0 or "origin" not in remotes.split():
190
+ return "no origin remote. Add origin or push yourself."
191
+ code, out = _run(project, ["git", "push", "-u", "origin", "HEAD"])
192
+ return out or "pushed" if code == 0 else out
193
+
194
+
195
+ def create_pr(project: Path, title: str, body: str) -> str:
196
+ blocked = _in_project(project)
197
+ if blocked:
198
+ return blocked
199
+ branch = current_branch(project)
200
+ if branch in PROTECTED:
201
+ return "refusing to open a PR from main/master. Action: branch first."
202
+ title = " ".join(title.strip().split())
203
+ if len(title) < 8:
204
+ return "pr needs Title: of at least 8 characters"
205
+ text = (body.strip() or title) + PR_FOOTER
206
+ code, out = _run(
207
+ project,
208
+ ["gh", "pr", "create", "--title", title, "--body", text],
209
+ )
210
+ return out or "opened pull request" if code == 0 else out
211
+
212
+
213
+ PR_FIELDS = "title,mergeable,mergeStateStatus,statusCheckRollup,author"
214
+
215
+
216
+ def read_pr_state(project: Path, number: str) -> dict:
217
+ """What GitHub currently says about this pull request. {} when unknown."""
218
+ code, out = _run(
219
+ project,
220
+ ["gh", "pr", "view", number, "--json", PR_FIELDS],
221
+ keep_all=True,
222
+ )
223
+ if code != 0:
224
+ return {}
225
+ try:
226
+ loaded = json.loads(out)
227
+ except json.JSONDecodeError:
228
+ return {}
229
+ return loaded if isinstance(loaded, dict) else {}
230
+
231
+
232
+ def merge_pr(project: Path, number: str, *, allowed: bool) -> str:
233
+ if not allowed:
234
+ return "merge only when the task says merge"
235
+ if not number.isdigit():
236
+ return "merge needs Number: (PR digits)"
237
+ blocked = _in_project(project)
238
+ if blocked:
239
+ return blocked
240
+ state = read_pr_state(project, number)
241
+ # An empty read means gh could not answer. Merging anyway would make
242
+ # every check here optional the moment the network hiccups.
243
+ if not state:
244
+ return f"cannot read #{number} from GitHub, so not merging it"
245
+ refused = refuse_bot_merge(state, project)
246
+ if refused:
247
+ return f"not merging #{number}: {refused}"
248
+ code, out = _run(
249
+ project,
250
+ [
251
+ "gh",
252
+ "pr",
253
+ "merge",
254
+ number,
255
+ "--merge",
256
+ "--subject",
257
+ f"Merge pull request #{number}",
258
+ "--body",
259
+ with_co_author(f"Merged #{number}."),
260
+ ],
261
+ )
262
+ return out or f"merged #{number}" if code == 0 else out