kunchi 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 (108) hide show
  1. kunchi/__init__.py +8 -0
  2. kunchi/agents/__init__.py +4 -0
  3. kunchi/agents/cli/__init__.py +0 -0
  4. kunchi/agents/executor.py +155 -0
  5. kunchi/agents/fake.py +98 -0
  6. kunchi/agents/implementor_contract.py +3 -0
  7. kunchi/agents/parsing.py +129 -0
  8. kunchi/agents/planner_contract.py +21 -0
  9. kunchi/agents/prompts.py +57 -0
  10. kunchi/agents/registry.py +61 -0
  11. kunchi/agents/validator_contract.py +6 -0
  12. kunchi/auth/__init__.py +11 -0
  13. kunchi/auth/models.py +24 -0
  14. kunchi/auth/provider.py +15 -0
  15. kunchi/auth/providers/__init__.py +3 -0
  16. kunchi/auth/providers/memory.py +44 -0
  17. kunchi/cli/__init__.py +5 -0
  18. kunchi/cli/main.py +129 -0
  19. kunchi/config/__init__.py +21 -0
  20. kunchi/config/defaults.py +11 -0
  21. kunchi/config/harness_config.py +76 -0
  22. kunchi/config/resolver.py +51 -0
  23. kunchi/events/__init__.py +3 -0
  24. kunchi/events/bus.py +20 -0
  25. kunchi/examples/__init__.py +0 -0
  26. kunchi/examples/demo.py +47 -0
  27. kunchi/examples/manual/README.md +38 -0
  28. kunchi/examples/manual/__init__.py +1 -0
  29. kunchi/examples/manual/_helpers.py +19 -0
  30. kunchi/examples/manual/cli_agent_fake_binary.py +65 -0
  31. kunchi/examples/manual/cli_runner.py +29 -0
  32. kunchi/examples/manual/cursor_provider_argv.py +63 -0
  33. kunchi/examples/manual/cursor_smoke.py +75 -0
  34. kunchi/examples/manual/harness_fake.py +29 -0
  35. kunchi/examples/manual/inspect_plan.py +32 -0
  36. kunchi/examples/manual/list_plans.py +34 -0
  37. kunchi/examples/manual/load_profile_file.py +44 -0
  38. kunchi/examples/manual/mixed_config.py +43 -0
  39. kunchi/examples/manual/shell_validator.py +38 -0
  40. kunchi/examples/manual/trace.py +15 -0
  41. kunchi/examples/manual/trace_harness.py +25 -0
  42. kunchi/harness.py +193 -0
  43. kunchi/hello.py +3 -0
  44. kunchi/models/__init__.py +54 -0
  45. kunchi/models/agent.py +42 -0
  46. kunchi/models/artifact.py +8 -0
  47. kunchi/models/context.py +23 -0
  48. kunchi/models/events.py +159 -0
  49. kunchi/models/output.py +52 -0
  50. kunchi/models/plan.py +70 -0
  51. kunchi/models/report.py +20 -0
  52. kunchi/models/task.py +65 -0
  53. kunchi/models/validation.py +19 -0
  54. kunchi/plan/__init__.py +3 -0
  55. kunchi/plan/engine.py +139 -0
  56. kunchi/plan/parsing.py +191 -0
  57. kunchi/plan/resume.py +28 -0
  58. kunchi/profiles/__init__.py +7 -0
  59. kunchi/profiles/base.py +35 -0
  60. kunchi/profiles/cursor/__init__.py +3 -0
  61. kunchi/profiles/cursor/profile.py +51 -0
  62. kunchi/profiles/examples/cursor.yaml +33 -0
  63. kunchi/profiles/examples/minimal.json +32 -0
  64. kunchi/profiles/examples/minimal.yaml +20 -0
  65. kunchi/profiles/fake/__init__.py +3 -0
  66. kunchi/profiles/fake/profile.py +20 -0
  67. kunchi/profiles/loader.py +51 -0
  68. kunchi/profiles/registry.py +25 -0
  69. kunchi/project/__init__.py +5 -0
  70. kunchi/project/settings.py +178 -0
  71. kunchi/providers/__init__.py +7 -0
  72. kunchi/providers/agents/__init__.py +13 -0
  73. kunchi/providers/agents/base.py +52 -0
  74. kunchi/providers/agents/cli_agent.py +50 -0
  75. kunchi/providers/agents/cursor/__init__.py +3 -0
  76. kunchi/providers/agents/cursor/provider.py +188 -0
  77. kunchi/providers/agents/registry.py +23 -0
  78. kunchi/providers/agents/runner.py +102 -0
  79. kunchi/providers/agents/shell/__init__.py +3 -0
  80. kunchi/providers/agents/shell/agent.py +107 -0
  81. kunchi/providers/vcs/__init__.py +4 -0
  82. kunchi/providers/vcs/artifacts.py +15 -0
  83. kunchi/providers/vcs/base.py +23 -0
  84. kunchi/providers/vcs/git/__init__.py +3 -0
  85. kunchi/providers/vcs/git/backend.py +94 -0
  86. kunchi/providers/vcs/hg/__init__.py +3 -0
  87. kunchi/providers/vcs/hg/backend.py +31 -0
  88. kunchi/providers/vcs/registry.py +39 -0
  89. kunchi/state/__init__.py +5 -0
  90. kunchi/state/file.py +50 -0
  91. kunchi/state/memory.py +22 -0
  92. kunchi/state/store.py +13 -0
  93. kunchi/task/__init__.py +3 -0
  94. kunchi/task/engine.py +285 -0
  95. kunchi/task/scheduler.py +20 -0
  96. kunchi/tracing/__init__.py +31 -0
  97. kunchi/tracing/format.py +330 -0
  98. kunchi/tracing/monitor.py +190 -0
  99. kunchi/tracing/preview.py +7 -0
  100. kunchi/tracing/trace.py +59 -0
  101. kunchi/validation/__init__.py +4 -0
  102. kunchi/validation/feedback.py +44 -0
  103. kunchi/validation/output.py +99 -0
  104. kunchi-0.1.0.dist-info/METADATA +168 -0
  105. kunchi-0.1.0.dist-info/RECORD +108 -0
  106. kunchi-0.1.0.dist-info/WHEEL +4 -0
  107. kunchi-0.1.0.dist-info/entry_points.txt +3 -0
  108. kunchi-0.1.0.dist-info/licenses/LICENSE +21 -0
kunchi/__init__.py ADDED
@@ -0,0 +1,8 @@
1
+ """Kunchi — a simple coding harness."""
2
+
3
+ from kunchi.config import HarnessConfig
4
+ from kunchi.harness import Harness, default_harness_config
5
+ from kunchi.hello import hello
6
+ from kunchi.models import PlanStatus, RunReport
7
+
8
+ __all__ = ["Harness", "HarnessConfig", "PlanStatus", "RunReport", "default_harness_config", "hello"]
@@ -0,0 +1,4 @@
1
+ from kunchi.agents.fake import FakeImplementorAgent, FakePlannerAgent, FakeValidatorAgent
2
+ from kunchi.agents.registry import AgentRegistry
3
+
4
+ __all__ = ["AgentRegistry", "FakeImplementorAgent", "FakePlannerAgent", "FakeValidatorAgent"]
File without changes
@@ -0,0 +1,155 @@
1
+ from kunchi.plan.parsing import PlanDraftParseError, parse_plan_draft
2
+ from kunchi.agents.prompts import PromptBuilder
3
+ from kunchi.agents.registry import AgentRegistry
4
+ from kunchi.config.resolver import ConfigResolver
5
+ from kunchi.events.bus import EventBus
6
+ from kunchi.models.agent import AgentInput, AgentResult, AgentRole, AgentSpec
7
+ from kunchi.models.context import PlanContext, TaskContext
8
+ from kunchi.models.events import AgentCompleted, AgentInvoked
9
+ from kunchi.models.plan import PlanDraft
10
+ from kunchi.models.task import FixRequest, Task
11
+ from kunchi.models.validation import ValidationResult
12
+ from kunchi.tracing.preview import preview_text
13
+ from kunchi.tracing.trace import (
14
+ invocation_from_metadata,
15
+ traced_agent_result,
16
+ traced_validation_result,
17
+ )
18
+
19
+
20
+ class AgentExecutor:
21
+ def __init__(
22
+ self,
23
+ registry: AgentRegistry,
24
+ prompts: PromptBuilder,
25
+ resolver: ConfigResolver,
26
+ events: EventBus | None = None,
27
+ ) -> None:
28
+ self._registry = registry
29
+ self._prompts = prompts
30
+ self._resolver = resolver
31
+ self._events = events
32
+
33
+ async def invoke_planner(self, spec: AgentSpec, goal: str, context: PlanContext) -> PlanDraft:
34
+ agent = self._registry.resolve(spec)
35
+ prompt = self._prompts.build_planner_prompt(goal, context)
36
+ result = await self._invoke_agent(
37
+ agent,
38
+ AgentInput(role=AgentRole.PLANNER, prompt=prompt, context=context),
39
+ spec,
40
+ prompt,
41
+ context,
42
+ )
43
+ if not isinstance(result, AgentResult):
44
+ raise TypeError("Planner must return AgentResult")
45
+ return parse_plan_draft(result)
46
+
47
+ async def invoke_implementor(
48
+ self,
49
+ task: Task,
50
+ ctx: TaskContext,
51
+ fix: FixRequest | None = None,
52
+ ) -> AgentResult:
53
+ agent = self._registry.resolve(task.implementor)
54
+ prompt = self._prompts.build_implementor_prompt(task, ctx, fix)
55
+ result = await self._invoke_agent(
56
+ agent,
57
+ AgentInput(role=AgentRole.IMPLEMENTOR, prompt=prompt, context=ctx),
58
+ task.implementor,
59
+ prompt,
60
+ ctx,
61
+ )
62
+ if not isinstance(result, AgentResult):
63
+ raise TypeError("Implementor must return AgentResult")
64
+ return result
65
+
66
+ async def invoke_validator(
67
+ self,
68
+ task: Task,
69
+ validator: AgentSpec,
70
+ ctx: TaskContext,
71
+ implementor: AgentResult,
72
+ attempt: int,
73
+ ) -> ValidationResult:
74
+ agent = self._registry.resolve(validator)
75
+ prompt = self._prompts.build_validator_prompt(task, validator, ctx, implementor)
76
+ ctx.metadata["attempt"] = attempt
77
+ result = await self._invoke_agent(
78
+ agent,
79
+ AgentInput(role=AgentRole.VALIDATOR, prompt=prompt, context=ctx),
80
+ validator,
81
+ prompt,
82
+ ctx,
83
+ )
84
+ if not isinstance(result, ValidationResult):
85
+ raise TypeError("Validator must return ValidationResult")
86
+ return result
87
+
88
+ async def _invoke_agent(
89
+ self,
90
+ agent: object,
91
+ agent_input: AgentInput,
92
+ spec: AgentSpec,
93
+ prompt: str,
94
+ context: PlanContext | TaskContext,
95
+ ) -> AgentResult | ValidationResult:
96
+ plan_id, task_id, attempt = self._context_ids(context)
97
+ preview_limit = self._resolver.trace_preview_chars()
98
+
99
+ await self._emit(
100
+ AgentInvoked(
101
+ agent_id=spec.id,
102
+ role=agent_input.role,
103
+ prompt=preview_text(prompt, preview_limit),
104
+ plan_id=plan_id,
105
+ task_id=task_id,
106
+ attempt=attempt,
107
+ )
108
+ )
109
+
110
+ result = await agent.invoke(agent_input) # type: ignore[union-attr]
111
+
112
+ agent_result: AgentResult | None = None
113
+ validation_result: ValidationResult | None = None
114
+ invocation = None
115
+
116
+ if isinstance(result, AgentResult):
117
+ agent_result = traced_agent_result(result, preview_limit)
118
+ invocation = invocation_from_metadata(agent_result.metadata)
119
+ elif isinstance(result, ValidationResult):
120
+ validation_result = traced_validation_result(result, preview_limit)
121
+ invocation = invocation_from_metadata(validation_result.metadata)
122
+
123
+ await self._emit(
124
+ AgentCompleted(
125
+ agent_id=spec.id,
126
+ role=agent_input.role,
127
+ prompt=preview_text(prompt, preview_limit),
128
+ plan_id=plan_id,
129
+ task_id=task_id,
130
+ attempt=attempt,
131
+ agent_result=agent_result,
132
+ validation_result=validation_result,
133
+ invocation=invocation,
134
+ )
135
+ )
136
+ return result
137
+
138
+ async def _emit(self, event: AgentInvoked | AgentCompleted) -> None:
139
+ if self._events is None:
140
+ return
141
+ await self._events.emit(event)
142
+
143
+ @staticmethod
144
+ def _context_ids(context: PlanContext | TaskContext) -> tuple[str | None, str | None, int | None]:
145
+ if not isinstance(context, TaskContext):
146
+ return None, None, None
147
+ metadata = context.metadata
148
+ plan_id = metadata.get("plan_id")
149
+ task_id = metadata.get("task_id")
150
+ attempt = metadata.get("attempt")
151
+ return (
152
+ str(plan_id) if plan_id is not None else None,
153
+ str(task_id) if task_id is not None else None,
154
+ int(attempt) if attempt is not None else None,
155
+ )
kunchi/agents/fake.py ADDED
@@ -0,0 +1,98 @@
1
+ import json
2
+ from typing import Protocol
3
+
4
+ from kunchi.models.agent import AgentInput, AgentResult, AgentRole, AgentSpec
5
+ from kunchi.models.validation import ValidationResult
6
+
7
+
8
+ class Agent(Protocol):
9
+ spec: AgentSpec
10
+
11
+ async def invoke(self, input: AgentInput) -> AgentResult | ValidationResult: ...
12
+
13
+
14
+ class FakePlannerAgent:
15
+ def __init__(self, spec: AgentSpec) -> None:
16
+ self.spec = spec
17
+
18
+ async def invoke(self, input: AgentInput) -> AgentResult:
19
+ goal = input.prompt
20
+ if "invalid-plan" in goal.lower():
21
+ raw = "{not valid json"
22
+ else:
23
+ draft = {
24
+ "tasks": [
25
+ {
26
+ "title": "Setup foundation",
27
+ "description": f"Initial work for: {goal[:80]}",
28
+ "acceptance_criteria": ["Foundation exists"],
29
+ "depends_on": [],
30
+ },
31
+ {
32
+ "title": "Complete feature",
33
+ "description": "Finish the requested feature",
34
+ "acceptance_criteria": ["Feature works"],
35
+ "depends_on": ["task-1"],
36
+ },
37
+ ]
38
+ }
39
+ raw = json.dumps(draft)
40
+ return AgentResult(
41
+ agent_id=self.spec.id,
42
+ role=AgentRole.PLANNER,
43
+ summary="Fake planner produced a two-task plan",
44
+ raw_output=raw,
45
+ metadata={"kind": "fake-planner"},
46
+ )
47
+
48
+
49
+ class FakeImplementorAgent:
50
+ def __init__(self, spec: AgentSpec) -> None:
51
+ self.spec = spec
52
+ self._attempts: dict[str, int] = {}
53
+
54
+ async def invoke(self, input: AgentInput) -> AgentResult:
55
+ task_key = input.prompt[:64]
56
+ attempt = self._attempts.get(task_key, 0) + 1
57
+ self._attempts[task_key] = attempt
58
+
59
+ fail_output_attempts = int(self.spec.config.get("fail_output_attempts", 0))
60
+ if attempt <= fail_output_attempts:
61
+ raw = json.dumps({"summary": "missing required structure"})
62
+ else:
63
+ raw = json.dumps(
64
+ {
65
+ "summary": f"Implemented task (attempt {attempt})",
66
+ "result_code": "ok",
67
+ }
68
+ )
69
+
70
+ return AgentResult(
71
+ agent_id=self.spec.id,
72
+ role=AgentRole.IMPLEMENTOR,
73
+ summary=f"Fake implementor attempt {attempt}",
74
+ raw_output=raw,
75
+ metadata={"attempt": attempt, "kind": "fake-implementor"},
76
+ )
77
+
78
+
79
+ class FakeValidatorAgent:
80
+ def __init__(self, spec: AgentSpec) -> None:
81
+ self.spec = spec
82
+
83
+ async def invoke(self, input: AgentInput) -> ValidationResult:
84
+ fail_until_attempt = int(self.spec.config.get("fail_until_attempt", 0))
85
+ attempt = int(input.context.metadata.get("attempt", 1)) # type: ignore[union-attr]
86
+
87
+ if attempt <= fail_until_attempt:
88
+ return ValidationResult(
89
+ validator_id=self.spec.id,
90
+ passed=False,
91
+ feedback=f"{self.spec.id} rejected attempt {attempt}",
92
+ )
93
+
94
+ return ValidationResult(
95
+ validator_id=self.spec.id,
96
+ passed=True,
97
+ feedback=f"{self.spec.id} approved",
98
+ )
@@ -0,0 +1,3 @@
1
+ IMPLEMENTOR_OUTPUT_INSTRUCTIONS = """Return ONLY a single JSON object matching the required output contract (no markdown fences, no commentary).
2
+ Use snake_case field names exactly as specified in the contract.
3
+ When git_commit is required, create a git commit with your changes before responding."""
@@ -0,0 +1,129 @@
1
+ import json
2
+ import re
3
+
4
+ from kunchi.models.validation import ValidationResult
5
+
6
+
7
+ def extract_json_from_stdout(text: str) -> dict[str, object]:
8
+ """Extract a JSON object from CLI stdout, tolerating leading/trailing noise."""
9
+ text = text.strip()
10
+ if not text:
11
+ raise ValueError("Empty stdout")
12
+
13
+ for line in text.splitlines():
14
+ line = line.strip()
15
+ if not line:
16
+ continue
17
+ try:
18
+ data = json.loads(line)
19
+ except json.JSONDecodeError:
20
+ continue
21
+ if isinstance(data, dict):
22
+ return data
23
+
24
+ try:
25
+ data = json.loads(text)
26
+ if isinstance(data, dict):
27
+ return data
28
+ except json.JSONDecodeError:
29
+ pass
30
+
31
+ block = _find_json_object(text)
32
+ if block is None:
33
+ raise ValueError("No JSON object found in stdout")
34
+ data = json.loads(block)
35
+ if not isinstance(data, dict):
36
+ raise ValueError("Parsed JSON is not an object")
37
+ return data
38
+
39
+
40
+ def unwrap_cursor_cli_text(data: dict[str, object]) -> str | None:
41
+ """Extract assistant/plan text from a Cursor CLI JSON envelope."""
42
+ if data.get("type") == "result":
43
+ inner = data.get("result")
44
+ if isinstance(inner, str) and inner.strip():
45
+ return inner.strip()
46
+ if isinstance(inner, dict):
47
+ nested = extract_assistant_text(inner)
48
+ return nested or json.dumps(inner)
49
+
50
+ direct = extract_assistant_text(data)
51
+ if direct:
52
+ return direct
53
+
54
+ for key in ("text", "output", "content", "message"):
55
+ value = data.get(key)
56
+ if isinstance(value, str) and value.strip():
57
+ return value.strip()
58
+
59
+ return None
60
+
61
+
62
+ def extract_assistant_text(data: dict[str, object]) -> str | None:
63
+ message = data.get("message")
64
+ if isinstance(message, dict):
65
+ content = message.get("content")
66
+ if isinstance(content, list):
67
+ parts: list[str] = []
68
+ for item in content:
69
+ if isinstance(item, dict):
70
+ text = item.get("text")
71
+ if isinstance(text, str) and text.strip():
72
+ parts.append(text.strip())
73
+ if parts:
74
+ return "\n".join(parts)
75
+ text = message.get("text")
76
+ if isinstance(text, str) and text.strip():
77
+ return text.strip()
78
+
79
+ if "tasks" in data and isinstance(data.get("tasks"), list):
80
+ return json.dumps(data)
81
+
82
+ return None
83
+
84
+
85
+ def _find_json_object(text: str) -> str | None:
86
+ start = text.find("{")
87
+ if start == -1:
88
+ return None
89
+ depth = 0
90
+ for index, char in enumerate(text[start:], start=start):
91
+ if char == "{":
92
+ depth += 1
93
+ elif char == "}":
94
+ depth -= 1
95
+ if depth == 0:
96
+ return text[start : index + 1]
97
+ return None
98
+
99
+
100
+ def extract_json_object_from_text(text: str) -> dict[str, object] | None:
101
+ """Extract a JSON object from prose, fenced blocks, or CLI envelope text."""
102
+ text = text.strip()
103
+ if not text:
104
+ return None
105
+
106
+ try:
107
+ return extract_json_from_stdout(text)
108
+ except ValueError:
109
+ pass
110
+
111
+ for block in re.findall(r"```(?:json)?\s*([\s\S]*?)```", text, flags=re.IGNORECASE):
112
+ try:
113
+ return extract_json_from_stdout(block)
114
+ except ValueError:
115
+ continue
116
+
117
+ return None
118
+
119
+
120
+ def parse_validation_result(data: dict[str, object], validator_id: str) -> ValidationResult:
121
+ passed = bool(data.get("passed", False))
122
+ feedback = str(data.get("feedback", ""))
123
+ if not passed and not feedback:
124
+ feedback = "Validation failed"
125
+ return ValidationResult(
126
+ validator_id=validator_id,
127
+ passed=passed,
128
+ feedback=feedback,
129
+ )
@@ -0,0 +1,21 @@
1
+ PLAN_DRAFT_JSON_EXAMPLE = """{
2
+ "tasks": [
3
+ {
4
+ "title": "Short task title",
5
+ "description": "What to implement",
6
+ "acceptance_criteria": ["Criterion one", "Criterion two"],
7
+ "depends_on": []
8
+ }
9
+ ]
10
+ }"""
11
+
12
+ PLANNER_OUTPUT_INSTRUCTIONS = f"""Return ONLY a single JSON object matching this schema (no markdown fences, no commentary):
13
+
14
+ {PLAN_DRAFT_JSON_EXAMPLE}
15
+
16
+ Rules:
17
+ - "tasks" is required and must contain at least one task object.
18
+ - Each task requires "title" and "description" strings.
19
+ - Use snake_case field names exactly as shown.
20
+ - "depends_on" lists task titles or ids that must finish first (use [] when none).
21
+ - Keep the plan focused and actionable for the given goal."""
@@ -0,0 +1,57 @@
1
+ from kunchi.agents.implementor_contract import IMPLEMENTOR_OUTPUT_INSTRUCTIONS
2
+ from kunchi.agents.planner_contract import PLANNER_OUTPUT_INSTRUCTIONS
3
+ from kunchi.agents.validator_contract import VALIDATOR_OUTPUT_INSTRUCTIONS
4
+ from kunchi.models.agent import AgentResult, AgentSpec
5
+ from kunchi.models.context import PlanContext, TaskContext
6
+ from kunchi.models.task import FixRequest, Task
7
+ from kunchi.models.validation import ValidationResult
8
+
9
+
10
+ class PromptBuilder:
11
+ def build_planner_prompt(self, goal: str, context: PlanContext) -> str:
12
+ return (
13
+ f"Goal: {goal}\n"
14
+ f"Workspace: {context.workspace.path}\n\n"
15
+ f"{PLANNER_OUTPUT_INSTRUCTIONS}"
16
+ )
17
+
18
+ def build_implementor_prompt(
19
+ self,
20
+ task: Task,
21
+ ctx: TaskContext,
22
+ fix: FixRequest | None = None,
23
+ ) -> str:
24
+ parts = [
25
+ f"Task: {task.title}",
26
+ f"Description: {task.description}",
27
+ task.output_contract.to_prompt_instructions(),
28
+ IMPLEMENTOR_OUTPUT_INSTRUCTIONS,
29
+ ]
30
+ if task.acceptance_criteria:
31
+ parts.append("Acceptance criteria:\n- " + "\n- ".join(task.acceptance_criteria))
32
+ if ctx.upstream_outputs:
33
+ parts.append("Upstream completed tasks:")
34
+ for task_id, result in ctx.upstream_outputs.items():
35
+ parts.append(f"- {task_id}: {result.summary}")
36
+ if fix and fix.accumulated_feedback:
37
+ parts.append(f"Fix the following issues:\n{fix.accumulated_feedback}")
38
+ return "\n\n".join(parts)
39
+
40
+ def build_validator_prompt(
41
+ self,
42
+ task: Task,
43
+ validator: AgentSpec,
44
+ ctx: TaskContext,
45
+ implementor: AgentResult,
46
+ ) -> str:
47
+ del ctx
48
+ field_lines = [f"- {key}: {value}" for key, value in implementor.fields.items()]
49
+ criteria = "\n- ".join(task.acceptance_criteria) or "(none)"
50
+ return (
51
+ f"Validator: {validator.id}\n"
52
+ f"Task: {task.title}\n"
53
+ f"Implementor summary: {implementor.summary}\n"
54
+ f"Implementor fields:\n" + "\n".join(field_lines) + "\n"
55
+ f"Acceptance criteria:\n- {criteria}\n\n"
56
+ f"{VALIDATOR_OUTPUT_INSTRUCTIONS}"
57
+ )
@@ -0,0 +1,61 @@
1
+ from typing import Protocol
2
+
3
+ from kunchi.agents.fake import FakeImplementorAgent, FakePlannerAgent, FakeValidatorAgent
4
+ from kunchi.config.resolver import ConfigResolver
5
+ from kunchi.models.agent import AgentInput, AgentResult, AgentSpec, AgentRole
6
+ from kunchi.models.validation import ValidationResult
7
+ from kunchi.providers.agents.cli_agent import CLIAgent
8
+ from kunchi.providers.agents.registry import AgentClientRegistry
9
+ from kunchi.providers.agents.runner import CLIRunner
10
+ from kunchi.providers.agents.shell import ShellAgent
11
+ from kunchi.providers.vcs.artifacts import ArtifactCollector
12
+
13
+
14
+ class Agent(Protocol):
15
+ spec: AgentSpec
16
+
17
+ async def invoke(self, input: AgentInput) -> AgentResult | ValidationResult: ...
18
+
19
+
20
+ class AgentRegistry:
21
+ def __init__(
22
+ self,
23
+ resolver: ConfigResolver,
24
+ runner: CLIRunner | None = None,
25
+ artifact_collector: ArtifactCollector | None = None,
26
+ agent_clients: AgentClientRegistry | None = None,
27
+ ) -> None:
28
+ self._resolver = resolver
29
+ self._runner = runner or CLIRunner()
30
+ self._artifact_collector = artifact_collector or ArtifactCollector()
31
+ self._agent_clients = agent_clients or AgentClientRegistry.default()
32
+
33
+ def resolve(self, spec: AgentSpec) -> Agent:
34
+ if spec.config.get("backend") == "fake":
35
+ return self._resolve_fake(spec)
36
+
37
+ cli_name = spec.config.get("cli")
38
+ if cli_name is None:
39
+ return self._resolve_fake(spec)
40
+
41
+ cli_name = str(cli_name)
42
+ if cli_name == "shell":
43
+ return ShellAgent(spec, self._runner, self._resolver)
44
+
45
+ provider = self._agent_clients.get(cli_name)
46
+ return CLIAgent(
47
+ spec,
48
+ provider,
49
+ self._runner,
50
+ self._artifact_collector,
51
+ self._resolver,
52
+ )
53
+
54
+ def _resolve_fake(self, spec: AgentSpec) -> Agent:
55
+ if spec.role is AgentRole.PLANNER:
56
+ return FakePlannerAgent(spec)
57
+ if spec.role is AgentRole.IMPLEMENTOR:
58
+ return FakeImplementorAgent(spec)
59
+ if spec.role is AgentRole.VALIDATOR:
60
+ return FakeValidatorAgent(spec)
61
+ raise ValueError(f"Unsupported fake agent role: {spec.role}")
@@ -0,0 +1,6 @@
1
+ VALIDATOR_OUTPUT_INSTRUCTIONS = """Return ONLY a single JSON object (no markdown fences, no commentary):
2
+ {"passed": true|false, "feedback": "short explanation"}
3
+
4
+ Rules:
5
+ - "passed" is required and must be a boolean.
6
+ - "feedback" is required when passed is false; may be brief when passed is true."""
@@ -0,0 +1,11 @@
1
+ from kunchi.auth.models import AuthConfig, AuthToken, UserCredentials
2
+ from kunchi.auth.provider import AuthProvider
3
+ from kunchi.auth.providers.memory import InMemoryAuthProvider
4
+
5
+ __all__ = [
6
+ "AuthConfig",
7
+ "AuthProvider",
8
+ "AuthToken",
9
+ "InMemoryAuthProvider",
10
+ "UserCredentials",
11
+ ]
kunchi/auth/models.py ADDED
@@ -0,0 +1,24 @@
1
+ from pydantic import BaseModel, Field
2
+
3
+
4
+ class AuthConfig(BaseModel):
5
+ """Configuration for authentication providers."""
6
+
7
+ secret_key: str = Field(min_length=1, description="Signing key for tokens")
8
+ token_ttl_seconds: int = Field(default=3600, ge=1)
9
+ algorithm: str = "HS256"
10
+
11
+
12
+ class UserCredentials(BaseModel):
13
+ """Credentials supplied during login."""
14
+
15
+ username: str = Field(min_length=1)
16
+ password: str = Field(min_length=1)
17
+
18
+
19
+ class AuthToken(BaseModel):
20
+ """Issued authentication token."""
21
+
22
+ access_token: str
23
+ token_type: str = "bearer"
24
+ expires_in: int | None = None
@@ -0,0 +1,15 @@
1
+ from typing import Protocol
2
+
3
+ from kunchi.auth.models import AuthToken, UserCredentials
4
+
5
+
6
+ class AuthProvider(Protocol):
7
+ """Contract for authentication backends."""
8
+
9
+ async def authenticate(self, credentials: UserCredentials) -> AuthToken | None:
10
+ """Validate credentials and return a token, or None if invalid."""
11
+ ...
12
+
13
+ async def verify_token(self, token: str) -> str | None:
14
+ """Return the authenticated subject for a valid token, or None."""
15
+ ...
@@ -0,0 +1,3 @@
1
+ from kunchi.auth.providers.memory import InMemoryAuthProvider
2
+
3
+ __all__ = ["InMemoryAuthProvider"]
@@ -0,0 +1,44 @@
1
+ import secrets
2
+ from datetime import UTC, datetime, timedelta
3
+
4
+ from kunchi.auth.models import AuthConfig, AuthToken, UserCredentials
5
+
6
+
7
+ class InMemoryAuthProvider:
8
+ """Simple in-memory auth backend for development and tests."""
9
+
10
+ def __init__(
11
+ self,
12
+ config: AuthConfig,
13
+ users: dict[str, str] | None = None,
14
+ ) -> None:
15
+ self._config = config
16
+ self._users = dict(users or {})
17
+ self._tokens: dict[str, tuple[str, datetime]] = {}
18
+
19
+ def register_user(self, username: str, password: str) -> None:
20
+ self._users[username] = password
21
+
22
+ async def authenticate(self, credentials: UserCredentials) -> AuthToken | None:
23
+ if self._users.get(credentials.username) != credentials.password:
24
+ return None
25
+
26
+ token = secrets.token_urlsafe(32)
27
+ expires_at = datetime.now(UTC) + timedelta(seconds=self._config.token_ttl_seconds)
28
+ self._tokens[token] = (credentials.username, expires_at)
29
+ return AuthToken(
30
+ access_token=token,
31
+ expires_in=self._config.token_ttl_seconds,
32
+ )
33
+
34
+ async def verify_token(self, token: str) -> str | None:
35
+ entry = self._tokens.get(token)
36
+ if entry is None:
37
+ return None
38
+
39
+ username, expires_at = entry
40
+ if datetime.now(UTC) >= expires_at:
41
+ del self._tokens[token]
42
+ return None
43
+
44
+ return username
kunchi/cli/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ """Kunchi command-line interface."""
2
+
3
+ from kunchi.cli.main import main
4
+
5
+ __all__ = ["main"]