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/cli.py ADDED
@@ -0,0 +1,63 @@
1
+ """C3 CLI entry point.
2
+
3
+ Each subcommand registers its parser through ``register(subparsers)`` and
4
+ exposes a ``handle(args) -> int`` function. Keeping each subcommand in its own
5
+ module (``cli_*.py``) keeps the dispatch table small and isolates the
6
+ implementation details.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import argparse
12
+ import sys
13
+
14
+ from c3 import __version__
15
+ from c3 import cli_doctor, cli_init, cli_list, cli_po, cli_update
16
+
17
+
18
+ def build_parser() -> argparse.ArgumentParser:
19
+ parser = argparse.ArgumentParser(
20
+ prog="c3",
21
+ description="Claude Code Conductor - multi-agent orchestration framework",
22
+ )
23
+ parser.add_argument(
24
+ "--version",
25
+ action="version",
26
+ version=f"c3 {__version__}",
27
+ )
28
+ sub = parser.add_subparsers(dest="command", metavar="<command>")
29
+ sub.required = True
30
+
31
+ cli_init.register(sub)
32
+ cli_update.register(sub)
33
+ cli_list.register(sub)
34
+ cli_doctor.register(sub)
35
+ cli_po.register(sub)
36
+
37
+ return parser
38
+
39
+
40
+ def main(argv: list[str] | None = None) -> int:
41
+ _force_utf8_streams()
42
+ parser = build_parser()
43
+ args = parser.parse_args(argv)
44
+ return args.handler(args)
45
+
46
+
47
+ def _force_utf8_streams() -> None:
48
+ """Reconfigure stdout/stderr to UTF-8 so Japanese output renders correctly on Windows.
49
+
50
+ No-op on platforms where stdout is already UTF-8 or where ``reconfigure`` is unavailable.
51
+ """
52
+ for stream in (sys.stdout, sys.stderr):
53
+ reconfigure = getattr(stream, "reconfigure", None)
54
+ if reconfigure is None:
55
+ continue
56
+ try:
57
+ reconfigure(encoding="utf-8")
58
+ except (ValueError, OSError):
59
+ pass
60
+
61
+
62
+ if __name__ == "__main__":
63
+ sys.exit(main())
c3/cli_doctor.py ADDED
@@ -0,0 +1,135 @@
1
+ """``c3 doctor`` - diagnose the C3 installation health."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import os
8
+ import shutil
9
+ import sys
10
+ from pathlib import Path
11
+
12
+ from c3.paths import claude_root_for
13
+ from c3.po.detect import detect_po
14
+
15
+ _OK = "OK"
16
+ _WARN = "WARN"
17
+ _ERR = "ERR"
18
+
19
+
20
+ def register(subparsers: argparse._SubParsersAction) -> None:
21
+ parser = subparsers.add_parser(
22
+ "doctor",
23
+ help="Run diagnostics on the local C3 setup",
24
+ )
25
+ parser.add_argument(
26
+ "--check",
27
+ choices=["all", "po-only"],
28
+ default="all",
29
+ help="Limit checks to a subset (default: all)",
30
+ )
31
+ parser.add_argument(
32
+ "--quiet",
33
+ action="store_true",
34
+ help="Print only failures and warnings",
35
+ )
36
+ parser.set_defaults(handler=handle)
37
+
38
+
39
+ def handle(args: argparse.Namespace) -> int:
40
+ color = _supports_color()
41
+ findings: list[tuple[str, str, str]] = [] # (status, label, detail)
42
+
43
+ if args.check == "po-only":
44
+ findings.append(_check_po())
45
+ else:
46
+ findings.append(_check_claude_dir())
47
+ findings.append(_check_settings_json())
48
+ findings.append(_check_claude_binary())
49
+ findings.append(_check_po())
50
+
51
+ exit_code = 0
52
+ for status, label, detail in findings:
53
+ if args.quiet and status == _OK:
54
+ continue
55
+ print(_format(status, label, detail, color=color))
56
+ if status == _ERR:
57
+ exit_code = 1
58
+
59
+ return exit_code
60
+
61
+
62
+ def _check_claude_dir() -> tuple[str, str, str]:
63
+ root = claude_root_for(Path.cwd())
64
+ if root is None:
65
+ return (
66
+ _WARN,
67
+ ".claude/ directory",
68
+ "not found from cwd; run `c3 init` to scaffold one",
69
+ )
70
+ return _OK, ".claude/ directory", str(root / ".claude")
71
+
72
+
73
+ def _check_settings_json() -> tuple[str, str, str]:
74
+ root = claude_root_for(Path.cwd())
75
+ if root is None:
76
+ return _WARN, "settings.json", "skipped (.claude/ not found)"
77
+ settings = root / ".claude" / "settings.json"
78
+ if not settings.is_file():
79
+ return _WARN, "settings.json", f"missing at {settings}"
80
+ try:
81
+ json.loads(settings.read_text(encoding="utf-8"))
82
+ except json.JSONDecodeError as exc:
83
+ return _ERR, "settings.json", f"invalid JSON: {exc}"
84
+ return _OK, "settings.json", str(settings)
85
+
86
+
87
+ def _check_claude_binary() -> tuple[str, str, str]:
88
+ path = shutil.which("claude")
89
+ if path is None:
90
+ return (
91
+ _WARN,
92
+ "claude binary",
93
+ "not on PATH; install Claude Code CLI before running parallel-orchestra",
94
+ )
95
+ return _OK, "claude binary", path
96
+
97
+
98
+ def _check_po() -> tuple[str, str, str]:
99
+ available, version, cli_path = detect_po()
100
+ if available:
101
+ ver = version or "unknown version"
102
+ return _OK, "parallel-orchestra", f"{ver} at {cli_path}"
103
+ return (
104
+ _WARN,
105
+ "parallel-orchestra",
106
+ (
107
+ "not installed (optional). 並列実行を使うには "
108
+ "`pip install parallel-orchestra` を実行してください。"
109
+ "詳細: https://pypi.org/project/parallel-orchestra/"
110
+ ),
111
+ )
112
+
113
+
114
+ def _supports_color() -> bool:
115
+ if os.environ.get("NO_COLOR"):
116
+ return False
117
+ if not sys.stdout.isatty():
118
+ return False
119
+ return True
120
+
121
+
122
+ def _format(status: str, label: str, detail: str, *, color: bool) -> str:
123
+ icon = {
124
+ _OK: "[OK]",
125
+ _WARN: "[WARN]",
126
+ _ERR: "[ERR]",
127
+ }[status]
128
+ if color:
129
+ ansi = {
130
+ _OK: "\033[32m",
131
+ _WARN: "\033[33m",
132
+ _ERR: "\033[31m",
133
+ }[status]
134
+ icon = f"{ansi}{icon}\033[0m"
135
+ return f" {icon} {label}: {detail}"
c3/cli_init.py ADDED
@@ -0,0 +1,70 @@
1
+ """``c3 init`` - scaffold ``.claude/`` into the current project."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import shutil
7
+ import sys
8
+ from pathlib import Path
9
+
10
+ from c3.paths import templates_dir
11
+
12
+
13
+ def register(subparsers: argparse._SubParsersAction) -> None:
14
+ parser = subparsers.add_parser(
15
+ "init",
16
+ help="Scaffold a fresh .claude/ directory into the current project",
17
+ description=(
18
+ "Copy the bundled C3 .claude/ template into the current working "
19
+ "directory. Refuses to overwrite an existing .claude/ unless "
20
+ "--force is given."
21
+ ),
22
+ )
23
+ parser.add_argument(
24
+ "--force",
25
+ action="store_true",
26
+ help="Overwrite an existing .claude/ directory without confirmation",
27
+ )
28
+ parser.add_argument(
29
+ "--target",
30
+ type=Path,
31
+ default=None,
32
+ help="Destination directory (defaults to the current working directory)",
33
+ )
34
+ parser.set_defaults(handler=handle)
35
+
36
+
37
+ def handle(args: argparse.Namespace) -> int:
38
+ target_root: Path = (args.target or Path.cwd()).resolve()
39
+ dest = target_root / ".claude"
40
+
41
+ if dest.exists() and not args.force:
42
+ print(
43
+ f"refusing to overwrite existing directory: {dest}\n"
44
+ "Pass --force to overwrite or run `c3 update` for a diff-aware merge.",
45
+ file=sys.stderr,
46
+ )
47
+ return 1
48
+
49
+ template = templates_dir()
50
+ if dest.exists() and args.force:
51
+ shutil.rmtree(dest)
52
+
53
+ target_root.mkdir(parents=True, exist_ok=True)
54
+ copied = _copytree(template, dest)
55
+ print(f"initialized {dest} ({copied} files copied)")
56
+ return 0
57
+
58
+
59
+ def _copytree(src: Path, dst: Path) -> int:
60
+ """Copy src -> dst recursively; return number of regular files written."""
61
+ dst.mkdir(parents=True, exist_ok=True)
62
+ count = 0
63
+ for entry in src.iterdir():
64
+ target = dst / entry.name
65
+ if entry.is_dir():
66
+ count += _copytree(entry, target)
67
+ elif entry.is_file():
68
+ shutil.copy2(entry, target)
69
+ count += 1
70
+ return count
c3/cli_list.py ADDED
@@ -0,0 +1,69 @@
1
+ """``c3 list-agents`` / ``list-skills`` / ``list-commands`` - inspect installed assets."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import re
7
+ import sys
8
+ from pathlib import Path
9
+
10
+ from c3.paths import claude_root_for
11
+
12
+ _FRONTMATTER_RE = re.compile(r"^---\s*\n(.*?)\n---\s*\n", re.DOTALL)
13
+ _DESCRIPTION_RE = re.compile(r"^description:\s*(.+?)\s*$", re.MULTILINE)
14
+ _H1_RE = re.compile(r"^#\s+(.+?)\s*$", re.MULTILINE)
15
+
16
+
17
+ def register(subparsers: argparse._SubParsersAction) -> None:
18
+ for kind in ("agents", "skills", "commands"):
19
+ parser = subparsers.add_parser(
20
+ f"list-{kind}",
21
+ help=f"List installed {kind} in the project's .claude/{kind}/",
22
+ )
23
+ parser.add_argument(
24
+ "--target",
25
+ type=Path,
26
+ default=None,
27
+ help="Project root (defaults to walking up from cwd to find .claude/)",
28
+ )
29
+ parser.set_defaults(handler=handle, kind=kind)
30
+
31
+
32
+ def handle(args: argparse.Namespace) -> int:
33
+ start = (args.target or Path.cwd()).resolve()
34
+ root = claude_root_for(start)
35
+ if root is None:
36
+ print(
37
+ f"no .claude/ directory found at or above {start}",
38
+ file=sys.stderr,
39
+ )
40
+ return 1
41
+
42
+ target_dir = root / ".claude" / args.kind
43
+ if not target_dir.is_dir():
44
+ print(f"no .claude/{args.kind}/ directory at {root}", file=sys.stderr)
45
+ return 1
46
+
47
+ files = sorted(p for p in target_dir.glob("*.md") if p.is_file())
48
+ if not files:
49
+ print(f"(no {args.kind} found)")
50
+ return 0
51
+
52
+ width = max(len(p.stem) for p in files)
53
+ for path in files:
54
+ summary = _summary(path)
55
+ print(f" {path.stem.ljust(width)} {summary}")
56
+ return 0
57
+
58
+
59
+ def _summary(path: Path) -> str:
60
+ text = path.read_text(encoding="utf-8")
61
+ fm_match = _FRONTMATTER_RE.match(text)
62
+ if fm_match:
63
+ desc_match = _DESCRIPTION_RE.search(fm_match.group(1))
64
+ if desc_match:
65
+ return desc_match.group(1).strip("\"'")
66
+ h1_match = _H1_RE.search(text)
67
+ if h1_match:
68
+ return h1_match.group(1)
69
+ return ""
c3/cli_po.py ADDED
@@ -0,0 +1,102 @@
1
+ """``c3 po`` - thin wrapper around the parallel-orchestra CLI."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import sys
7
+ from pathlib import Path
8
+
9
+ from c3.paths import claude_root_for
10
+ from c3.po.detect import detect_po
11
+ from c3.po.manifest import validate_manifest
12
+ from c3.po.run import run_manifest
13
+
14
+
15
+ _NOT_INSTALLED_MSG = (
16
+ "parallel-orchestra is not installed. "
17
+ "並列実行を使うには `pip install parallel-orchestra` を実行してください。"
18
+ "詳細: https://pypi.org/project/parallel-orchestra/"
19
+ )
20
+
21
+
22
+ def register(subparsers: argparse._SubParsersAction) -> None:
23
+ parser = subparsers.add_parser(
24
+ "po",
25
+ help="Run a plan-report as a parallel-orchestra manifest",
26
+ )
27
+ inner = parser.add_subparsers(dest="po_command", metavar="<subcommand>")
28
+ inner.required = True
29
+
30
+ dry = inner.add_parser(
31
+ "dry-run",
32
+ help="Validate the manifest without executing tasks",
33
+ )
34
+ dry.add_argument("manifest", type=Path)
35
+ dry.set_defaults(handler=_handle_dry_run)
36
+
37
+ run = inner.add_parser(
38
+ "run",
39
+ help="Execute the manifest",
40
+ )
41
+ run.add_argument("manifest", type=Path)
42
+ run.add_argument("--max-workers", type=int, default=None)
43
+ run.add_argument("--report", type=Path, default=None)
44
+ run.add_argument("--quiet", action="store_true")
45
+ run.add_argument("--claude-exe", default=None)
46
+ run.set_defaults(handler=_handle_run)
47
+
48
+
49
+ def _ensure_po_available() -> int:
50
+ available, _, _ = detect_po()
51
+ if not available:
52
+ print(_NOT_INSTALLED_MSG, file=sys.stderr)
53
+ return 1
54
+ return 0
55
+
56
+
57
+ def _preflight(manifest: Path) -> int:
58
+ if not manifest.is_file():
59
+ print(f"manifest not found: {manifest}", file=sys.stderr)
60
+ return 2
61
+ root = claude_root_for(manifest.parent) or claude_root_for(Path.cwd())
62
+ if root is None:
63
+ print("could not locate .claude/ directory for agent lookup", file=sys.stderr)
64
+ return 2
65
+ errors = validate_manifest(manifest, root)
66
+ if errors:
67
+ for err in errors:
68
+ print(err, file=sys.stderr)
69
+ return 2
70
+ return 0
71
+
72
+
73
+ def _handle_dry_run(args: argparse.Namespace) -> int:
74
+ rc = _preflight(args.manifest)
75
+ if rc != 0:
76
+ return rc
77
+ if (rc := _ensure_po_available()) != 0:
78
+ return rc
79
+ result = run_manifest(args.manifest, dry_run=True)
80
+ if result.status == "not_installed":
81
+ print(_NOT_INSTALLED_MSG, file=sys.stderr)
82
+ return 1
83
+ return result.exit_code if result.exit_code >= 0 else 1
84
+
85
+
86
+ def _handle_run(args: argparse.Namespace) -> int:
87
+ rc = _preflight(args.manifest)
88
+ if rc != 0:
89
+ return rc
90
+ if (rc := _ensure_po_available()) != 0:
91
+ return rc
92
+ result = run_manifest(
93
+ args.manifest,
94
+ max_workers=args.max_workers,
95
+ report=args.report,
96
+ quiet=args.quiet,
97
+ claude_exe=args.claude_exe,
98
+ )
99
+ if result.status == "not_installed":
100
+ print(_NOT_INSTALLED_MSG, file=sys.stderr)
101
+ return 1
102
+ return result.exit_code if result.exit_code >= 0 else 1
c3/cli_update.py ADDED
@@ -0,0 +1,117 @@
1
+ """``c3 update`` - bring the project's ``.claude/`` up to date with the package template."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import filecmp
7
+ import shutil
8
+ import sys
9
+ from pathlib import Path
10
+
11
+ from c3.paths import templates_dir
12
+
13
+ # Files that the user is expected to edit locally; never overwrite them.
14
+ # These match the .gitignore entries used in the C3 source repo.
15
+ _LOCAL_FILES: tuple[str, ...] = (
16
+ "docs/decisions.md",
17
+ "docs/taxonomy.md",
18
+ "docs/game-studios-research.md",
19
+ "memory/patterns.json",
20
+ "memory/agent-audit.log",
21
+ )
22
+ _LOCAL_DIRS: tuple[str, ...] = (
23
+ "memory/sessions",
24
+ "reports",
25
+ "tmp",
26
+ )
27
+
28
+
29
+ def register(subparsers: argparse._SubParsersAction) -> None:
30
+ parser = subparsers.add_parser(
31
+ "update",
32
+ help="Refresh .claude/ from the bundled template (skips local files)",
33
+ description=(
34
+ "Compare the project's .claude/ to the bundled template and "
35
+ "overwrite framework files that differ. User-managed files "
36
+ "(reports/, memory/sessions/, docs/decisions.md, etc.) are "
37
+ "always skipped."
38
+ ),
39
+ )
40
+ parser.add_argument(
41
+ "--dry-run",
42
+ action="store_true",
43
+ help="Show what would change without writing anything",
44
+ )
45
+ parser.add_argument(
46
+ "--target",
47
+ type=Path,
48
+ default=None,
49
+ help="Destination directory (defaults to the current working directory)",
50
+ )
51
+ parser.set_defaults(handler=handle)
52
+
53
+
54
+ def handle(args: argparse.Namespace) -> int:
55
+ target_root: Path = (args.target or Path.cwd()).resolve()
56
+ dest = target_root / ".claude"
57
+ if not dest.is_dir():
58
+ print(
59
+ f"no .claude/ directory found in {target_root}. "
60
+ "Run `c3 init` first.",
61
+ file=sys.stderr,
62
+ )
63
+ return 1
64
+
65
+ template = templates_dir()
66
+ actions = list(_walk_diff(template, dest))
67
+ if not actions:
68
+ print("up to date")
69
+ return 0
70
+
71
+ if args.dry_run:
72
+ print(f"{len(actions)} file(s) would change:")
73
+ for action, path in actions:
74
+ print(f" {action}: {path.relative_to(dest)}")
75
+ return 0
76
+
77
+ for action, abs_path in actions:
78
+ rel = abs_path.relative_to(dest)
79
+ src = template / rel
80
+ if action == "add" or action == "update":
81
+ abs_path.parent.mkdir(parents=True, exist_ok=True)
82
+ shutil.copy2(src, abs_path)
83
+ print(f" {action}: {rel}")
84
+
85
+ print(f"{len(actions)} file(s) updated")
86
+ return 0
87
+
88
+
89
+ def _walk_diff(template: Path, dest: Path):
90
+ """Yield (action, absolute_dest_path) tuples for files that differ.
91
+
92
+ Only ``add`` and ``update`` are emitted; we never delete files in dest.
93
+ """
94
+ for src_file in _iter_files(template):
95
+ rel = src_file.relative_to(template)
96
+ if _is_local(rel):
97
+ continue
98
+ target = dest / rel
99
+ if not target.exists():
100
+ yield "add", target
101
+ elif not filecmp.cmp(src_file, target, shallow=False):
102
+ yield "update", target
103
+
104
+
105
+ def _iter_files(root: Path):
106
+ for entry in root.iterdir():
107
+ if entry.is_dir():
108
+ yield from _iter_files(entry)
109
+ elif entry.is_file():
110
+ yield entry
111
+
112
+
113
+ def _is_local(rel: Path) -> bool:
114
+ rel_posix = rel.as_posix()
115
+ if rel_posix in _LOCAL_FILES:
116
+ return True
117
+ return any(rel_posix == d or rel_posix.startswith(d + "/") for d in _LOCAL_DIRS)
c3/paths.py ADDED
@@ -0,0 +1,64 @@
1
+ """Path resolution helpers for C3.
2
+
3
+ Two responsibilities:
4
+ - locate the user project's ``.claude/`` directory by walking upward from a cwd
5
+ - locate the bundled ``.claude/`` template (works for both regular installs and
6
+ editable installs)
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from importlib.resources import files as _resource_files
12
+ from pathlib import Path
13
+
14
+
15
+ def claude_root_for(start: Path | str) -> Path | None:
16
+ """Walk up from ``start`` and return the nearest directory containing ``.claude/``.
17
+
18
+ Returns ``None`` if no ``.claude/`` is found before the filesystem root.
19
+ """
20
+ here = Path(start).resolve()
21
+ candidates = [here, *here.parents]
22
+ for candidate in candidates:
23
+ if (candidate / ".claude").is_dir():
24
+ return candidate
25
+ return None
26
+
27
+
28
+ def templates_dir() -> Path:
29
+ """Return the path to the bundled ``.claude/`` template.
30
+
31
+ Resolution order:
32
+
33
+ 1. Dev source: walk up from this file looking for a sibling ``.claude/``
34
+ next to a ``pyproject.toml``. This makes editable installs (``pip install
35
+ -e .``) reflect live edits to the source ``.claude/`` without rebuilding.
36
+ In a wheel-installed environment the ``.py`` files live under
37
+ ``site-packages/`` and this lookup naturally returns no match.
38
+ 2. Installed location: ``importlib.resources.files("c3") / "_template" / ".claude"``.
39
+ This is the path produced by hatchling's build hook + ``force-include``
40
+ during ``pip install``.
41
+
42
+ Raises ``FileNotFoundError`` if neither location exists. This usually means
43
+ the package was built without the template (manually copied source tree) -
44
+ reinstall via pip to fix.
45
+ """
46
+ here = Path(__file__).resolve()
47
+ for parent in here.parents:
48
+ candidate = parent / ".claude"
49
+ if candidate.is_dir() and (parent / "pyproject.toml").is_file():
50
+ return candidate
51
+
52
+ try:
53
+ bundled = _resource_files("c3").joinpath("_template", ".claude")
54
+ bundled_path = Path(str(bundled))
55
+ if bundled_path.is_dir():
56
+ return bundled_path
57
+ except (ModuleNotFoundError, FileNotFoundError, AttributeError):
58
+ pass
59
+
60
+ raise FileNotFoundError(
61
+ "Could not locate the bundled .claude/ template. "
62
+ "Reinstall claude-code-conductor with "
63
+ "`pip install --force-reinstall claude-code-conductor`."
64
+ )
c3/po/__init__.py ADDED
@@ -0,0 +1,11 @@
1
+ """parallel-orchestra (PO) integration helpers.
2
+
3
+ This package only invokes PO via its CLI (subprocess). It deliberately does
4
+ not import ``parallel_orchestra`` so that C3 stays loosely coupled and is
5
+ unaffected by PO's internal API changes.
6
+ """
7
+
8
+ from c3.po.detect import detect_po
9
+ from c3.po.run import RunResult, run_manifest
10
+
11
+ __all__ = ["detect_po", "run_manifest", "RunResult"]
c3/po/detect.py ADDED
@@ -0,0 +1,44 @@
1
+ """Runtime detection of the parallel-orchestra (PO) installation.
2
+
3
+ The authoritative signal is ``shutil.which`` because PO is invoked as a
4
+ subprocess. ``importlib.metadata`` provides the version for diagnostics but
5
+ its absence does not flip availability to False (e.g. pipx-installed PO can
6
+ expose the binary while hiding the metadata from the caller's interpreter).
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import shutil
12
+ import sys
13
+ from importlib.metadata import PackageNotFoundError, version
14
+
15
+
16
+ def detect_po() -> tuple[bool, str | None, str | None]:
17
+ """Return ``(is_available, version, cli_path)``.
18
+
19
+ ``is_available`` is ``True`` iff ``parallel-orchestra`` is on PATH.
20
+ ``version`` is the package version reported by ``importlib.metadata``,
21
+ or ``None`` if the metadata is not queryable from this interpreter.
22
+ ``cli_path`` is the absolute path returned by ``shutil.which`` (or ``None``).
23
+
24
+ Never raises.
25
+ """
26
+ cli_path = shutil.which("parallel-orchestra")
27
+ try:
28
+ ver = version("parallel-orchestra")
29
+ except PackageNotFoundError:
30
+ ver = None
31
+ return cli_path is not None, ver, cli_path
32
+
33
+
34
+ def main() -> int:
35
+ """CLI helper: print key=value lines and exit 0/1 based on availability."""
36
+ available, ver, cli_path = detect_po()
37
+ print(f"available={'true' if available else 'false'}")
38
+ print(f"version={ver if ver is not None else 'None'}")
39
+ print(f"cli_path={cli_path if cli_path is not None else 'None'}")
40
+ return 0 if available else 1
41
+
42
+
43
+ if __name__ == "__main__":
44
+ sys.exit(main())