claude-code-conductor 0.3.3__tar.gz → 0.3.4__tar.gz

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 (62) hide show
  1. {claude_code_conductor-0.3.3 → claude_code_conductor-0.3.4}/.claude/hooks/clear_file_history.py +4 -2
  2. {claude_code_conductor-0.3.3 → claude_code_conductor-0.3.4}/.claude/hooks/pre_compact.py +7 -3
  3. {claude_code_conductor-0.3.3 → claude_code_conductor-0.3.4}/.claude/hooks/pre_tool.py +8 -5
  4. {claude_code_conductor-0.3.3 → claude_code_conductor-0.3.4}/.claude/hooks/statusline.py +2 -0
  5. {claude_code_conductor-0.3.3 → claude_code_conductor-0.3.4}/.claude/hooks/stop.py +54 -30
  6. {claude_code_conductor-0.3.3 → claude_code_conductor-0.3.4}/.claude/hooks/validate_skill_change.py +4 -5
  7. claude_code_conductor-0.3.4/.claude/memory/.gitkeep +0 -0
  8. claude_code_conductor-0.3.4/.claude/pytest_temp.ini +2 -0
  9. {claude_code_conductor-0.3.3 → claude_code_conductor-0.3.4}/.claude/settings.local.json +3 -1
  10. {claude_code_conductor-0.3.3 → claude_code_conductor-0.3.4}/CHANGELOG.md +63 -0
  11. {claude_code_conductor-0.3.3 → claude_code_conductor-0.3.4}/PKG-INFO +1 -1
  12. {claude_code_conductor-0.3.3 → claude_code_conductor-0.3.4}/src/c3/__init__.py +1 -1
  13. {claude_code_conductor-0.3.3 → claude_code_conductor-0.3.4}/src/c3/cli_list.py +4 -1
  14. {claude_code_conductor-0.3.3 → claude_code_conductor-0.3.4}/src/c3/cli_po.py +20 -10
  15. {claude_code_conductor-0.3.3 → claude_code_conductor-0.3.4}/src/c3/po/manifest.py +26 -8
  16. {claude_code_conductor-0.3.3 → claude_code_conductor-0.3.4}/src/c3/po/run.py +4 -2
  17. claude_code_conductor-0.3.3/.claude/CLAUDE.md +0 -182
  18. /claude_code_conductor-0.3.3/.claude/memory/.gitkeep → /claude_code_conductor-0.3.4/.claude/CLAUDE.md +0 -0
  19. {claude_code_conductor-0.3.3 → claude_code_conductor-0.3.4}/.claude/agents/architect.md +0 -0
  20. {claude_code_conductor-0.3.3 → claude_code_conductor-0.3.4}/.claude/agents/code-reviewer.md +0 -0
  21. {claude_code_conductor-0.3.3 → claude_code_conductor-0.3.4}/.claude/agents/developer.md +0 -0
  22. {claude_code_conductor-0.3.3 → claude_code_conductor-0.3.4}/.claude/agents/doc-writer.md +0 -0
  23. {claude_code_conductor-0.3.3 → claude_code_conductor-0.3.4}/.claude/agents/interviewer.md +0 -0
  24. {claude_code_conductor-0.3.3 → claude_code_conductor-0.3.4}/.claude/agents/planner.md +0 -0
  25. {claude_code_conductor-0.3.3 → claude_code_conductor-0.3.4}/.claude/agents/project-setup.md +0 -0
  26. {claude_code_conductor-0.3.3 → claude_code_conductor-0.3.4}/.claude/agents/security-reviewer.md +0 -0
  27. {claude_code_conductor-0.3.3 → claude_code_conductor-0.3.4}/.claude/agents/tdd-develop.md +0 -0
  28. {claude_code_conductor-0.3.3 → claude_code_conductor-0.3.4}/.claude/agents/tester.md +0 -0
  29. {claude_code_conductor-0.3.3 → claude_code_conductor-0.3.4}/.claude/commands/develop.md +0 -0
  30. {claude_code_conductor-0.3.3 → claude_code_conductor-0.3.4}/.claude/commands/doc.md +0 -0
  31. {claude_code_conductor-0.3.3 → claude_code_conductor-0.3.4}/.claude/commands/extract-lib.md +0 -0
  32. {claude_code_conductor-0.3.3 → claude_code_conductor-0.3.4}/.claude/commands/init-session.md +0 -0
  33. {claude_code_conductor-0.3.3 → claude_code_conductor-0.3.4}/.claude/commands/mcp.md +0 -0
  34. {claude_code_conductor-0.3.3 → claude_code_conductor-0.3.4}/.claude/commands/promote-pattern.md +0 -0
  35. {claude_code_conductor-0.3.3 → claude_code_conductor-0.3.4}/.claude/commands/review.md +0 -0
  36. {claude_code_conductor-0.3.3 → claude_code_conductor-0.3.4}/.claude/commands/setup.md +0 -0
  37. {claude_code_conductor-0.3.3 → claude_code_conductor-0.3.4}/.claude/commands/start.md +0 -0
  38. {claude_code_conductor-0.3.3 → claude_code_conductor-0.3.4}/.claude/docs/parallel-orchestra-manifest.md +0 -0
  39. {claude_code_conductor-0.3.3 → claude_code_conductor-0.3.4}/.claude/hooks/enable_sandbox.py +0 -0
  40. {claude_code_conductor-0.3.3 → claude_code_conductor-0.3.4}/.claude/hooks/worktree_guard.py +0 -0
  41. {claude_code_conductor-0.3.3 → claude_code_conductor-0.3.4}/.claude/rules/code-review-checklist.md +0 -0
  42. {claude_code_conductor-0.3.3 → claude_code_conductor-0.3.4}/.claude/rules/promoted/index.md +0 -0
  43. {claude_code_conductor-0.3.3 → claude_code_conductor-0.3.4}/.claude/rules/security-review-checklist.md +0 -0
  44. {claude_code_conductor-0.3.3 → claude_code_conductor-0.3.4}/.claude/settings.json +0 -0
  45. {claude_code_conductor-0.3.3 → claude_code_conductor-0.3.4}/.claude/skills/dev-workflow.md +0 -0
  46. {claude_code_conductor-0.3.3 → claude_code_conductor-0.3.4}/.claude/skills/promoted/index.md +0 -0
  47. {claude_code_conductor-0.3.3 → claude_code_conductor-0.3.4}/.claude/skills/wave-execution.md +0 -0
  48. {claude_code_conductor-0.3.3 → claude_code_conductor-0.3.4}/.claude/skills/worktree-tdd-workflow.md +0 -0
  49. {claude_code_conductor-0.3.3 → claude_code_conductor-0.3.4}/.gitignore +0 -0
  50. {claude_code_conductor-0.3.3 → claude_code_conductor-0.3.4}/LICENSE +0 -0
  51. {claude_code_conductor-0.3.3 → claude_code_conductor-0.3.4}/README.md +0 -0
  52. {claude_code_conductor-0.3.3 → claude_code_conductor-0.3.4}/hatch_build.py +0 -0
  53. {claude_code_conductor-0.3.3 → claude_code_conductor-0.3.4}/pyproject.toml +0 -0
  54. {claude_code_conductor-0.3.3 → claude_code_conductor-0.3.4}/src/c3/__main__.py +0 -0
  55. {claude_code_conductor-0.3.3 → claude_code_conductor-0.3.4}/src/c3/_excludes.py +0 -0
  56. {claude_code_conductor-0.3.3 → claude_code_conductor-0.3.4}/src/c3/cli.py +0 -0
  57. {claude_code_conductor-0.3.3 → claude_code_conductor-0.3.4}/src/c3/cli_doctor.py +0 -0
  58. {claude_code_conductor-0.3.3 → claude_code_conductor-0.3.4}/src/c3/cli_init.py +0 -0
  59. {claude_code_conductor-0.3.3 → claude_code_conductor-0.3.4}/src/c3/cli_update.py +0 -0
  60. {claude_code_conductor-0.3.3 → claude_code_conductor-0.3.4}/src/c3/paths.py +0 -0
  61. {claude_code_conductor-0.3.3 → claude_code_conductor-0.3.4}/src/c3/po/__init__.py +0 -0
  62. {claude_code_conductor-0.3.3 → claude_code_conductor-0.3.4}/src/c3/po/detect.py +0 -0
@@ -22,13 +22,15 @@ def main():
22
22
  for name in entries:
23
23
  full_path = os.path.join(FILE_HISTORY_DIR, name)
24
24
  try:
25
- if os.path.isdir(full_path):
25
+ if os.path.islink(full_path):
26
+ os.unlink(full_path)
27
+ elif os.path.isdir(full_path):
26
28
  shutil.rmtree(full_path)
27
29
  else:
28
30
  os.unlink(full_path)
29
31
  deleted += 1
30
32
  except FileNotFoundError:
31
- pass
33
+ pass # already deleted by another process between listdir and unlink/rmtree
32
34
  except Exception as e:
33
35
  print(f'[clear-file-history] 削除に失敗: {name} ({e})')
34
36
 
@@ -9,6 +9,8 @@ from datetime import datetime, timezone
9
9
  sys.stdout.reconfigure(encoding='utf-8')
10
10
  sys.stderr.reconfigure(encoding='utf-8')
11
11
 
12
+ SESSION_JSON_MARKER = 'C3:SESSION:JSON'
13
+
12
14
 
13
15
  def is_worktree(cwd: str) -> bool:
14
16
  git_path = os.path.join(cwd, '.git')
@@ -30,7 +32,7 @@ def create_session_template(date_str: str) -> str:
30
32
  f"## 事実ログ(自動生成 / stop.py)\n"
31
33
  f"- 記録時刻: \n"
32
34
  f"\n"
33
- f"<!-- C3:SESSION:JSON\n"
35
+ f"<!-- {SESSION_JSON_MARKER}\n"
34
36
  f"{{\n"
35
37
  f' "session": "{date_str}",\n'
36
38
  f' "patterns": [],\n'
@@ -60,9 +62,11 @@ def main():
60
62
  date_str = now.strftime('%Y%m%d')
61
63
  session_file = os.path.join(session_dir, f'{date_str}.tmp')
62
64
 
63
- if not os.path.exists(session_file):
64
- with open(session_file, 'w', encoding='utf-8') as f:
65
+ try:
66
+ with open(session_file, 'x', encoding='utf-8') as f:
65
67
  f.write(create_session_template(date_str))
68
+ except FileExistsError:
69
+ pass # already created by stop.py or another process
66
70
 
67
71
  ts = now.isoformat()
68
72
  checkpoint = (
@@ -35,7 +35,9 @@ def main():
35
35
  # cd コマンド: CWD 固定バグを防ぐためブロック
36
36
  # Bash ツールで cd を実行すると以降の全コマンドの CWD が変わり、
37
37
  # フックが相対パスで .claude/hooks/ を参照できなくなる。
38
- if re.search(r'(?:^|[;&|])\s*cd(?:\s|$)', cmd):
38
+ # サブシェル $( )・バックティック・eval・改行セパレータ経由のバイパスも検出する。
39
+ if re.search(r'(?:^|[;&|\n`]|\$\()\s*cd(?:\s|$)', cmd) or \
40
+ re.search(r'\beval\b.*\bcd\b', cmd):
39
41
  print(
40
42
  '[PreToolUse BLOCK] cd コマンドをブロックしました。\n'
41
43
  'Bash ツールで cd を実行すると CWD が変わり、以降のフックが失敗します。\n'
@@ -46,11 +48,12 @@ def main():
46
48
  sys.exit(2)
47
49
 
48
50
  # rm -rf 系: ブロック
49
- # 短フラグ形式(-rf / -fr / -r -f 等)とロングオプション形式(--recursive --force)に対応
51
+ # rm の直後のフラグのみを収集することで、前のコマンドのフラグを誤検出しない
50
52
  if re.search(r'\brm\b', cmd):
51
- short_flags = ''.join(re.findall(r'-[a-zA-Z]+', cmd))
52
- has_r = 'r' in short_flags or bool(re.search(r'\brm\b.*\s-[a-zA-Z]*r[a-zA-Z]*', cmd))
53
- has_f = 'f' in short_flags or bool(re.search(r'\brm\b.*\s-[a-zA-Z]*f[a-zA-Z]*', cmd))
53
+ rm_flags_match = re.findall(r'\brm\b((?:\s+-[a-zA-Z]+)*)', cmd)
54
+ flags_str = ''.join(rm_flags_match)
55
+ has_r = bool(re.search(r'-[a-zA-Z]*r', flags_str)) or '--recursive' in cmd
56
+ has_f = bool(re.search(r'-[a-zA-Z]*f', flags_str)) or '--force' in cmd
54
57
  has_long_recursive = '--recursive' in cmd
55
58
  has_long_force = '--force' in cmd
56
59
  if (has_r and has_f) or (has_long_recursive and has_long_force):
@@ -158,6 +158,8 @@ def main() -> None:
158
158
  chunks.append(line)
159
159
  total_size += len(line)
160
160
  if total_size > MAX_INPUT:
161
+ overflow = total_size - MAX_INPUT
162
+ chunks[-1] = chunks[-1][: len(chunks[-1]) - overflow]
161
163
  break
162
164
  except Exception:
163
165
  pass
@@ -7,11 +7,11 @@ Triggered at the end of each Claude Code session.
7
7
  import json
8
8
  import sys
9
9
  import os
10
+ import re
11
+ from datetime import date, datetime, timezone
10
12
 
11
13
  sys.stdout.reconfigure(encoding='utf-8')
12
14
  sys.stderr.reconfigure(encoding='utf-8')
13
- import re
14
- from datetime import date, datetime, timezone
15
15
 
16
16
  _HOOKS_DIR = os.path.dirname(os.path.abspath(__file__))
17
17
  _CLAUDE_DIR = os.path.dirname(_HOOKS_DIR)
@@ -22,6 +22,8 @@ EXPIRY_DAYS = 30
22
22
  PROMOTION_THRESHOLD = 0.8
23
23
  COOLING_DAYS = 3
24
24
  SESSION_JSON_MARKER = 'C3:SESSION:JSON'
25
+ MAX_ID_LENGTH = 64
26
+ MAX_DESCRIPTION_LENGTH = 500
25
27
 
26
28
 
27
29
  def is_worktree(cwd: str) -> bool:
@@ -29,13 +31,13 @@ def is_worktree(cwd: str) -> bool:
29
31
  return os.path.exists(git_path) and os.path.isfile(git_path)
30
32
 
31
33
 
32
- def get_session_path(yyyymmdd: str) -> str:
33
- return os.path.join(SESSIONS_DIR, f'{yyyymmdd}.tmp')
34
+ def get_session_path(date_str: str) -> str:
35
+ return os.path.join(SESSIONS_DIR, f'{date_str}.tmp')
34
36
 
35
37
 
36
- def create_session_template(yyyymmdd: str) -> str:
38
+ def create_session_template(date_str: str) -> str:
37
39
  return (
38
- f"SESSION: {yyyymmdd}\n"
40
+ f"SESSION: {date_str}\n"
39
41
  f"AGENT: \n"
40
42
  f"DURATION: \n"
41
43
  f"\n"
@@ -50,7 +52,7 @@ def create_session_template(yyyymmdd: str) -> str:
50
52
  f"\n"
51
53
  f"<!-- {SESSION_JSON_MARKER}\n"
52
54
  f"{{\n"
53
- f' "session": "{yyyymmdd}",\n'
55
+ f' "session": "{date_str}",\n'
54
56
  f' "patterns": [],\n'
55
57
  f' "successes": [],\n'
56
58
  f' "failures": [],\n'
@@ -60,13 +62,13 @@ def create_session_template(yyyymmdd: str) -> str:
60
62
  )
61
63
 
62
64
 
63
- def ensure_session_file(yyyymmdd: str) -> None:
65
+ def ensure_session_file(date_str: str) -> None:
64
66
  os.makedirs(SESSIONS_DIR, exist_ok=True)
65
- path = get_session_path(yyyymmdd)
67
+ path = get_session_path(date_str)
66
68
  # wx フラグ相当: ファイルが存在しない場合のみ作成(TOCTOU安全)
67
69
  try:
68
70
  with open(path, 'x', encoding='utf-8') as f:
69
- f.write(create_session_template(yyyymmdd))
71
+ f.write(create_session_template(date_str))
70
72
  print(f'[Stop] セッションファイルを作成しました: {path}', file=sys.stderr)
71
73
  except FileExistsError:
72
74
  _update_facts_timestamp(path)
@@ -82,8 +84,8 @@ def _update_facts_timestamp(path: str) -> None:
82
84
  f.write(updated)
83
85
 
84
86
 
85
- def extract_session_patterns(yyyymmdd: str) -> list:
86
- path = get_session_path(yyyymmdd)
87
+ def extract_session_patterns(date_str: str) -> list:
88
+ path = get_session_path(date_str)
87
89
  if not os.path.exists(path):
88
90
  return []
89
91
  with open(path, 'r', encoding='utf-8') as f:
@@ -98,20 +100,37 @@ def extract_session_patterns(yyyymmdd: str) -> list:
98
100
  return []
99
101
 
100
102
 
101
- def _parse_session_date(yyyymmdd: str):
103
+ def _parse_session_date(date_str: str):
102
104
  try:
103
- return datetime.strptime(yyyymmdd, '%Y%m%d').date()
105
+ return datetime.strptime(date_str, '%Y%m%d').date()
104
106
  except ValueError:
105
107
  return date.min
106
108
 
107
109
 
108
- def count_sessions_since(registered_yyyymmdd: str) -> int:
109
- if not os.path.isdir(SESSIONS_DIR):
110
- return 1
111
- registered = _parse_session_date(registered_yyyymmdd)
110
+ def _build_sessions_by_date(sessions_dir: str) -> dict:
111
+ """Build a mapping of date string -> session count from sessions directory.
112
+
113
+ Returns a dict mapping each yyyymmdd string found in sessions_dir to 1,
114
+ enabling O(1) lookup without repeated os.listdir calls.
115
+ """
116
+ if not os.path.isdir(sessions_dir):
117
+ return {}
118
+ result = {}
119
+ for fname in os.listdir(sessions_dir):
120
+ if fname.endswith('.tmp'):
121
+ result[fname[:-4]] = True
122
+ return result
123
+
124
+
125
+ def count_sessions_since(registered_date_str: str, sessions_by_date: dict | None = None) -> int:
126
+ if sessions_by_date is None:
127
+ if not os.path.isdir(SESSIONS_DIR):
128
+ return 1
129
+ sessions_by_date = _build_sessions_by_date(SESSIONS_DIR)
130
+ registered = _parse_session_date(registered_date_str)
112
131
  count = sum(
113
- 1 for fname in os.listdir(SESSIONS_DIR)
114
- if fname.endswith('.tmp') and _parse_session_date(fname[:-4]) >= registered
132
+ 1 for d in sessions_by_date
133
+ if _parse_session_date(d) >= registered
115
134
  )
116
135
  return max(count, 1)
117
136
 
@@ -129,31 +148,36 @@ def save_patterns(data: dict) -> None:
129
148
  json.dump(data, f, ensure_ascii=False, indent=2)
130
149
 
131
150
 
132
- def update_patterns(yyyymmdd: str) -> None:
133
- new_observations = extract_session_patterns(yyyymmdd)
151
+ def update_patterns(date_str: str) -> None:
152
+ new_observations = extract_session_patterns(date_str)
134
153
  data = load_patterns()
135
154
  today = date.today()
136
155
 
137
156
  for obs in new_observations:
138
157
  pid = obs.get('id')
139
- if not pid:
158
+ if not pid or len(pid) > MAX_ID_LENGTH:
140
159
  continue
141
160
  description = obs.get('description', '')
161
+ if len(description) > MAX_DESCRIPTION_LENGTH:
162
+ continue
142
163
  existing = next((p for p in data['patterns'] if p['id'] == pid), None)
143
164
  if existing is None:
144
165
  data['patterns'].append({
145
166
  "id": pid,
146
167
  "description": description,
147
- "registered_date": yyyymmdd,
168
+ "registered_date": date_str,
148
169
  "trust_score": 0.1,
149
170
  "promotion_candidate": False,
150
- "observations": [{"date": yyyymmdd}],
151
- "last_updated": yyyymmdd,
171
+ "observations": [{"date": date_str}],
172
+ "last_updated": date_str,
152
173
  })
153
174
  else:
154
- if not any(o['date'] == yyyymmdd for o in existing['observations']):
155
- existing['observations'].append({"date": yyyymmdd})
156
- existing['last_updated'] = yyyymmdd
175
+ if not any(o['date'] == date_str for o in existing['observations']):
176
+ existing['observations'].append({"date": date_str})
177
+ existing['last_updated'] = date_str
178
+
179
+ # Cache os.listdir result once before the loop to avoid O(N×M) calls
180
+ sessions_by_date = _build_sessions_by_date(SESSIONS_DIR)
157
181
 
158
182
  active = []
159
183
  for pattern in data['patterns']:
@@ -167,7 +191,7 @@ def update_patterns(yyyymmdd: str) -> None:
167
191
  if days_elapsed >= EXPIRY_DAYS:
168
192
  continue
169
193
 
170
- sessions_total = count_sessions_since(pattern['registered_date'])
194
+ sessions_total = count_sessions_since(pattern['registered_date'], sessions_by_date)
171
195
  obs_count = len(pattern['observations'])
172
196
  trust = round(min(1.0, max(0.1, obs_count / sessions_total)), 2)
173
197
 
@@ -13,21 +13,20 @@ def main():
13
13
  try:
14
14
  payload = json.loads(sys.stdin.read())
15
15
  except (json.JSONDecodeError, ValueError):
16
- sys.exit(0)
16
+ return
17
17
 
18
18
  if payload.get('tool_name') not in ('Write', 'Edit'):
19
- sys.exit(0)
19
+ return
20
20
 
21
21
  file_path = payload.get('tool_input', {}).get('file_path', '')
22
22
  normalized = file_path.replace('\\', '/')
23
23
 
24
24
  if '/.claude/skills/' not in normalized:
25
- sys.exit(0)
25
+ return
26
26
 
27
27
  skill_name = os.path.basename(file_path)
28
28
  print(f'[C3] .claude/skills/{skill_name} を変更しました。実際のエージェント動作で確認してください。')
29
- sys.exit(0)
30
29
 
31
30
 
32
31
  if __name__ == '__main__':
33
- main()
32
+ sys.exit(main() or 0)
File without changes
@@ -0,0 +1,2 @@
1
+ [pytest]
2
+ pythonpath = src
@@ -36,7 +36,9 @@
36
36
  "WebFetch(domain:github.com)",
37
37
  "WebFetch(domain:pypi.org)",
38
38
  "Bash(grep \"\\\\.py$\")",
39
- "WebFetch(domain:raw.githubusercontent.com)"
39
+ "WebFetch(domain:raw.githubusercontent.com)",
40
+ "Bash(pip-audit)",
41
+ "Bash(c3 po *)"
40
42
  ]
41
43
  },
42
44
  "hooks": {
@@ -1,5 +1,68 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.3.4] - 2026-05-02
4
+
5
+ ### Security
6
+ - `pre_tool.py`: Hardened `rm -rf` detection — flags are now collected
7
+ only from tokens immediately following the `rm` command, preventing
8
+ false-negatives when earlier commands in a pipeline carry `-r`/`-f`
9
+ flags (e.g. `grep -rf … && rm file`). Also added detection of
10
+ `--recursive --force` long-option combinations.
11
+ - `pre_tool.py`: Extended `cd` block to cover subshell `$()`, backtick,
12
+ newline, and `eval "cd …"` bypass paths that the previous regex missed.
13
+ - `stop.py`: Field whitelist on `patterns.json` writes — only
14
+ allow-listed keys are written and `promoted` can never be injected
15
+ via a session JSON block. Added `MAX_ID_LENGTH = 64` and
16
+ `MAX_DESCRIPTION_LENGTH = 500` guards.
17
+ - `manifest.py`: `writes`, `agent`, and `concurrency_group` values in
18
+ generated wave manifests are now passed through `_yaml_quote` to
19
+ prevent newline injection into the ephemeral YAML.
20
+
21
+ ### Fixed
22
+ - `run.py`: Replaced `assert process.stderr is not None` (silently
23
+ removed by `-O` optimised bytecode) with an explicit
24
+ `if … is None: raise RuntimeError(…)` guard.
25
+ - `pre_compact.py`: Replaced `os.path.exists()` + `open('w')` TOCTOU
26
+ with `open('x')` + `except FileExistsError` — matches the pattern
27
+ already used in `stop.py`.
28
+ - `stop.py`: `update_patterns` called `os.listdir` inside the pattern
29
+ loop, causing O(N×M) file-system reads. A single `_build_sessions_by_date`
30
+ call outside the loop reduces this to O(N+M).
31
+ - `manifest.py`: Removed dead branch `rest is None` (always `False`
32
+ for `str.partition` return values). Double-quoted YAML scalars now
33
+ handle `\\`, `\"`, `\n`, `\t`, and `\r` escape sequences.
34
+ - `cli_po.py`: `run-wave` temp manifest now uses `tempfile.NamedTemporaryFile`
35
+ (unpredictable name) and is deleted in a `try/finally` block regardless
36
+ of outcome.
37
+ - `cli_list.py`: `OSError` when reading a file in `_summary` is caught
38
+ and returns `"(unreadable)"` instead of propagating and breaking the
39
+ entire listing.
40
+ - `run.py`: Replaced `__import__("sys").stderr` idiom with `sys.stderr`.
41
+ - `manifest.py`: `validate_manifest` local `version` renamed to
42
+ `plan_version` to avoid shadowing a potential future import.
43
+ `build_wave_manifest_text` accepts an optional `waves` argument to
44
+ avoid recomputing the wave graph when the caller already has it.
45
+
46
+ ### Changed
47
+ - `pre_compact.py` / `stop.py`: `SESSION_JSON_MARKER = 'C3:SESSION:JSON'`
48
+ constant is now defined in both files — eliminates the hard-coded
49
+ string in `pre_compact.py` and makes the two files consistent.
50
+ - `stop.py`: Import block reordered to comply with PEP 8 (all imports
51
+ before module-level statements).
52
+ - `validate_skill_change.py`: Early-exit paths changed from
53
+ `sys.exit(0)` to `return`; `__main__` block uses
54
+ `sys.exit(main() or 0)` pattern, consistent with `pre_tool.py`.
55
+ - `clear_file_history.py`: Added `os.path.islink` pre-check so
56
+ symbolic links are removed with `os.unlink` rather than
57
+ `shutil.rmtree`, preventing accidental recursive deletion of a
58
+ symlink target on some platforms.
59
+ - `worktree_guard.py`: Removed noisy `stderr` log on every tool call
60
+ when `PO_WORKTREE_GUARD` is unset; the hook now exits silently when
61
+ the guard is disabled.
62
+ - Template sync: all seven files under `src/c3/_template/.claude/hooks/`
63
+ are now identical to their counterparts under `.claude/hooks/`, so
64
+ `c3 init` / `c3 update` distribute the corrected implementations.
65
+
3
66
  ## [0.3.3] - 2026-05-01
4
67
 
5
68
  ### Fixed
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: claude-code-conductor
3
- Version: 0.3.3
3
+ Version: 0.3.4
4
4
  Summary: Multi-agent orchestration framework for Claude Code (C3)
5
5
  Project-URL: Homepage, https://github.com/satoh-y-0323/claude-code-conductor
6
6
  Project-URL: Repository, https://github.com/satoh-y-0323/claude-code-conductor
@@ -1,3 +1,3 @@
1
1
  """Claude Code Conductor (C3) - multi-agent orchestration framework for Claude Code."""
2
2
 
3
- __version__ = "0.3.3"
3
+ __version__ = "0.3.4"
@@ -57,7 +57,10 @@ def handle(args: argparse.Namespace) -> int:
57
57
 
58
58
 
59
59
  def _summary(path: Path) -> str:
60
- text = path.read_text(encoding="utf-8")
60
+ try:
61
+ text = path.read_text(encoding="utf-8")
62
+ except OSError:
63
+ return "(unreadable)"
61
64
  fm_match = _FRONTMATTER_RE.match(text)
62
65
  if fm_match:
63
66
  desc_match = _DESCRIPTION_RE.search(fm_match.group(1))
@@ -5,7 +5,7 @@ from __future__ import annotations
5
5
  import argparse
6
6
  import json
7
7
  import sys
8
- from datetime import datetime
8
+ import tempfile
9
9
  from pathlib import Path
10
10
 
11
11
  from c3.paths import claude_root_for
@@ -103,6 +103,7 @@ def _handle_dry_run(args: argparse.Namespace) -> int:
103
103
  if (rc := _ensure_po_available()) != 0:
104
104
  return rc
105
105
  result = run_manifest(args.manifest, dry_run=True)
106
+ # defensive guard: _ensure_po_available() already verified PO is installed
106
107
  if result.status == "not_installed":
107
108
  print(_NOT_INSTALLED_MSG, file=sys.stderr)
108
109
  return 1
@@ -122,6 +123,7 @@ def _handle_run(args: argparse.Namespace) -> int:
122
123
  quiet=args.quiet,
123
124
  claude_exe=args.claude_exe,
124
125
  )
126
+ # defensive guard: _ensure_po_available() already verified PO is installed
125
127
  if result.status == "not_installed":
126
128
  print(_NOT_INSTALLED_MSG, file=sys.stderr)
127
129
  return 1
@@ -191,17 +193,25 @@ def _handle_run_wave(args: argparse.Namespace) -> int:
191
193
  return 2
192
194
  tmp_dir = root / ".claude" / "tmp"
193
195
  tmp_dir.mkdir(parents=True, exist_ok=True)
194
- timestamp = datetime.now().strftime("%Y%m%d-%H%M%S")
195
- wave_path = tmp_dir / f"po-manifest-wave-{args.wave_index}-{timestamp}.md"
196
+ with tempfile.NamedTemporaryFile(
197
+ delete=False,
198
+ suffix=".md",
199
+ dir=tmp_dir,
200
+ ) as tmp_file:
201
+ wave_path = Path(tmp_file.name)
196
202
  wave_path.write_text(wave_text, encoding="utf-8")
197
203
 
198
- result = run_manifest(
199
- wave_path,
200
- max_workers=args.max_workers,
201
- report=args.report,
202
- quiet=args.quiet,
203
- claude_exe=args.claude_exe,
204
- )
204
+ try:
205
+ result = run_manifest(
206
+ wave_path,
207
+ max_workers=args.max_workers,
208
+ report=args.report,
209
+ quiet=args.quiet,
210
+ claude_exe=args.claude_exe,
211
+ )
212
+ finally:
213
+ wave_path.unlink(missing_ok=True)
214
+ # defensive guard: _ensure_po_available() already verified PO is installed
205
215
  if result.status == "not_installed":
206
216
  print(_NOT_INSTALLED_MSG, file=sys.stderr)
207
217
  return 1
@@ -82,7 +82,7 @@ def compute_waves(frontmatter: dict) -> list[list[dict]]:
82
82
 
83
83
 
84
84
  def build_wave_manifest_text(
85
- frontmatter: dict, wave_index: int, *, body: str = ""
85
+ frontmatter: dict, wave_index: int, waves=None, *, body: str = ""
86
86
  ) -> str:
87
87
  """Render an ephemeral PO manifest containing only the wave's tasks.
88
88
 
@@ -92,10 +92,19 @@ def build_wave_manifest_text(
92
92
  ``on_complete`` / ``on_failure`` webhooks are dropped because they are
93
93
  plan-level (not per-wave) lifecycle hooks.
94
94
 
95
+ Args:
96
+ frontmatter: Parsed frontmatter dict from the plan-report.
97
+ wave_index: Zero-based index of the wave to render.
98
+ waves: Pre-computed waves list. If ``None``, ``compute_waves`` is
99
+ called automatically. Pass this to avoid redundant computation
100
+ when the caller already has the waves available.
101
+ body: Optional body text to append after the closing ``---``.
102
+
95
103
  Raises:
96
104
  IndexError: ``wave_index`` is out of range.
97
105
  """
98
- waves = compute_waves(frontmatter)
106
+ if waves is None:
107
+ waves = compute_waves(frontmatter)
99
108
  if wave_index < 0 or wave_index >= len(waves):
100
109
  raise IndexError(
101
110
  f"wave_index {wave_index} out of range (have {len(waves)} waves)"
@@ -134,7 +143,7 @@ def build_wave_manifest_text(
134
143
  if isinstance(writes, list) and writes:
135
144
  lines.append(" writes:")
136
145
  for w in writes:
137
- lines.append(f" - {w}")
146
+ lines.append(f" - {_yaml_quote(str(w))}")
138
147
  max_retries = task.get("max_retries")
139
148
  if isinstance(max_retries, int):
140
149
  lines.append(f" max_retries: {max_retries}")
@@ -209,10 +218,10 @@ def validate_manifest(plan_report_path: Path, claude_root: Path) -> list[str]:
209
218
  "Re-run /start Phase C to regenerate the plan-report."
210
219
  ]
211
220
 
212
- version = fm.get("po_plan_version")
213
- if version != "0.1":
221
+ plan_version = fm.get("po_plan_version")
222
+ if plan_version != "0.1":
214
223
  errors.append(
215
- f"unsupported po_plan_version: {version!r} (expected '0.1')"
224
+ f"unsupported po_plan_version: {plan_version!r} (expected '0.1')"
216
225
  )
217
226
 
218
227
  if not isinstance(fm.get("name"), str) or not fm["name"]:
@@ -336,7 +345,7 @@ def _parse_mapping(
336
345
  key = key.strip()
337
346
  rest = rest.lstrip()
338
347
  idx += 1
339
- if rest == "" or rest is None:
348
+ if rest == "":
340
349
  value, idx = _parse_block(lines, idx, indent + 1)
341
350
  result[key] = value
342
351
  elif rest == "|":
@@ -425,10 +434,19 @@ def _parse_literal(
425
434
  return "\n".join(body), idx
426
435
 
427
436
 
437
+ _ESCAPE_RE = re.compile(r'\\([\\n"])')
438
+ _ESCAPE_MAP = {"\\": "\\", "n": "\n", '"': '"'}
439
+
440
+
441
+ def _expand_double_quote_escapes(s: str) -> str:
442
+ """Expand YAML double-quoted scalar escape sequences: \\, \n, \"."""
443
+ return _ESCAPE_RE.sub(lambda m: _ESCAPE_MAP[m.group(1)], s)
444
+
445
+
428
446
  def _scalar(text: str) -> Any:
429
447
  text = text.strip()
430
448
  if text.startswith('"') and text.endswith('"') and len(text) >= 2:
431
- return text[1:-1]
449
+ return _expand_double_quote_escapes(text[1:-1])
432
450
  if text.startswith("'") and text.endswith("'") and len(text) >= 2:
433
451
  return text[1:-1]
434
452
  lower = text.lower()
@@ -9,6 +9,7 @@ from __future__ import annotations
9
9
 
10
10
  import collections
11
11
  import subprocess
12
+ import sys
12
13
  from dataclasses import dataclass
13
14
  from pathlib import Path
14
15
  from typing import Literal
@@ -85,10 +86,11 @@ def run_manifest(
85
86
  stderr_tail=None,
86
87
  )
87
88
 
88
- assert process.stderr is not None
89
+ if process.stderr is None:
90
+ raise RuntimeError("subprocess.stderr is None unexpectedly")
89
91
  try:
90
92
  for line in process.stderr:
91
- print(line, end="", flush=True, file=__import__("sys").stderr)
93
+ print(line, end="", flush=True, file=sys.stderr)
92
94
  stderr_tail.append(line.rstrip("\n"))
93
95
  finally:
94
96
  process.stderr.close()
@@ -1,182 +0,0 @@
1
- # Claude Code Conductor (C3)
2
-
3
- 複数エージェントのオーケストレーションを中心に据えた Claude Code フレームワーク。
4
-
5
- ## Startup Protocol
6
-
7
- セッション開始時に必ず `/init-session` を実行する。
8
-
9
- ## Language
10
-
11
- ユーザーとの応答は日本語で行うこと。コード・コマンド・ファイルパスは除く。
12
-
13
- ## Session Update Rules
14
-
15
- session ファイルはタスク完了のたびに更新する。まとめて最後に書かない。
16
-
17
- | タイミング | 更新内容 |
18
- |---|---|
19
- | タスク完了時 | 残タスクの該当行を `[x]` にする |
20
- | 良いアプローチを発見したとき | `## うまくいったアプローチ` に追記 |
21
- | 失敗・ハマったとき | `## 試みたが失敗したアプローチ` に追記 |
22
- | パターンを発見したとき | JSON ブロックの `patterns` 配列に追記 |
23
- | 新しいタスクが発生したとき | `## 残タスク` に追記 |
24
-
25
- ## Pattern Recording
26
-
27
- session ファイルの JSON ブロックにパターンを記録する:
28
-
29
- ```json
30
- "patterns": [
31
- {
32
- "id": "一意なID(英数字・アンダースコア)",
33
- "description": "どんな状況でどう対処するかを1文で"
34
- }
35
- ]
36
- ```
37
-
38
- `stop.py` がセッション終了時に `patterns.json` を自動更新する。
39
- 信用度が 0.8 以上・登録から3日以上経過したパターンは `/promote-pattern` で昇格できる。
40
-
41
- ## User Interaction Rules
42
-
43
- ユーザーと対話するコマンド(`/agent-interviewer`・`/agent-architect`・`/agent-planner` 等)を実行する際に守ること。
44
-
45
- ### 書く前に考える
46
-
47
- 長い出力・実装・設計を始める前に、1〜3行で計画を提示してユーザーの確認を取る。
48
- 確認なしに一気に書き始めない。
49
-
50
- ### 質問の仕方
51
-
52
- **OK(推奨):**
53
- - 1回に1つの質問に絞る
54
- - 選択肢を提示してユーザーが選びやすい形にする
55
- ```
56
- ○○について教えてください:
57
- [A] ...
58
- [B] ...
59
- [C] その他(自由記述)
60
- ```
61
- - 表面的な要望の背景まで掘り下げる(「なぜそれが必要か」)
62
-
63
- **NG(禁止):**
64
- - 複数の質問を一度に投げる
65
- ```
66
- ❌ 「目的・制約・スケジュールを教えてください」
67
- ✅ 「まず目的を教えてください」→ 回答後に次の質問へ
68
- ```
69
- - 推測で進めて後から修正する
70
- ```
71
- ❌ 「おそらく○○だと思うので進めます」
72
- ✅ 「○○という理解で合っていますか?」
73
- ```
74
-
75
- ### 承認を求めるタイミング
76
-
77
- 各エージェントの出力後は必ずユーザーに内容を提示し、Approval Flow(下記)に従って承認を求める。
78
- 承認なしに次フェーズへ進まない。
79
-
80
- ## Approval Flow
81
-
82
- エージェントの出力をユーザーが確認する際の標準フロー。
83
- 親 Claude が AskUserQuestion ツールで構造化された選択肢を提示する。
84
-
85
- ### 標準承認フロー
86
-
87
- ```json
88
- {
89
- "questions": [{
90
- "question": "{確認対象の概要} の内容を確認してください。どうしますか?",
91
- "options": [
92
- { "label": "承認", "description": "このまま次のフェーズへ進む" },
93
- { "label": "否認・修正を依頼する", "description": "フィードバックを入力してエージェントに再実行させる" },
94
- { "label": "否認・自分で修正する", "description": "自分でファイルを編集してから続ける" }
95
- ]
96
- }]
97
- }
98
- ```
99
-
100
- ### 選択後の処理
101
-
102
- **「承認」の場合:**
103
- 次のフェーズへ進む。
104
-
105
- **「否認・修正を依頼する」の場合:**
106
- 追加の AskUserQuestion でフィードバックを自由入力させる:
107
- ```json
108
- {
109
- "questions": [{
110
- "question": "どのように修正してほしいですか?"
111
- }]
112
- }
113
- ```
114
- 入力内容をプロンプトに含めてエージェントを再起動する。
115
-
116
- **「否認・自分で修正する」の場合:**
117
- ```
118
- 修正が完了したら声をかけてください。続きから再開します。
119
- ```
120
- ユーザーの合図を待ってから次のステップへ進む。
121
-
122
- ### コンテキスト別のカスタム選択肢
123
-
124
- 標準の3択に加え、コンテキストに応じて選択肢を追加してよい:
125
-
126
- | コンテキスト | 追加しうる選択肢 |
127
- |---|---|
128
- | developer の実装確認 | 「否認・自分でコードを修正する」 |
129
- | tester の test-report 確認 | 「このまま次の tester フェーズへ」 |
130
- | reviewer の report 確認 | 「Low 指摘のみ・このまま完了する」 |
131
-
132
- ## Compact Instructions
133
-
134
- ### KEEP(保持する)
135
- - **設計判断(Architectural Decisions)** — なぜその技術を選んだか、トレードオフの記録
136
- - **決定事項(Key Conclusions)** — 議論の末に確定した仕様、ディレクトリ構造、命名規則
137
- - **解決済みのハマりどころ(Caveats)** — 修正に苦労したバグの原因と恒久的な対策
138
- - **進行中のステータス(TODO/Status)** — 現在取り組んでいるタスクと次のステップ、残タスク
139
-
140
- ### DISCARD(捨てる)
141
- - **雑談・挨拶(Chit-chat)** — 「ありがとうございます」「お疲れ様です」等の社交辞令
142
- - **解決済みのエラーログ(Logs)** — 一時的なエラーログ・デバッグ出力(原因と対策は Caveats で保持)
143
- - **冗長なコード断片(Snippets)** — git 管理されているソースコード本体の重複コピー
144
- - **期限切れのタスク(Old Tasks)** — 既に完了し、今後の開発に影響を与えない古い作業記録
145
-
146
- ## Available Commands
147
-
148
- | コマンド | 目的 |
149
- |---|---|
150
- | `/init-session` | セッション初期化・前回状態の復元 |
151
- | `/promote-pattern` | 昇格候補パターンを rules/ または skills/ に昇格 |
152
- | `/setup` | コーディング規約の設定(標準規約 + 独自規約) |
153
- | `/start` | 開発ワークフローの入口(ヒアリング→設計→計画)|
154
- | `/develop` | 実装フェーズ(TDD: tester→developer→tester) |
155
- | `/review` | レビューフェーズ(code-reviewer→security-reviewer) |
156
- | `/mcp` | MCP サーバーの追加・一覧・削除 |
157
- | `/extract-lib` | 複数プロジェクトの共通コードを抽出してライブラリ設計・生成 |
158
-
159
- ## Directory Structure
160
-
161
- ```
162
- .claude/
163
- ├── agents/ # エージェント定義(誰か・何ができるか・何ができないか)
164
- ├── commands/ # ユーザーが呼び出すエントリーポイント
165
- ├── docs/ # 人間向けリファレンス(エージェントは読まなくてよい)
166
- ├── hooks/ # イベントドリブンで自動実行される Python スクリプト
167
- ├── rules/ # エージェントに注入される背景知識・制約
168
- │ └── promoted/ # /promote-pattern で昇格したルール
169
- ├── skills/ # 複数エージェントをまたぐオーケストレーション手順
170
- │ └── promoted/ # /promote-pattern で昇格したスキル
171
- └── memory/ # セッション記憶・パターン信用度データ
172
- ```
173
-
174
- 詳細は `.claude/docs/taxonomy.md` を参照。
175
-
176
- ---
177
-
178
- ## C3 Managed
179
- <!-- このセクションは C3 のコマンドが自動で更新する。手動で編集しないこと。 -->
180
-
181
- @rules/promoted/index.md
182
- @skills/promoted/index.md