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.
Files changed (57) hide show
  1. c3/__init__.py +3 -0
  2. c3/__main__.py +4 -0
  3. c3/_template/.claude/CLAUDE.md +182 -0
  4. c3/_template/.claude/agents/architect.md +50 -0
  5. c3/_template/.claude/agents/code-reviewer.md +50 -0
  6. c3/_template/.claude/agents/developer.md +55 -0
  7. c3/_template/.claude/agents/doc-writer.md +62 -0
  8. c3/_template/.claude/agents/interviewer.md +46 -0
  9. c3/_template/.claude/agents/planner.md +59 -0
  10. c3/_template/.claude/agents/project-setup.md +106 -0
  11. c3/_template/.claude/agents/security-reviewer.md +51 -0
  12. c3/_template/.claude/agents/tdd-develop.md +117 -0
  13. c3/_template/.claude/agents/tester.md +48 -0
  14. c3/_template/.claude/commands/develop.md +10 -0
  15. c3/_template/.claude/commands/doc.md +174 -0
  16. c3/_template/.claude/commands/extract-lib.md +292 -0
  17. c3/_template/.claude/commands/init-session.md +110 -0
  18. c3/_template/.claude/commands/mcp.md +322 -0
  19. c3/_template/.claude/commands/promote-pattern.md +135 -0
  20. c3/_template/.claude/commands/review.md +9 -0
  21. c3/_template/.claude/commands/setup.md +206 -0
  22. c3/_template/.claude/commands/start.md +88 -0
  23. c3/_template/.claude/docs/parallel-orchestra-manifest.md +108 -0
  24. c3/_template/.claude/hooks/clear_file_history.py +39 -0
  25. c3/_template/.claude/hooks/enable_sandbox.py +61 -0
  26. c3/_template/.claude/hooks/pre_compact.py +82 -0
  27. c3/_template/.claude/hooks/pre_tool.py +64 -0
  28. c3/_template/.claude/hooks/statusline.py +170 -0
  29. c3/_template/.claude/hooks/stop.py +202 -0
  30. c3/_template/.claude/hooks/validate_skill_change.py +33 -0
  31. c3/_template/.claude/hooks/worktree_guard.py +53 -0
  32. c3/_template/.claude/memory/.gitkeep +0 -0
  33. c3/_template/.claude/rules/code-review-checklist.md +91 -0
  34. c3/_template/.claude/rules/promoted/index.md +5 -0
  35. c3/_template/.claude/rules/security-review-checklist.md +84 -0
  36. c3/_template/.claude/settings.json +136 -0
  37. c3/_template/.claude/settings.local.json +126 -0
  38. c3/_template/.claude/skills/dev-workflow.md +484 -0
  39. c3/_template/.claude/skills/parallel-execution.md +121 -0
  40. c3/_template/.claude/skills/promoted/index.md +5 -0
  41. c3/_template/.claude/skills/worktree-tdd-workflow.md +71 -0
  42. c3/cli.py +63 -0
  43. c3/cli_doctor.py +135 -0
  44. c3/cli_init.py +70 -0
  45. c3/cli_list.py +69 -0
  46. c3/cli_po.py +102 -0
  47. c3/cli_update.py +117 -0
  48. c3/paths.py +64 -0
  49. c3/po/__init__.py +11 -0
  50. c3/po/detect.py +44 -0
  51. c3/po/manifest.py +336 -0
  52. c3/po/run.py +105 -0
  53. claude_code_conductor-0.2.0.dist-info/METADATA +362 -0
  54. claude_code_conductor-0.2.0.dist-info/RECORD +57 -0
  55. claude_code_conductor-0.2.0.dist-info/WHEEL +4 -0
  56. claude_code_conductor-0.2.0.dist-info/entry_points.txt +2 -0
  57. 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
+ )