Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions src/draft/command_create.py
Original file line number Diff line number Diff line change
Expand Up @@ -599,6 +599,15 @@ def run(args) -> int:
worktree_mode,
)

try:
from draft.steps.implement_spec import original_spec

label = original_spec.preamble_label(ctx)
if label:
print(f"original-spec: attached from {label}", file=sys.stderr)
except Exception:
pass

# 14. Lifecycle + engine
engine = Runner()
lifecycle = DraftLifecycle(
Expand Down
24 changes: 24 additions & 0 deletions src/draft/runs.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,12 @@ def _run_started_at(run_dir: Path) -> float | None:
return None
try:
return parse_human(started).timestamp()
except (ValueError, TypeError):
pass
try:
from datetime import datetime

return datetime.fromisoformat(started.replace("Z", "+00:00")).timestamp()
except (ValueError, TypeError):
return None

Expand Down Expand Up @@ -119,6 +125,24 @@ def is_run_finished(state: dict) -> bool:
return all(s in state.get("completed", []) for s in expected_steps(state))


def find_original_run_on_branch(project: str, branch: str) -> Path | None:
candidates = []
for run_dir in project_runs(project):
state = load_state(run_dir)
if state is None:
continue
data = state.get("data", {})
if data.get("branch") != branch:
continue
if data.get("branch_source") != "new":
continue
started = _run_started_at(run_dir) or 0.0
candidates.append((started, run_dir))
if not candidates:
return None
return min(candidates, key=lambda x: x[0])[1]


def find_active_run_on_branch(project: str, branch: str) -> Path | None:
from draft.pipelines import CorruptStateError

Expand Down
35 changes: 30 additions & 5 deletions src/draft/steps/implement_spec/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
from pathlib import Path

from draft.hooks import HookResult, _run_hook_cmd
from draft.steps.implement_spec import original_spec
from draft.steps.implement_spec._live_status import LiveStatusSummarizer
from pipeline import Step, StepError
from pipeline.runner import TIMEOUT_EXIT
Expand All @@ -30,7 +31,7 @@ def _render_verify_commands(entries: list[dict]) -> str:
return ""
block = "\n".join(cmds)
return (
"## Verify commands\n\n"
"## Verified commands\n\n"
"Draft will run the following after your changes. "
"Run them yourself before finishing if practical.\n\n"
f"```bash\n{block}\n```"
Expand All @@ -39,18 +40,35 @@ def _render_verify_commands(entries: list[dict]) -> str:

def _render_prompt(ctx, template: str, verify_commands: str) -> str:
spec = ctx.get("spec", "")
spec_section = f"## Current Spec\n\n{spec}"
verify_errors = ctx.step_get("implement-spec", "verify_errors", "")
if verify_errors:
verify_section = f"## Test failures\n\n{verify_errors}\n\nFix the above failures before committing."
verify_section = f"## Verified errors\n\n{verify_errors}\n\nFix the above failures before committing."
else:
verify_section = ""
original_spec_section = original_spec.render_original_spec(ctx)
return (
template.replace("{{SPEC}}", spec)
.replace("{{VERIFY_COMMANDS}}", verify_commands)
template.replace("{{VERIFY_COMMANDS}}", verify_commands)
.replace("{{ORIGINAL_SPEC}}", original_spec_section)
.replace("{{SPEC}}", spec_section)
.replace("{{VERIFY_ERRORS}}", verify_section)
)


def _log_prompt(log_path, prompt: str, attempt: int, max_attempts: int) -> None:
ts = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
block = (
f"=== implement-spec prompt (attempt {attempt}/{max_attempts}) @ {ts} ===\n"
f"{prompt}\n"
f"=== end prompt ===\n\n"
)
try:
with open(log_path, "a", encoding="utf-8") as f:
f.write(block)
except OSError as exc:
print(f"warning: could not write prompt log {log_path}: {exc}", file=sys.stderr)


def _has_changes(cwd: str) -> bool:
result = subprocess.run(
["git", "status", "--porcelain"],
Expand Down Expand Up @@ -355,8 +373,15 @@ def run(self, ctx, engine, lifecycle, step_metrics):
prefix=prefix,
).start()
try:
prompt = _render_prompt(ctx, impl_template, verify_commands)
_log_prompt(
ctx.log_path(self.name),
prompt,
attempt=attempt,
max_attempts=cfg["max_retries"],
)
engine.run_llm(
prompt=_render_prompt(ctx, impl_template, verify_commands),
prompt=prompt,
cwd=wt_dir,
log_path=ctx.log_path(self.name),
step_metrics=step_metrics,
Expand Down
2 changes: 1 addition & 1 deletion src/draft/steps/implement_spec/implement_spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ You must not run `git commit`, `git add`, `git push`, `git stash`, or any comman

{{VERIFY_COMMANDS}}

## Spec
{{ORIGINAL_SPEC}}

{{SPEC}}

Expand Down
70 changes: 70 additions & 0 deletions src/draft/steps/implement_spec/original_spec.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import os
from importlib.resources import files
from pathlib import Path

from draft.runs import find_original_run_on_branch, load_state

CASE_NONE = "none"
CASE_PRIOR_RUN = "prior_run"
CASE_OPEN_PR = "open_pr"
CASE_COMMITS_ONLY = "commits_only"

_CASE_FILES = {
CASE_PRIOR_RUN: "original_spec_prior_run.md",
CASE_OPEN_PR: "original_spec_open_pr.md",
CASE_COMMITS_ONLY: "original_spec_commits_only.md",
}


def _load_case_template(case: str) -> str:
return files("draft.steps.implement_spec").joinpath(_CASE_FILES[case]).read_text()


def _render_case(case: str, mapping: dict) -> str:
template = _load_case_template(case)
for key, value in mapping.items():
template = template.replace(f"{{{{{key}}}}}", value)
return template


def resolve_case(ctx) -> tuple[str, dict]:
if ctx.get("branch_source") != "existing":
return (CASE_NONE, {})
original_run = find_original_run_on_branch(ctx.get("project"), ctx.get("branch"))
if original_run is not None:
state = load_state(original_run)
spec_path = state.get("data", {}).get("spec") if state else None
if spec_path and Path(spec_path).is_file() and os.access(spec_path, os.R_OK):
return (
CASE_PRIOR_RUN,
{
"ORIGINAL_SPEC_PATH": spec_path,
"ORIGINAL_RUN_ID": original_run.name,
},
)
pr_url = ctx.get("pr_url")
if pr_url:
return (CASE_OPEN_PR, {"PR_URL": pr_url})
return (
CASE_COMMITS_ONLY,
{
"BRANCH": ctx.get("branch") or "",
"BASE_BRANCH": ctx.get("base_branch") or "origin/main",
},
)


def render_original_spec(ctx) -> str:
case, mapping = resolve_case(ctx)
if case == CASE_NONE:
return ""
return _render_case(case, mapping)


def preamble_label(ctx) -> str | None:
case, mapping = resolve_case(ctx)
if case == CASE_PRIOR_RUN:
return f"run {mapping['ORIGINAL_RUN_ID']}"
if case == CASE_OPEN_PR:
return f"PR {mapping['PR_URL']}"
return None
10 changes: 10 additions & 0 deletions src/draft/steps/implement_spec/original_spec_commits_only.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
## Original spec

No prior run or open PR was found. Review the branch commits for context.

```bash
git log {{BASE_BRANCH}}..HEAD --oneline
git show <commit>
```

Branch: `{{BRANCH}}`
9 changes: 9 additions & 0 deletions src/draft/steps/implement_spec/original_spec_open_pr.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
## Original spec

This branch has an open pull request. Review it to understand the original intent.

```bash
gh pr view {{PR_URL}}
```

PR: {{PR_URL}}
5 changes: 5 additions & 0 deletions src/draft/steps/implement_spec/original_spec_prior_run.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
## Original spec

This branch was started in run `{{ORIGINAL_RUN_ID}}` from the spec at:

{{ORIGINAL_SPEC_PATH}}
Loading
Loading