claude-code-conductor 0.2.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.
- c3/__init__.py +3 -0
- c3/__main__.py +4 -0
- c3/_template/.claude/CLAUDE.md +182 -0
- c3/_template/.claude/agents/architect.md +50 -0
- c3/_template/.claude/agents/code-reviewer.md +50 -0
- c3/_template/.claude/agents/developer.md +55 -0
- c3/_template/.claude/agents/doc-writer.md +62 -0
- c3/_template/.claude/agents/interviewer.md +46 -0
- c3/_template/.claude/agents/planner.md +59 -0
- c3/_template/.claude/agents/project-setup.md +106 -0
- c3/_template/.claude/agents/security-reviewer.md +51 -0
- c3/_template/.claude/agents/tdd-develop.md +117 -0
- c3/_template/.claude/agents/tester.md +48 -0
- c3/_template/.claude/commands/develop.md +10 -0
- c3/_template/.claude/commands/doc.md +174 -0
- c3/_template/.claude/commands/extract-lib.md +292 -0
- c3/_template/.claude/commands/init-session.md +110 -0
- c3/_template/.claude/commands/mcp.md +322 -0
- c3/_template/.claude/commands/promote-pattern.md +135 -0
- c3/_template/.claude/commands/review.md +9 -0
- c3/_template/.claude/commands/setup.md +206 -0
- c3/_template/.claude/commands/start.md +88 -0
- c3/_template/.claude/docs/parallel-orchestra-manifest.md +108 -0
- c3/_template/.claude/hooks/clear_file_history.py +39 -0
- c3/_template/.claude/hooks/enable_sandbox.py +61 -0
- c3/_template/.claude/hooks/pre_compact.py +82 -0
- c3/_template/.claude/hooks/pre_tool.py +64 -0
- c3/_template/.claude/hooks/statusline.py +170 -0
- c3/_template/.claude/hooks/stop.py +202 -0
- c3/_template/.claude/hooks/validate_skill_change.py +33 -0
- c3/_template/.claude/hooks/worktree_guard.py +53 -0
- c3/_template/.claude/memory/.gitkeep +0 -0
- c3/_template/.claude/rules/code-review-checklist.md +91 -0
- c3/_template/.claude/rules/promoted/index.md +5 -0
- c3/_template/.claude/rules/security-review-checklist.md +84 -0
- c3/_template/.claude/settings.json +136 -0
- c3/_template/.claude/settings.local.json +126 -0
- c3/_template/.claude/skills/dev-workflow.md +484 -0
- c3/_template/.claude/skills/parallel-execution.md +121 -0
- c3/_template/.claude/skills/promoted/index.md +5 -0
- c3/_template/.claude/skills/worktree-tdd-workflow.md +71 -0
- c3/cli.py +63 -0
- c3/cli_doctor.py +135 -0
- c3/cli_init.py +70 -0
- c3/cli_list.py +69 -0
- c3/cli_po.py +102 -0
- c3/cli_update.py +117 -0
- c3/paths.py +64 -0
- c3/po/__init__.py +11 -0
- c3/po/detect.py +44 -0
- c3/po/manifest.py +336 -0
- c3/po/run.py +105 -0
- claude_code_conductor-0.2.0.dist-info/METADATA +362 -0
- claude_code_conductor-0.2.0.dist-info/RECORD +57 -0
- claude_code_conductor-0.2.0.dist-info/WHEEL +4 -0
- claude_code_conductor-0.2.0.dist-info/entry_points.txt +2 -0
- claude_code_conductor-0.2.0.dist-info/licenses/LICENSE +21 -0
c3/po/manifest.py
ADDED
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
"""Read and validate parallel-orchestra YAML frontmatter on plan-report files.
|
|
2
|
+
|
|
3
|
+
This module deliberately avoids PyYAML so that C3's runtime has no third-party
|
|
4
|
+
dependency. The parser implements the subset of YAML used by the PO 0.1 schema:
|
|
5
|
+
|
|
6
|
+
- block mappings (``key: value``)
|
|
7
|
+
- block sequences (``- item``)
|
|
8
|
+
- scalars: strings (with optional quotes), ints, floats, booleans, null
|
|
9
|
+
- multi-line literal scalars (``key: |``)
|
|
10
|
+
- nested mappings and sequences
|
|
11
|
+
|
|
12
|
+
Final validation is delegated to ``parallel-orchestra run --dry-run``; this
|
|
13
|
+
module performs C3-side preflight only.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import re
|
|
19
|
+
import sys
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from typing import Any
|
|
22
|
+
|
|
23
|
+
_FRONTMATTER_RE = re.compile(r"^---\s*\n(.*?)\n---\s*(?:\n|\Z)", re.DOTALL)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
# ---------------------------------------------------------------------------
|
|
27
|
+
# Frontmatter extraction
|
|
28
|
+
# ---------------------------------------------------------------------------
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def extract_frontmatter(plan_report_path: Path) -> dict | None:
|
|
32
|
+
"""Return the parsed YAML frontmatter dict, or ``None`` if absent/malformed."""
|
|
33
|
+
try:
|
|
34
|
+
text = plan_report_path.read_text(encoding="utf-8")
|
|
35
|
+
except OSError:
|
|
36
|
+
return None
|
|
37
|
+
match = _FRONTMATTER_RE.match(text)
|
|
38
|
+
if not match:
|
|
39
|
+
return None
|
|
40
|
+
body = match.group(1)
|
|
41
|
+
try:
|
|
42
|
+
return _parse_yaml(body)
|
|
43
|
+
except _ParseError:
|
|
44
|
+
return None
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
# ---------------------------------------------------------------------------
|
|
48
|
+
# Validation
|
|
49
|
+
# ---------------------------------------------------------------------------
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def validate_manifest(plan_report_path: Path, claude_root: Path) -> list[str]:
|
|
53
|
+
"""Run C3-side preflight checks. Returns a list of error strings (empty = OK).
|
|
54
|
+
|
|
55
|
+
``claude_root`` is the directory that contains the ``.claude/`` folder
|
|
56
|
+
(i.e. the project root).
|
|
57
|
+
"""
|
|
58
|
+
errors: list[str] = []
|
|
59
|
+
fm = extract_frontmatter(plan_report_path)
|
|
60
|
+
if fm is None:
|
|
61
|
+
return [
|
|
62
|
+
f"frontmatter missing or malformed in {plan_report_path}. "
|
|
63
|
+
"Re-run /start Phase C to regenerate the plan-report."
|
|
64
|
+
]
|
|
65
|
+
|
|
66
|
+
version = fm.get("po_plan_version")
|
|
67
|
+
if version != "0.1":
|
|
68
|
+
errors.append(
|
|
69
|
+
f"unsupported po_plan_version: {version!r} (expected '0.1')"
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
if not isinstance(fm.get("name"), str) or not fm["name"]:
|
|
73
|
+
errors.append("`name` is required and must be a non-empty string")
|
|
74
|
+
|
|
75
|
+
cwd = fm.get("cwd")
|
|
76
|
+
if not isinstance(cwd, str) or not cwd:
|
|
77
|
+
errors.append("`cwd` is required and must be a non-empty string")
|
|
78
|
+
|
|
79
|
+
tasks = fm.get("tasks")
|
|
80
|
+
if not isinstance(tasks, list) or not tasks:
|
|
81
|
+
errors.append("`tasks` is required and must contain at least one entry")
|
|
82
|
+
return errors
|
|
83
|
+
|
|
84
|
+
seen_ids: set[str] = set()
|
|
85
|
+
agents_dir = claude_root / ".claude" / "agents"
|
|
86
|
+
for index, task in enumerate(tasks):
|
|
87
|
+
if not isinstance(task, dict):
|
|
88
|
+
errors.append(f"tasks[{index}] must be a mapping")
|
|
89
|
+
continue
|
|
90
|
+
prefix = f"tasks[{index}]"
|
|
91
|
+
task_id = task.get("id")
|
|
92
|
+
if not isinstance(task_id, str) or not _ID_RE.match(task_id):
|
|
93
|
+
errors.append(
|
|
94
|
+
f"{prefix}.id must match [A-Za-z0-9_-]+ (got {task_id!r})"
|
|
95
|
+
)
|
|
96
|
+
elif task_id in seen_ids:
|
|
97
|
+
errors.append(f"duplicate task id: {task_id!r}")
|
|
98
|
+
else:
|
|
99
|
+
seen_ids.add(task_id)
|
|
100
|
+
|
|
101
|
+
agent = task.get("agent")
|
|
102
|
+
if not isinstance(agent, str) or not agent:
|
|
103
|
+
errors.append(f"{prefix}.agent is required and must be a string")
|
|
104
|
+
elif not (agents_dir / f"{agent}.md").is_file():
|
|
105
|
+
errors.append(
|
|
106
|
+
f"{prefix}.agent {agent!r} not found at {agents_dir / f'{agent}.md'}"
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
if "read_only" not in task or not isinstance(task["read_only"], bool):
|
|
110
|
+
errors.append(f"{prefix}.read_only is required and must be a boolean")
|
|
111
|
+
|
|
112
|
+
prompt = task.get("prompt")
|
|
113
|
+
if not isinstance(prompt, str) or not prompt.strip():
|
|
114
|
+
errors.append(f"{prefix}.prompt is required and must be non-empty")
|
|
115
|
+
|
|
116
|
+
depends_on = task.get("depends_on")
|
|
117
|
+
if depends_on is not None and not (
|
|
118
|
+
isinstance(depends_on, list)
|
|
119
|
+
and all(isinstance(d, str) for d in depends_on)
|
|
120
|
+
):
|
|
121
|
+
errors.append(f"{prefix}.depends_on must be a list of strings")
|
|
122
|
+
|
|
123
|
+
return errors
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
_ID_RE = re.compile(r"^[A-Za-z0-9_-]+$")
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
# ---------------------------------------------------------------------------
|
|
130
|
+
# Minimal YAML subset parser
|
|
131
|
+
# ---------------------------------------------------------------------------
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
class _ParseError(Exception):
|
|
135
|
+
pass
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _parse_yaml(text: str) -> dict:
|
|
139
|
+
lines = _preprocess(text)
|
|
140
|
+
value, consumed = _parse_block(lines, 0, 0)
|
|
141
|
+
if consumed != len(lines):
|
|
142
|
+
# Trailing content - tolerate (it could be whitespace).
|
|
143
|
+
pass
|
|
144
|
+
if not isinstance(value, dict):
|
|
145
|
+
raise _ParseError("top-level YAML must be a mapping")
|
|
146
|
+
return value
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _preprocess(text: str) -> list[tuple[int, str]]:
|
|
150
|
+
"""Strip blank/comment lines, return [(indent, content)]."""
|
|
151
|
+
out: list[tuple[int, str]] = []
|
|
152
|
+
for raw in text.splitlines():
|
|
153
|
+
stripped = raw.lstrip(" ")
|
|
154
|
+
indent = len(raw) - len(stripped)
|
|
155
|
+
if not stripped or stripped.startswith("#"):
|
|
156
|
+
continue
|
|
157
|
+
out.append((indent, stripped))
|
|
158
|
+
return out
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _parse_block(
|
|
162
|
+
lines: list[tuple[int, str]], start: int, base_indent: int
|
|
163
|
+
) -> tuple[Any, int]:
|
|
164
|
+
if start >= len(lines):
|
|
165
|
+
return {}, start
|
|
166
|
+
first_indent, first = lines[start]
|
|
167
|
+
if first_indent < base_indent:
|
|
168
|
+
return {}, start
|
|
169
|
+
if first.startswith("- "):
|
|
170
|
+
return _parse_sequence(lines, start, first_indent)
|
|
171
|
+
return _parse_mapping(lines, start, first_indent)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _parse_mapping(
|
|
175
|
+
lines: list[tuple[int, str]], start: int, indent: int
|
|
176
|
+
) -> tuple[dict, int]:
|
|
177
|
+
result: dict[str, Any] = {}
|
|
178
|
+
idx = start
|
|
179
|
+
while idx < len(lines):
|
|
180
|
+
cur_indent, content = lines[idx]
|
|
181
|
+
if cur_indent < indent:
|
|
182
|
+
break
|
|
183
|
+
if cur_indent > indent:
|
|
184
|
+
raise _ParseError(f"unexpected indent at line {idx}: {content!r}")
|
|
185
|
+
if content.startswith("- "):
|
|
186
|
+
raise _ParseError(f"sequence item where mapping expected: {content!r}")
|
|
187
|
+
key, sep, rest = content.partition(":")
|
|
188
|
+
if not sep:
|
|
189
|
+
raise _ParseError(f"missing ':' in mapping line: {content!r}")
|
|
190
|
+
key = key.strip()
|
|
191
|
+
rest = rest.lstrip()
|
|
192
|
+
idx += 1
|
|
193
|
+
if rest == "" or rest is None:
|
|
194
|
+
value, idx = _parse_block(lines, idx, indent + 1)
|
|
195
|
+
result[key] = value
|
|
196
|
+
elif rest == "|":
|
|
197
|
+
value, idx = _parse_literal(lines, idx, indent + 1)
|
|
198
|
+
result[key] = value
|
|
199
|
+
else:
|
|
200
|
+
result[key] = _scalar(rest)
|
|
201
|
+
return result, idx
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _parse_sequence(
|
|
205
|
+
lines: list[tuple[int, str]], start: int, indent: int
|
|
206
|
+
) -> tuple[list, int]:
|
|
207
|
+
result: list[Any] = []
|
|
208
|
+
idx = start
|
|
209
|
+
while idx < len(lines):
|
|
210
|
+
cur_indent, content = lines[idx]
|
|
211
|
+
if cur_indent < indent:
|
|
212
|
+
break
|
|
213
|
+
if cur_indent > indent:
|
|
214
|
+
raise _ParseError(f"unexpected indent at line {idx}: {content!r}")
|
|
215
|
+
if not content.startswith("- "):
|
|
216
|
+
break
|
|
217
|
+
item_body = content[2:]
|
|
218
|
+
idx += 1
|
|
219
|
+
if not item_body:
|
|
220
|
+
value, idx = _parse_block(lines, idx, indent + 1)
|
|
221
|
+
result.append(value)
|
|
222
|
+
continue
|
|
223
|
+
if ":" in item_body and not item_body.startswith(("'", '"')):
|
|
224
|
+
# Inline first key of a mapping item, e.g. "- id: foo"
|
|
225
|
+
inline_key, _, inline_rest = item_body.partition(":")
|
|
226
|
+
inline_key = inline_key.strip()
|
|
227
|
+
inline_rest = inline_rest.lstrip()
|
|
228
|
+
mapping: dict[str, Any] = {}
|
|
229
|
+
if inline_rest == "":
|
|
230
|
+
# Continue parsing the mapping with deeper indent.
|
|
231
|
+
deeper, idx = _parse_block(lines, idx, indent + 2)
|
|
232
|
+
mapping[inline_key] = deeper if deeper != {} else None
|
|
233
|
+
elif inline_rest == "|":
|
|
234
|
+
value, idx = _parse_literal(lines, idx, indent + 2)
|
|
235
|
+
mapping[inline_key] = value
|
|
236
|
+
else:
|
|
237
|
+
mapping[inline_key] = _scalar(inline_rest)
|
|
238
|
+
# Parse any further mapping fields that follow at indent+2.
|
|
239
|
+
while idx < len(lines):
|
|
240
|
+
next_indent, next_content = lines[idx]
|
|
241
|
+
if next_indent <= indent:
|
|
242
|
+
break
|
|
243
|
+
if next_content.startswith("- "):
|
|
244
|
+
break
|
|
245
|
+
k2, sep2, r2 = next_content.partition(":")
|
|
246
|
+
if not sep2:
|
|
247
|
+
raise _ParseError(
|
|
248
|
+
f"missing ':' in mapping continuation: {next_content!r}"
|
|
249
|
+
)
|
|
250
|
+
k2 = k2.strip()
|
|
251
|
+
r2 = r2.lstrip()
|
|
252
|
+
idx += 1
|
|
253
|
+
if r2 == "":
|
|
254
|
+
val, idx = _parse_block(lines, idx, next_indent + 1)
|
|
255
|
+
mapping[k2] = val
|
|
256
|
+
elif r2 == "|":
|
|
257
|
+
val, idx = _parse_literal(lines, idx, next_indent + 1)
|
|
258
|
+
mapping[k2] = val
|
|
259
|
+
else:
|
|
260
|
+
mapping[k2] = _scalar(r2)
|
|
261
|
+
result.append(mapping)
|
|
262
|
+
else:
|
|
263
|
+
result.append(_scalar(item_body))
|
|
264
|
+
return result, idx
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def _parse_literal(
|
|
268
|
+
lines: list[tuple[int, str]], start: int, base_indent: int
|
|
269
|
+
) -> tuple[str, int]:
|
|
270
|
+
"""Parse a multi-line literal scalar (``|``)."""
|
|
271
|
+
body: list[str] = []
|
|
272
|
+
idx = start
|
|
273
|
+
while idx < len(lines):
|
|
274
|
+
cur_indent, content = lines[idx]
|
|
275
|
+
if cur_indent < base_indent:
|
|
276
|
+
break
|
|
277
|
+
body.append(" " * (cur_indent - base_indent) + content)
|
|
278
|
+
idx += 1
|
|
279
|
+
return "\n".join(body), idx
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def _scalar(text: str) -> Any:
|
|
283
|
+
text = text.strip()
|
|
284
|
+
if text.startswith('"') and text.endswith('"') and len(text) >= 2:
|
|
285
|
+
return text[1:-1]
|
|
286
|
+
if text.startswith("'") and text.endswith("'") and len(text) >= 2:
|
|
287
|
+
return text[1:-1]
|
|
288
|
+
lower = text.lower()
|
|
289
|
+
if lower in ("true", "yes"):
|
|
290
|
+
return True
|
|
291
|
+
if lower in ("false", "no"):
|
|
292
|
+
return False
|
|
293
|
+
if lower in ("null", "~", ""):
|
|
294
|
+
return None
|
|
295
|
+
try:
|
|
296
|
+
if "." in text:
|
|
297
|
+
return float(text)
|
|
298
|
+
return int(text)
|
|
299
|
+
except ValueError:
|
|
300
|
+
return text
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
# ---------------------------------------------------------------------------
|
|
304
|
+
# CLI helper
|
|
305
|
+
# ---------------------------------------------------------------------------
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def main(argv: list[str] | None = None) -> int:
|
|
309
|
+
"""``python -m c3.po.manifest validate <plan-report>``."""
|
|
310
|
+
args = list(argv) if argv is not None else sys.argv[1:]
|
|
311
|
+
if len(args) != 2 or args[0] != "validate":
|
|
312
|
+
print("usage: python -m c3.po.manifest validate <plan-report-path>", file=sys.stderr)
|
|
313
|
+
return 2
|
|
314
|
+
plan_report = Path(args[1]).resolve()
|
|
315
|
+
if not plan_report.is_file():
|
|
316
|
+
print(f"plan-report not found: {plan_report}", file=sys.stderr)
|
|
317
|
+
return 2
|
|
318
|
+
|
|
319
|
+
# Walk up to find .claude/ to locate agents/.
|
|
320
|
+
from c3.paths import claude_root_for
|
|
321
|
+
|
|
322
|
+
root = claude_root_for(plan_report.parent) or claude_root_for(Path.cwd())
|
|
323
|
+
if root is None:
|
|
324
|
+
print("could not locate .claude/ directory", file=sys.stderr)
|
|
325
|
+
return 2
|
|
326
|
+
|
|
327
|
+
errors = validate_manifest(plan_report, root)
|
|
328
|
+
if not errors:
|
|
329
|
+
return 0
|
|
330
|
+
for err in errors:
|
|
331
|
+
print(err, file=sys.stderr)
|
|
332
|
+
return 1
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
if __name__ == "__main__":
|
|
336
|
+
sys.exit(main())
|
c3/po/run.py
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""Subprocess wrapper for ``parallel-orchestra run``.
|
|
2
|
+
|
|
3
|
+
Always invoked with ``shell=False`` and an argv list. The wrapper does not
|
|
4
|
+
import ``parallel_orchestra`` directly - the only dependency on PO is the
|
|
5
|
+
CLI executable on PATH.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import collections
|
|
11
|
+
import subprocess
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Literal
|
|
15
|
+
|
|
16
|
+
RunStatus = Literal[
|
|
17
|
+
"ok", "task_failure", "manifest_invalid", "runner_error", "not_installed"
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
_EXIT_TO_STATUS: dict[int, RunStatus] = {
|
|
21
|
+
0: "ok",
|
|
22
|
+
1: "task_failure",
|
|
23
|
+
2: "manifest_invalid",
|
|
24
|
+
3: "runner_error",
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass(frozen=True)
|
|
29
|
+
class RunResult:
|
|
30
|
+
exit_code: int
|
|
31
|
+
status: RunStatus
|
|
32
|
+
report_path: Path | None
|
|
33
|
+
stderr_tail: str | None # last ~40 lines, populated only on non-zero exit
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def run_manifest(
|
|
37
|
+
manifest_path: Path | str,
|
|
38
|
+
*,
|
|
39
|
+
max_workers: int | None = None,
|
|
40
|
+
report: Path | str | None = None,
|
|
41
|
+
quiet: bool = False,
|
|
42
|
+
dry_run: bool = False,
|
|
43
|
+
claude_exe: str | None = None,
|
|
44
|
+
cli: str = "parallel-orchestra",
|
|
45
|
+
) -> RunResult:
|
|
46
|
+
"""Invoke ``parallel-orchestra run <manifest>`` and return a typed result.
|
|
47
|
+
|
|
48
|
+
Streams stdout/stderr to the parent terminal so PO's progress dashboard
|
|
49
|
+
is visible to the user. The last 40 stderr lines are captured separately
|
|
50
|
+
for failure summaries.
|
|
51
|
+
"""
|
|
52
|
+
manifest_path = Path(manifest_path)
|
|
53
|
+
report_path = Path(report) if report is not None else None
|
|
54
|
+
|
|
55
|
+
argv: list[str] = [cli, "run", str(manifest_path)]
|
|
56
|
+
if max_workers is not None:
|
|
57
|
+
argv.extend(["--max-workers", str(max_workers)])
|
|
58
|
+
if report_path is not None:
|
|
59
|
+
argv.extend(["--report", str(report_path)])
|
|
60
|
+
if quiet:
|
|
61
|
+
argv.append("--quiet")
|
|
62
|
+
if dry_run:
|
|
63
|
+
argv.append("--dry-run")
|
|
64
|
+
if claude_exe is not None:
|
|
65
|
+
argv.extend(["--claude-exe", claude_exe])
|
|
66
|
+
|
|
67
|
+
stderr_tail = collections.deque(maxlen=40)
|
|
68
|
+
|
|
69
|
+
try:
|
|
70
|
+
process = subprocess.Popen(
|
|
71
|
+
argv,
|
|
72
|
+
shell=False,
|
|
73
|
+
text=True,
|
|
74
|
+
stdout=None,
|
|
75
|
+
stderr=subprocess.PIPE,
|
|
76
|
+
bufsize=1,
|
|
77
|
+
)
|
|
78
|
+
except FileNotFoundError:
|
|
79
|
+
return RunResult(
|
|
80
|
+
exit_code=-1,
|
|
81
|
+
status="not_installed",
|
|
82
|
+
report_path=None,
|
|
83
|
+
stderr_tail=None,
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
assert process.stderr is not None
|
|
87
|
+
try:
|
|
88
|
+
for line in process.stderr:
|
|
89
|
+
print(line, end="", flush=True, file=__import__("sys").stderr)
|
|
90
|
+
stderr_tail.append(line.rstrip("\n"))
|
|
91
|
+
finally:
|
|
92
|
+
process.stderr.close()
|
|
93
|
+
exit_code = process.wait()
|
|
94
|
+
|
|
95
|
+
status: RunStatus = _EXIT_TO_STATUS.get(exit_code, "runner_error")
|
|
96
|
+
tail: str | None = None
|
|
97
|
+
if exit_code != 0 and stderr_tail:
|
|
98
|
+
tail = "\n".join(stderr_tail)
|
|
99
|
+
|
|
100
|
+
return RunResult(
|
|
101
|
+
exit_code=exit_code,
|
|
102
|
+
status=status,
|
|
103
|
+
report_path=report_path,
|
|
104
|
+
stderr_tail=tail,
|
|
105
|
+
)
|