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.
- odd/__init__.py +3 -0
- odd/__main__.py +20 -0
- odd/agent.py +394 -0
- odd/backlog.py +117 -0
- odd/cfo.py +675 -0
- odd/cli/__init__.py +249 -0
- odd/cli/config_cmds.py +398 -0
- odd/cli/misc_cmds.py +134 -0
- odd/cli/sprint_cmds.py +384 -0
- odd/cli/team_cmds.py +261 -0
- odd/cli/vacation_cmds.py +317 -0
- odd/commands/__init__.py +1 -0
- odd/commands/hire_cmd.py +154 -0
- odd/commands/init_cmd.py +493 -0
- odd/config.py +323 -0
- odd/display.py +488 -0
- odd/fallback/__init__.py +5 -0
- odd/fallback/runner.py +427 -0
- odd/git.py +743 -0
- odd/health.py +253 -0
- odd/labels.py +190 -0
- odd/menu.py +258 -0
- odd/middleware.py +267 -0
- odd/models.py +550 -0
- odd/parsers.py +537 -0
- odd/processes.py +573 -0
- odd/provider.py +143 -0
- odd/providers/__init__.py +71 -0
- odd/providers/anthropic.py +312 -0
- odd/providers/openai_compat.py +389 -0
- odd/providers/openwebui.py +48 -0
- odd/review.py +275 -0
- odd/roles.py +206 -0
- odd/spinner.py +49 -0
- odd/sprint/__init__.py +18 -0
- odd/sprint/_console.py +21 -0
- odd/sprint/briefing.py +136 -0
- odd/sprint/engine.py +886 -0
- odd/sprint/phase_runner.py +333 -0
- odd/sprint/planner.py +297 -0
- odd/sprint/tdd_gate.py +570 -0
- odd/sprint/vacation_runner.py +516 -0
- odd/sprint/wrapup.py +988 -0
- odd/state.py +296 -0
- odd/tools/__init__.py +48 -0
- odd/tools/definitions.py +190 -0
- odd/tools/execution.py +706 -0
- odd/tools/isolation.py +242 -0
- odd/tools/schemas.py +406 -0
- odd/tracing.py +299 -0
- odd/values.py +55 -0
- odd_org-0.1.0.dist-info/METADATA +158 -0
- odd_org-0.1.0.dist-info/RECORD +56 -0
- odd_org-0.1.0.dist-info/WHEEL +4 -0
- odd_org-0.1.0.dist-info/entry_points.txt +2 -0
- odd_org-0.1.0.dist-info/licenses/LICENSE +21 -0
odd/__init__.py
ADDED
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)
|