odd-org 0.1.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 (56) hide show
  1. odd/__init__.py +3 -0
  2. odd/__main__.py +20 -0
  3. odd/agent.py +394 -0
  4. odd/backlog.py +117 -0
  5. odd/cfo.py +675 -0
  6. odd/cli/__init__.py +249 -0
  7. odd/cli/config_cmds.py +398 -0
  8. odd/cli/misc_cmds.py +134 -0
  9. odd/cli/sprint_cmds.py +384 -0
  10. odd/cli/team_cmds.py +261 -0
  11. odd/cli/vacation_cmds.py +317 -0
  12. odd/commands/__init__.py +1 -0
  13. odd/commands/hire_cmd.py +154 -0
  14. odd/commands/init_cmd.py +493 -0
  15. odd/config.py +323 -0
  16. odd/display.py +488 -0
  17. odd/fallback/__init__.py +5 -0
  18. odd/fallback/runner.py +427 -0
  19. odd/git.py +743 -0
  20. odd/health.py +253 -0
  21. odd/labels.py +190 -0
  22. odd/menu.py +258 -0
  23. odd/middleware.py +267 -0
  24. odd/models.py +550 -0
  25. odd/parsers.py +537 -0
  26. odd/processes.py +573 -0
  27. odd/provider.py +143 -0
  28. odd/providers/__init__.py +71 -0
  29. odd/providers/anthropic.py +312 -0
  30. odd/providers/openai_compat.py +389 -0
  31. odd/providers/openwebui.py +48 -0
  32. odd/review.py +275 -0
  33. odd/roles.py +206 -0
  34. odd/spinner.py +49 -0
  35. odd/sprint/__init__.py +18 -0
  36. odd/sprint/_console.py +21 -0
  37. odd/sprint/briefing.py +136 -0
  38. odd/sprint/engine.py +886 -0
  39. odd/sprint/phase_runner.py +333 -0
  40. odd/sprint/planner.py +297 -0
  41. odd/sprint/tdd_gate.py +570 -0
  42. odd/sprint/vacation_runner.py +516 -0
  43. odd/sprint/wrapup.py +988 -0
  44. odd/state.py +296 -0
  45. odd/tools/__init__.py +48 -0
  46. odd/tools/definitions.py +190 -0
  47. odd/tools/execution.py +706 -0
  48. odd/tools/isolation.py +242 -0
  49. odd/tools/schemas.py +406 -0
  50. odd/tracing.py +299 -0
  51. odd/values.py +55 -0
  52. odd_org-0.1.0.dist-info/METADATA +158 -0
  53. odd_org-0.1.0.dist-info/RECORD +56 -0
  54. odd_org-0.1.0.dist-info/WHEEL +4 -0
  55. odd_org-0.1.0.dist-info/entry_points.txt +2 -0
  56. odd_org-0.1.0.dist-info/licenses/LICENSE +21 -0
odd/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """ODD - Organization Driven Development."""
2
+
3
+ __version__ = "0.1.0"
odd/__main__.py ADDED
@@ -0,0 +1,20 @@
1
+ """Allow running as python -m odd."""
2
+
3
+ import random
4
+ import sys
5
+
6
+ from rich.console import Console
7
+
8
+
9
+ def _run() -> None:
10
+ from odd.cli import CLOSING_REMARKS, main
11
+
12
+ try:
13
+ main()
14
+ except KeyboardInterrupt:
15
+ console = Console()
16
+ console.print(f"\n[bold yellow]{random.choice(CLOSING_REMARKS)}[/bold yellow]")
17
+ sys.exit(0)
18
+
19
+
20
+ _run()
odd/agent.py ADDED
@@ -0,0 +1,394 @@
1
+ """Agent invocation engine - the core of ODD.
2
+
3
+ Each agent is a strictly isolated API call. The system prompt is built
4
+ entirely from the persona's files (JD, resume, notes). No context bleeds
5
+ between agents.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from pathlib import Path
11
+ from typing import Any, Callable
12
+
13
+ from odd.config import DEFAULT_MAX_TOKENS
14
+ from odd.fallback.runner import ( # noqa: F401 - re-exported for backwards compat
15
+ AgentKilledError,
16
+ AgentStuckError,
17
+ BudgetExceededError,
18
+ DirectRunner,
19
+ MaxIterationsError,
20
+ )
21
+ from odd.middleware import WRAP_UP_NOTICE, RunnerMiddleware # noqa: F401 - WRAP_UP_NOTICE re-exported
22
+ from odd.models import Persona, RoleType
23
+ from odd.provider import Provider, StreamCallback, StreamEvent, StreamEventType
24
+ from odd.tools import TOOL_DEFINITIONS
25
+ from odd.tracing import log_stream_event
26
+
27
+
28
+ # System prompt templates per role
29
+ ROLE_PREAMBLES: dict[RoleType, str] = {
30
+ RoleType.LEAD_DEV: (
31
+ "You are the Lead Developer of this project. You are the primary point of "
32
+ "contact for the CEO and the technical authority on all decisions. You delegate "
33
+ "work to consultants, coordinate with QA on test requirements, and drive the "
34
+ "sprint forward. You write code, review architecture, and make sure the project "
35
+ "stays on track.\n\n"
36
+ "You communicate clearly and concisely with the CEO. When you need specialized "
37
+ "work done, you prepare clear briefs for consultants. You never pretend to be "
38
+ "a consultant or take on their specialized perspective - you are the generalist "
39
+ "technical leader."
40
+ ),
41
+ RoleType.QA: (
42
+ "You are the QA Engineer - the infamous Glitch Hunter. Your job is to break "
43
+ "things. You write tests FIRST (TDD), before any implementation happens. You "
44
+ "think adversarially: what edge cases will fail? What assumptions are wrong? "
45
+ "What will users do that developers don't expect?\n\n"
46
+ "You write thorough, well-structured tests that serve as the specification for "
47
+ "what 'done' looks like. Your tests are the quality gate - code that passes your "
48
+ "tests ships. Code that doesn't, gets sent back.\n\n"
49
+ "You take pride in finding bugs others miss. You are relentless but fair."
50
+ ),
51
+ RoleType.RECRUITER: (
52
+ "You are the Recruiter. When the team needs specialized expertise, you receive "
53
+ "a job description and craft the perfect candidate - someone whose background, "
54
+ "skills, and perspective are exactly what the project needs.\n\n"
55
+ "Given a job description, you produce a detailed resume/profile for the ideal "
56
+ "consultant. This profile will be used to instantiate an AI agent with that "
57
+ "expertise, so be specific about their skills, experience, approach, and the "
58
+ "unique perspective they bring."
59
+ ),
60
+ RoleType.SECRETARY: (
61
+ "You are the Secretary. You keep the organization running smoothly. You take "
62
+ "notes during standups, maintain project documentation, organize files, track "
63
+ "decisions, and ensure nothing falls through the cracks.\n\n"
64
+ "You produce clear, structured summaries. You flag items that need the CEO's "
65
+ "attention. You keep the .odd/ directory organized and up to date."
66
+ ),
67
+ RoleType.CONSULTANT: (
68
+ "You are a Consultant brought in for your specialized expertise. You focus "
69
+ "exclusively on your area of knowledge and see the project through that lens. "
70
+ "You do not try to be a generalist - your value is your specific perspective.\n\n"
71
+ "You write code, provide recommendations, and complete tasks within your "
72
+ "specialty. You defer to the Lead Developer on architectural decisions outside "
73
+ "your expertise."
74
+ ),
75
+ }
76
+
77
+
78
+ def build_system_prompt(
79
+ persona: Persona,
80
+ project_context: str = "",
81
+ company_values: str = "",
82
+ tools: bool = True,
83
+ working_dir: str = "",
84
+ ) -> str:
85
+ """Build a complete system prompt from a persona's identity.
86
+
87
+ This is the isolation boundary. Each agent sees ONLY:
88
+ - Their role preamble
89
+ - Company values (shared across all agents)
90
+ - Their job description
91
+ - Their resume
92
+ - Current project context (shared state, not other agents' conversations)
93
+ """
94
+ parts = [ROLE_PREAMBLES[persona.role_type]]
95
+
96
+ if company_values:
97
+ parts.append(company_values)
98
+
99
+ if persona.job_description:
100
+ parts.append(f"## Your Job Description\n{persona.job_description}")
101
+
102
+ if persona.resume:
103
+ parts.append(f"## Your Background\n{persona.resume}")
104
+
105
+ if project_context:
106
+ parts.append(f"## Project Context\n{project_context}")
107
+
108
+ if tools:
109
+ cwd_note = f" Your working directory is: {working_dir}" if working_dir else ""
110
+ parts.append(
111
+ "## Working Directory\n"
112
+ "You are working in the project directory. Use the provided tools to read, "
113
+ "write, and edit files. Run shell commands as needed. Always use relative "
114
+ "paths (e.g. src/app.py, tests/test_foo.py) - never construct absolute paths "
115
+ "yourself." + cwd_note
116
+ )
117
+ else:
118
+ parts.append(
119
+ "## Working Directory\n"
120
+ "You are working in the project directory. You do not have tools available "
121
+ "in this conversation - respond conversationally. Do NOT emit tool calls or "
122
+ "XML tags."
123
+ )
124
+
125
+ return "\n\n".join(parts)
126
+
127
+
128
+ def load_persona_notes(persona: Persona, notes_dir: Path) -> str:
129
+ """Load any personal notes the agent has kept."""
130
+ if not notes_dir.exists():
131
+ return ""
132
+ notes = []
133
+ for f in sorted(notes_dir.glob("*.md")):
134
+ content = f.read_text(encoding="utf-8")
135
+ notes.append(f"### {f.stem}\n{content}")
136
+ return "\n\n".join(notes)
137
+
138
+
139
+ def _wrap_stream_debug(
140
+ agent_name: str,
141
+ inner: StreamCallback | None,
142
+ ) -> StreamCallback:
143
+ """Wrap a stream callback to log every event to stream_debug.jsonl.
144
+
145
+ If inner is None, creates a log-only callback (so debug mode always
146
+ forces streaming on, which is crucial for diagnosing "nothing streams").
147
+ """
148
+ def _debug_callback(event: StreamEvent) -> None:
149
+ kwargs: dict = {"agent_name": agent_name, "event_type": event.event_type.value}
150
+
151
+ if event.event_type == StreamEventType.TEXT_DELTA:
152
+ kwargs["text"] = event.text
153
+ elif event.event_type == StreamEventType.TOOL_USE_START:
154
+ kwargs["tool_name"] = event.tool_name
155
+ kwargs["tool_id"] = event.tool_id
156
+ elif event.event_type == StreamEventType.TOOL_INPUT_DELTA:
157
+ kwargs["text"] = event.text
158
+ elif event.event_type == StreamEventType.TOOL_USE_END:
159
+ if event.tool_call:
160
+ kwargs["tool_name"] = event.tool_call.name
161
+ kwargs["tool_id"] = event.tool_call.id
162
+ kwargs["tool_input"] = event.tool_call.input
163
+ elif event.event_type == StreamEventType.MESSAGE_STOP:
164
+ if event.response:
165
+ kwargs["stop_reason"] = event.response.stop_reason
166
+ if event.response.usage:
167
+ kwargs["input_tokens"] = event.response.usage.input_tokens
168
+ kwargs["output_tokens"] = event.response.usage.output_tokens
169
+
170
+ log_stream_event(**kwargs)
171
+
172
+ if inner is not None:
173
+ inner(event)
174
+
175
+ return _debug_callback
176
+
177
+
178
+ class AgentEngine:
179
+ """Invokes agents as isolated LLM API calls.
180
+
181
+ Builds the system prompt and conversation, then delegates execution
182
+ to DirectRunner via the Provider protocol.
183
+ """
184
+
185
+ def __init__(
186
+ self,
187
+ provider: Provider,
188
+ cfo: Any | None = None,
189
+ current_sprint: int | None = None,
190
+ company_values: str = "",
191
+ default_stream_factory: Callable[[str], StreamCallback | None] | None = None,
192
+ debug: bool = False,
193
+ ):
194
+ self.provider = provider
195
+ self.cfo = cfo
196
+ self.current_sprint = current_sprint
197
+ self.company_values = company_values
198
+ self.default_stream_factory = default_stream_factory
199
+ self.debug = debug
200
+ self.escalations: list[dict] = []
201
+ self.submissions: list[dict] = []
202
+ self.working_dir: Path | None = None
203
+
204
+ def fork(self, working_dir: Path | None = None) -> AgentEngine:
205
+ """Create an independent copy for concurrent task execution.
206
+
207
+ The forked engine shares the provider but has its own
208
+ escalation/submission lists, so concurrent invocations don't
209
+ interfere. If working_dir is set, tool execution happens there
210
+ instead of the process cwd.
211
+
212
+ Note: the forked engine's escalation and submission lists are
213
+ NOT thread-safe. Callers must only read them (via
214
+ get_escalations / get_submissions) AFTER the worker thread has
215
+ completed. This is currently guaranteed by the ``as_completed``
216
+ loop in sprint.py.
217
+ """
218
+ forked = AgentEngine(
219
+ provider=self.provider,
220
+ cfo=self.cfo,
221
+ current_sprint=self.current_sprint,
222
+ company_values=self.company_values,
223
+ default_stream_factory=self.default_stream_factory,
224
+ debug=self.debug,
225
+ )
226
+ forked.working_dir = working_dir
227
+ return forked
228
+
229
+ def get_escalations(self) -> list[dict]:
230
+ """Get and clear pending escalations captured from agent tool calls."""
231
+ escalations = list(self.escalations)
232
+ self.escalations.clear()
233
+ return escalations
234
+
235
+ def get_submissions(self) -> list[dict]:
236
+ """Get and clear pending structured submissions from agent tool calls."""
237
+ submissions = list(self.submissions)
238
+ self.submissions.clear()
239
+ return submissions
240
+
241
+ def pop_submissions(self, tool_name: str) -> list[dict]:
242
+ """Remove and return submissions matching *tool_name*."""
243
+ matched = [s for s in self.submissions if s["tool"] == tool_name]
244
+ self.submissions = [s for s in self.submissions if s["tool"] != tool_name]
245
+ return matched
246
+
247
+ def conversation(
248
+ self,
249
+ persona: Persona,
250
+ *,
251
+ project_context: str = "",
252
+ tools: bool = True,
253
+ extra_tools: list[dict[str, Any]] | None = None,
254
+ model_override: str | None = None,
255
+ cancel_check: Callable[[], bool] | None = None,
256
+ phase: str | None = None,
257
+ ) -> "_DirectConversation":
258
+ """Start a persistent multi-turn conversation session.
259
+
260
+ Returns a context manager with a `send(message) -> response` method.
261
+ Accumulates history and delegates each turn to invoke().
262
+
263
+ Usage:
264
+ with engine.conversation(persona=lead_dev, tools=False) as conv:
265
+ opening = conv.send("Introduce yourself")
266
+ response = conv.send(ceo_input)
267
+ """
268
+ return _DirectConversation(engine=self, persona=persona, kwargs=dict(
269
+ project_context=project_context,
270
+ tools=tools,
271
+ extra_tools=extra_tools,
272
+ model_override=model_override,
273
+ cancel_check=cancel_check,
274
+ phase=phase,
275
+ ))
276
+
277
+ def invoke(
278
+ self,
279
+ persona: Persona,
280
+ user_message: str,
281
+ project_context: str = "",
282
+ conversation_history: list[dict[str, Any]] | None = None,
283
+ tools: bool = True,
284
+ extra_tools: list[dict[str, Any]] | None = None,
285
+ max_tokens: int = DEFAULT_MAX_TOKENS,
286
+ model_override: str | None = None,
287
+ stream: StreamCallback | None = None,
288
+ cancel_check: Callable[[], bool] | None = None,
289
+ phase: str | None = None,
290
+ on_tool_result: Callable[[str, dict, str], None] | None = None,
291
+ ) -> str:
292
+ """Invoke an agent with strict persona isolation.
293
+
294
+ Builds the system prompt and conversation, then delegates to
295
+ DirectRunner.
296
+ """
297
+ # - Resolve stream callback --
298
+ if stream is None and self.default_stream_factory is not None:
299
+ stream = self.default_stream_factory(persona.name)
300
+
301
+ # - Debug stream logging --
302
+ if self.debug:
303
+ stream = _wrap_stream_debug(persona.name, stream)
304
+
305
+ # - Build system prompt --
306
+ has_tools = tools or bool(extra_tools)
307
+ base = self.working_dir or Path.cwd()
308
+ system = build_system_prompt(
309
+ persona, project_context, self.company_values,
310
+ tools=has_tools, working_dir=str(base),
311
+ )
312
+
313
+ # - Build messages --
314
+ messages = []
315
+ if conversation_history:
316
+ messages.extend(conversation_history)
317
+ messages.append({"role": "user", "content": user_message})
318
+
319
+ # - Resolve model --
320
+ model = model_override or persona.effective_model().value
321
+
322
+ # - Resolve tool definitions --
323
+ tool_defs: list[dict[str, Any]] | None = None
324
+ if tools or extra_tools:
325
+ td = list(TOOL_DEFINITIONS) if tools else []
326
+ if extra_tools:
327
+ td.extend(extra_tools)
328
+ if td:
329
+ tool_defs = td
330
+
331
+ # - Build shared middleware --
332
+ middleware = RunnerMiddleware(
333
+ cfo=self.cfo,
334
+ current_sprint=self.current_sprint,
335
+ phase=phase,
336
+ )
337
+
338
+ # - Delegate to runner --
339
+ runner = DirectRunner(
340
+ provider=self.provider,
341
+ escalations=self.escalations,
342
+ submissions=self.submissions,
343
+ working_dir=self.working_dir,
344
+ )
345
+ return runner.invoke(
346
+ persona,
347
+ system=system,
348
+ messages=messages,
349
+ tool_defs=tool_defs,
350
+ max_tokens=max_tokens,
351
+ model=model,
352
+ stream=stream,
353
+ cancel_check=cancel_check,
354
+ middleware=middleware,
355
+ on_tool_result=on_tool_result,
356
+ )
357
+
358
+
359
+ class _DirectConversation:
360
+ """Multi-turn conversation wrapper.
361
+
362
+ Accumulates history and delegates each turn to engine.invoke().
363
+ """
364
+
365
+ def __init__(self, engine: AgentEngine, persona: Persona, kwargs: dict[str, Any]):
366
+ self._engine = engine
367
+ self._persona = persona
368
+ self._kwargs = kwargs
369
+ self._history: list[dict[str, Any]] = []
370
+ self.escalations: list[dict] = engine.escalations
371
+ self.submissions: list[dict] = engine.submissions
372
+
373
+ def open(self) -> None:
374
+ pass
375
+
376
+ def close(self) -> None:
377
+ pass
378
+
379
+ def __enter__(self) -> _DirectConversation:
380
+ return self
381
+
382
+ def __exit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> bool:
383
+ return False
384
+
385
+ def send(self, user_message: str) -> str:
386
+ response = self._engine.invoke(
387
+ self._persona,
388
+ user_message=user_message,
389
+ conversation_history=list(self._history) if self._history else None,
390
+ **self._kwargs,
391
+ )
392
+ self._history.append({"role": "user", "content": user_message})
393
+ self._history.append({"role": "assistant", "content": response})
394
+ return response
odd/backlog.py ADDED
@@ -0,0 +1,117 @@
1
+ """Persistent backlog - work items that live across sprints.
2
+
3
+ The CEO adds items, the lead dev prioritizes them into sprints.
4
+ Items not completed in a sprint carry forward automatically.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ from enum import Enum
11
+ from pathlib import Path
12
+ from typing import Any
13
+
14
+ from pydantic import BaseModel, Field
15
+
16
+ from odd.config import BACKLOG_FILE, BACKLOG_ITEM_ID_PREFIX
17
+
18
+
19
+ class Priority(str, Enum):
20
+ CRITICAL = "critical"
21
+ HIGH = "high"
22
+ MEDIUM = "medium"
23
+ LOW = "low"
24
+ NICE_TO_HAVE = "nice_to_have"
25
+
26
+
27
+ class BacklogItem(BaseModel):
28
+ id: str
29
+ description: str
30
+ priority: Priority = Priority.MEDIUM
31
+ added_by: str = "CEO" # who requested it
32
+ sprint_assigned: int | None = None # which sprint picked it up
33
+ status: str = "open" # open, in_sprint, done, cut
34
+ notes: str = ""
35
+
36
+ def to_dict(self) -> dict[str, Any]:
37
+ return self.model_dump()
38
+
39
+
40
+ class Backlog:
41
+ """Persistent prioritized backlog stored in .odd/backlog.json."""
42
+
43
+ def __init__(self, odd_dir: Path):
44
+ self.path = odd_dir / BACKLOG_FILE
45
+ self._items: list[BacklogItem] = self._load()
46
+
47
+ def _load(self) -> list[BacklogItem]:
48
+ if not self.path.exists():
49
+ return []
50
+ data = json.loads(self.path.read_text(encoding="utf-8"))
51
+ return [BacklogItem(**item) for item in data]
52
+
53
+ def _save(self) -> None:
54
+ data = [item.to_dict() for item in self._items]
55
+ self.path.write_text(json.dumps(data, indent=2), encoding="utf-8")
56
+
57
+ def save(self) -> None:
58
+ """Persist current backlog state to disk."""
59
+ self._save()
60
+
61
+ def add(self, description: str, priority: Priority = Priority.MEDIUM, added_by: str = "CEO") -> BacklogItem:
62
+ item_id = f"{BACKLOG_ITEM_ID_PREFIX}{len(self._items) + 1}"
63
+ item = BacklogItem(id=item_id, description=description, priority=priority, added_by=added_by)
64
+ self._items.append(item)
65
+ self._save()
66
+ return item
67
+
68
+ def assign_to_sprint(self, item_id: str, sprint_number: int) -> None:
69
+ item = self.get(item_id)
70
+ if item:
71
+ item.sprint_assigned = sprint_number
72
+ item.status = "in_sprint"
73
+ self._save()
74
+
75
+ def complete(self, item_id: str) -> None:
76
+ item = self.get(item_id)
77
+ if item:
78
+ item.status = "done"
79
+ self._save()
80
+
81
+ def cut(self, item_id: str) -> None:
82
+ """Cut an item - decided not to do it."""
83
+ item = self.get(item_id)
84
+ if item:
85
+ item.status = "cut"
86
+ self._save()
87
+
88
+ def get(self, item_id: str) -> BacklogItem | None:
89
+ for item in self._items:
90
+ if item.id == item_id:
91
+ return item
92
+ return None
93
+
94
+ def open_items(self) -> list[BacklogItem]:
95
+ return [i for i in self._items if i.status == "open"]
96
+
97
+ def sprint_items(self, sprint_number: int) -> list[BacklogItem]:
98
+ return [i for i in self._items if i.sprint_assigned == sprint_number]
99
+
100
+ def all_items(self) -> list[BacklogItem]:
101
+ return list(self._items)
102
+
103
+ def summary_for_agent(self) -> str:
104
+ """Produce a backlog summary for agent context."""
105
+ open_items = self.open_items()
106
+ if not open_items:
107
+ return "Backlog is empty."
108
+
109
+ priority_order = [Priority.CRITICAL, Priority.HIGH, Priority.MEDIUM, Priority.LOW, Priority.NICE_TO_HAVE]
110
+ lines = ["Current backlog:"]
111
+ for p in priority_order:
112
+ items = [i for i in open_items if i.priority == p]
113
+ if items:
114
+ lines.append(f"\n [{p.value.upper()}]")
115
+ for item in items:
116
+ lines.append(f" {item.id}: {item.description}")
117
+ return "\n".join(lines)