awf 1.0.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.
- awf/__init__.py +3 -0
- awf/__main__.py +6 -0
- awf/_atomic.py +37 -0
- awf/_env.py +90 -0
- awf/_log.py +24 -0
- awf/agent_stage.py +275 -0
- awf/api/__init__.py +128 -0
- awf/api/_background.py +158 -0
- awf/api/_errors.py +10 -0
- awf/api/_helpers.py +71 -0
- awf/api/_results.py +286 -0
- awf/api/_stack.py +130 -0
- awf/api/_templates.py +110 -0
- awf/api/context.py +420 -0
- awf/api/dashboard.py +909 -0
- awf/api/dashboard_server.py +76 -0
- awf/api/dispatch.py +182 -0
- awf/api/lifecycle.py +683 -0
- awf/api/model_check.py +246 -0
- awf/api/pipeline.py +764 -0
- awf/api/planning.py +95 -0
- awf/api/roles.py +448 -0
- awf/api/setup.py +453 -0
- awf/api/wait_event.py +186 -0
- awf/cli.py +232 -0
- awf/cmd_add_role.py +42 -0
- awf/cmd_analyze_roles.py +82 -0
- awf/cmd_approve.py +24 -0
- awf/cmd_baseline.py +40 -0
- awf/cmd_init.py +218 -0
- awf/cmd_report.py +56 -0
- awf/cmd_reset.py +78 -0
- awf/cmd_rollback.py +51 -0
- awf/cmd_start.py +70 -0
- awf/cmd_status.py +71 -0
- awf/commit_gate.py +272 -0
- awf/config.py +36 -0
- awf/git_utils.py +81 -0
- awf/opencode_agents.py +159 -0
- awf/orchestrator.py +189 -0
- awf/paths.py +73 -0
- awf/phase.py +170 -0
- awf/pipeline.py +153 -0
- awf/pipeline_engine.py +583 -0
- awf/pipeline_state.py +135 -0
- awf/plan_checkpoint.py +605 -0
- awf/plan_progress.py +172 -0
- awf/signal_watch.py +203 -0
- awf/signals.py +152 -0
- awf/supervisor.py +907 -0
- awf/templates/dashboard.html.j2 +420 -0
- awf/templates/roles/supervisor/_core.md +44 -0
- awf/templates/roles/supervisor/phase-brief.md +61 -0
- awf/templates/roles/supervisor/phase-form.md +29 -0
- awf/templates/roles/supervisor/phase-goal.md +25 -0
- awf/templates/roles/supervisor/phase-init.md +17 -0
- awf/templates/roles/supervisor/phase-normalize.md +35 -0
- awf/templates/roles/supervisor/phase-run.md +17 -0
- awf/templates/roles/supervisor/phase-verify.md +28 -0
- awf/templates/roles/supervisor.md +633 -0
- awf/todos.py +161 -0
- awf/transitions.py +54 -0
- awf/verify.py +202 -0
- awf/xdg.py +43 -0
- awf-1.0.0.dist-info/METADATA +189 -0
- awf-1.0.0.dist-info/RECORD +69 -0
- awf-1.0.0.dist-info/WHEEL +5 -0
- awf-1.0.0.dist-info/licenses/LICENSE +21 -0
- awf-1.0.0.dist-info/top_level.txt +1 -0
awf/__init__.py
ADDED
awf/__main__.py
ADDED
awf/_atomic.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""Atomic file write helpers for awf-core.
|
|
2
|
+
|
|
3
|
+
Centralized so all modules use the same temp+rename pattern.
|
|
4
|
+
"""
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import os
|
|
8
|
+
import tempfile
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def atomic_write_text(path: Path, content: str, encoding: str = "utf-8") -> None:
|
|
13
|
+
"""Write text file atomically via temp + rename.
|
|
14
|
+
|
|
15
|
+
Creates parent dir if missing. Uses tempfile in same dir to guarantee
|
|
16
|
+
same-filesystem rename (atomic on POSIX).
|
|
17
|
+
|
|
18
|
+
H5 fix: plan_progress.py was direct write_text — crash mid-write
|
|
19
|
+
corrupted plan.md (the only persistent progress artifact).
|
|
20
|
+
"""
|
|
21
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
22
|
+
fd, tmp_path = tempfile.mkstemp(
|
|
23
|
+
prefix=f".{path.name}.",
|
|
24
|
+
suffix=".tmp",
|
|
25
|
+
dir=str(path.parent),
|
|
26
|
+
)
|
|
27
|
+
try:
|
|
28
|
+
with os.fdopen(fd, "w", encoding=encoding) as f:
|
|
29
|
+
f.write(content)
|
|
30
|
+
os.replace(tmp_path, path)
|
|
31
|
+
except Exception:
|
|
32
|
+
# Clean up temp file on failure
|
|
33
|
+
try:
|
|
34
|
+
os.unlink(tmp_path)
|
|
35
|
+
except OSError:
|
|
36
|
+
pass
|
|
37
|
+
raise
|
awf/_env.py
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""BD-22/25: subprocess environment setup for opencode run.
|
|
2
|
+
|
|
3
|
+
Centralized so signal_watch + supervisor + agent_stage can use it without
|
|
4
|
+
circular imports through orchestrator.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import os
|
|
10
|
+
import sys
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _pdeathsig_preexec() -> None:
|
|
14
|
+
"""DF6-8: Set PR_SET_PDEATHSIG so child dies when parent (orchestrator) dies.
|
|
15
|
+
|
|
16
|
+
Linux-only. On other platforms, no-op (best effort).
|
|
17
|
+
Prevents orphan worker subprocesses from continuing after orchestrator crash.
|
|
18
|
+
"""
|
|
19
|
+
if sys.platform != "linux":
|
|
20
|
+
return
|
|
21
|
+
try:
|
|
22
|
+
import ctypes
|
|
23
|
+
import signal as _signal
|
|
24
|
+
|
|
25
|
+
libc = ctypes.CDLL("libc.so.6", use_errno=True)
|
|
26
|
+
PR_SET_PDEATHSIG = 1
|
|
27
|
+
libc.prctl(PR_SET_PDEATHSIG, _signal.SIGTERM)
|
|
28
|
+
except Exception:
|
|
29
|
+
pass # best effort — don't crash if libc/prctl unavailable
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def awf_subprocess_env() -> dict[str, str]:
|
|
33
|
+
"""BD-22/KAUD-5: env for opencode subprocess spawned by awf.
|
|
34
|
+
|
|
35
|
+
Sets ``OPENCODE_CONFIG_CONTENT`` to override permission rules so
|
|
36
|
+
the subprocess can run ``edit``/``bash``/``write`` without prompting.
|
|
37
|
+
|
|
38
|
+
KAUD-5: MERGES with user's existing opencode.json instead of replacing.
|
|
39
|
+
Reads user's config, adds our permission overrides on top, preserves
|
|
40
|
+
user's other settings (theme, providers, agents, etc.).
|
|
41
|
+
|
|
42
|
+
BD-25: strip ``OPENCODE_SERVER_*`` env vars so the subprocess does NOT
|
|
43
|
+
attach to a running ``opencode serve`` instance.
|
|
44
|
+
"""
|
|
45
|
+
env = os.environ.copy()
|
|
46
|
+
|
|
47
|
+
# KAUD-5: Start from user's existing config, then merge our overrides
|
|
48
|
+
user_config: dict = {}
|
|
49
|
+
try:
|
|
50
|
+
from .xdg import opencode_config_file
|
|
51
|
+
config_path = opencode_config_file()
|
|
52
|
+
if config_path.is_file():
|
|
53
|
+
user_config = json.loads(config_path.read_text(encoding="utf-8"))
|
|
54
|
+
if not isinstance(user_config, dict):
|
|
55
|
+
user_config = {}
|
|
56
|
+
except (OSError, json.JSONDecodeError):
|
|
57
|
+
pass # If user config is unreadable, start from empty
|
|
58
|
+
|
|
59
|
+
# Merge: user config + our permission overrides (ours take priority)
|
|
60
|
+
merged = user_config.copy()
|
|
61
|
+
merged_permissions = merged.get("permission", {})
|
|
62
|
+
if not isinstance(merged_permissions, dict):
|
|
63
|
+
merged_permissions = {}
|
|
64
|
+
merged_permissions.update({
|
|
65
|
+
"edit": "allow",
|
|
66
|
+
"bash": "allow",
|
|
67
|
+
"write": "allow",
|
|
68
|
+
"webfetch": "allow",
|
|
69
|
+
})
|
|
70
|
+
merged["permission"] = merged_permissions
|
|
71
|
+
|
|
72
|
+
# P1 security: only serialize permission overrides to env — never API keys,
|
|
73
|
+
# providers, or other sensitive fields from opencode.json. Workers only
|
|
74
|
+
# need the permission overrides; everything else is loaded by opencode
|
|
75
|
+
# itself from the real config file.
|
|
76
|
+
config_json = json.dumps({"permission": merged_permissions})
|
|
77
|
+
env["OPENCODE_CONFIG_CONTENT"] = config_json
|
|
78
|
+
|
|
79
|
+
# BD-25: strip server env vars
|
|
80
|
+
strip_keys = (
|
|
81
|
+
"OPENCODE_SERVER",
|
|
82
|
+
"OPENCODE_SERVER_URL",
|
|
83
|
+
"OPENCODE_SERVER_TOKEN",
|
|
84
|
+
"OPENCODE_HOST",
|
|
85
|
+
"OPENCODE_HOST_TOKEN",
|
|
86
|
+
)
|
|
87
|
+
for key in list(env.keys()):
|
|
88
|
+
if key in strip_keys:
|
|
89
|
+
env.pop(key, None)
|
|
90
|
+
return env
|
awf/_log.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""Lightweight file-based logger for awf.
|
|
2
|
+
|
|
3
|
+
Centralized so any module can log without circular imports.
|
|
4
|
+
"""
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import datetime as _dt
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def log(logs_dir: Path, message: str) -> None:
|
|
12
|
+
"""Append a timestamped message to ``logs_dir/orchestrator.log``."""
|
|
13
|
+
if not logs_dir.exists():
|
|
14
|
+
try:
|
|
15
|
+
logs_dir.mkdir(parents=True, exist_ok=True)
|
|
16
|
+
except OSError:
|
|
17
|
+
return
|
|
18
|
+
log_file = logs_dir / "orchestrator.log"
|
|
19
|
+
ts = _dt.datetime.now(_dt.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
20
|
+
try:
|
|
21
|
+
with open(log_file, "a", encoding="utf-8") as f:
|
|
22
|
+
f.write(f"[{ts}] {message}\n")
|
|
23
|
+
except OSError:
|
|
24
|
+
pass
|
awf/agent_stage.py
ADDED
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
"""Agent stage — spawn opencode run for each pipeline role.
|
|
2
|
+
|
|
3
|
+
Extracted from orchestrator.py (A6 refactor).
|
|
4
|
+
"""
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import subprocess
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from . import paths
|
|
11
|
+
from ._atomic import atomic_write_text
|
|
12
|
+
from ._log import log as _log
|
|
13
|
+
from .pipeline import Stage
|
|
14
|
+
from .signals import clean_stage_signals, expected_signal_prefixes
|
|
15
|
+
from .supervisor import (
|
|
16
|
+
build_prompt,
|
|
17
|
+
get_agent_name,
|
|
18
|
+
get_role_model,
|
|
19
|
+
resolve_role_file,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def run_agent_stage(
|
|
24
|
+
stage: Stage,
|
|
25
|
+
todo_id: str,
|
|
26
|
+
project_dir: Path,
|
|
27
|
+
config: dict,
|
|
28
|
+
logs_dir: Path,
|
|
29
|
+
prev_handoffs: list[Path] | None = None,
|
|
30
|
+
hard_timeout: int | None = None,
|
|
31
|
+
) -> None:
|
|
32
|
+
"""Spawn opencode run for an agent stage.
|
|
33
|
+
|
|
34
|
+
KAUD-4: ``hard_timeout`` overrides default 3600s timeout. Pass through
|
|
35
|
+
from CLI --timeout or config. None = use BD20_HARD_TIMEOUT default.
|
|
36
|
+
|
|
37
|
+
BD-15: ``prev_handoffs`` is a list of handoff .md files from previous
|
|
38
|
+
pipeline stages (in pipeline order). Each is passed to the agent as
|
|
39
|
+
``--file`` so the agent sees what previous roles did.
|
|
40
|
+
"""
|
|
41
|
+
role = stage.role
|
|
42
|
+
kind = stage.kind
|
|
43
|
+
agent_name = get_agent_name(config, role)
|
|
44
|
+
|
|
45
|
+
print()
|
|
46
|
+
print("=" * 41)
|
|
47
|
+
print(f" AGENT STAGE: {role} ({kind})")
|
|
48
|
+
print(f" Task: {todo_id}")
|
|
49
|
+
print("=" * 41)
|
|
50
|
+
print()
|
|
51
|
+
|
|
52
|
+
role_file = resolve_role_file(role, project_dir)
|
|
53
|
+
inbox = paths.inbox(project_dir)
|
|
54
|
+
todo_file = inbox / f"{todo_id}.md"
|
|
55
|
+
|
|
56
|
+
prompt = build_prompt(kind, todo_id, project_dir=project_dir)
|
|
57
|
+
|
|
58
|
+
print(f"Running agent: {agent_name}")
|
|
59
|
+
print(f"Role file: {role_file}")
|
|
60
|
+
print(f"Task: {todo_file}")
|
|
61
|
+
print()
|
|
62
|
+
|
|
63
|
+
# Clean stale signals this kind could produce
|
|
64
|
+
prefixes = expected_signal_prefixes(kind)
|
|
65
|
+
outbox = paths.outbox(project_dir)
|
|
66
|
+
clean_stage_signals(outbox, todo_id, *prefixes)
|
|
67
|
+
|
|
68
|
+
# DF5-5: clean stale PROGRESS from previous stage so collect_handoff
|
|
69
|
+
# doesn't pick up the previous worker's notes as this stage's output.
|
|
70
|
+
# PROGRESS is per-stage running notes, not cumulative. Previous stage's
|
|
71
|
+
# PROGRESS is already captured in its handoff .md file (BD-19).
|
|
72
|
+
stale_progress = outbox / f"PROGRESS-{todo_id}.md"
|
|
73
|
+
if stale_progress.exists():
|
|
74
|
+
stale_progress.unlink()
|
|
75
|
+
_log(logs_dir, f"DF5-5: cleaned stale PROGRESS-{todo_id}.md from previous stage")
|
|
76
|
+
|
|
77
|
+
_log(logs_dir, f"Agent stage started: {role} ({kind}) for {todo_id}")
|
|
78
|
+
|
|
79
|
+
cmd = [
|
|
80
|
+
"opencode", "run", "--auto",
|
|
81
|
+
"--print-logs",
|
|
82
|
+
"--agent", agent_name,
|
|
83
|
+
"--title", f"awf-{role}-{todo_id}",
|
|
84
|
+
"--file", str(role_file),
|
|
85
|
+
"--file", str(todo_file),
|
|
86
|
+
]
|
|
87
|
+
role_model = get_role_model(config, role)
|
|
88
|
+
if role_model:
|
|
89
|
+
cmd += ["--model", role_model]
|
|
90
|
+
|
|
91
|
+
if prev_handoffs:
|
|
92
|
+
for hf in prev_handoffs:
|
|
93
|
+
if hf.is_file():
|
|
94
|
+
cmd += ["--file", str(hf)]
|
|
95
|
+
print(f"Handoff in: {hf}")
|
|
96
|
+
_log(logs_dir, f"Forwarding handoff: {hf}")
|
|
97
|
+
|
|
98
|
+
cmd += ["--", prompt]
|
|
99
|
+
|
|
100
|
+
# BD-20: watch for expected signal files in outbox.
|
|
101
|
+
# P1: watch ALL expected prefixes, not just DONE/BLOCKED — otherwise
|
|
102
|
+
# REVIEW-APPROVED, TEST-PASSED etc. signals aren't detected until
|
|
103
|
+
# subprocess exits, causing unnecessary delays.
|
|
104
|
+
watch_paths: list[Path] = []
|
|
105
|
+
if todo_id:
|
|
106
|
+
for prefix in expected_signal_prefixes("execute"):
|
|
107
|
+
watch_paths.append(outbox / f"{prefix}-{todo_id}.ready")
|
|
108
|
+
watch_paths.append(outbox / f"{prefix}-{todo_id}.md.ready")
|
|
109
|
+
|
|
110
|
+
from ._env import awf_subprocess_env
|
|
111
|
+
from .signal_watch import run_subprocess_until_signal
|
|
112
|
+
|
|
113
|
+
result = run_subprocess_until_signal(
|
|
114
|
+
cmd, cwd=project_dir, watch_paths=watch_paths, logs_dir=logs_dir,
|
|
115
|
+
env=awf_subprocess_env(),
|
|
116
|
+
hard_timeout=hard_timeout,
|
|
117
|
+
)
|
|
118
|
+
_log(logs_dir, f"Agent stage finished: {role} ({kind}) for {todo_id} (exit={result.returncode})")
|
|
119
|
+
if result.returncode != 0:
|
|
120
|
+
raise RuntimeError(
|
|
121
|
+
f"Agent stage {role} ({kind}) subprocess exited with code {result.returncode}. "
|
|
122
|
+
f"Cmd: {' '.join(cmd)}"
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
collect_handoff(role, todo_id, project_dir, logs_dir)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def collect_handoff(
|
|
129
|
+
role: str,
|
|
130
|
+
todo_id: str,
|
|
131
|
+
project_dir: Path,
|
|
132
|
+
logs_dir: Path,
|
|
133
|
+
) -> Path:
|
|
134
|
+
"""BD-15/19: gather PROGRESS/DONE + git diff summary into handoff .md.
|
|
135
|
+
|
|
136
|
+
BD-19: filename is ``<role>-<todo_id>.md`` so retry on the same role
|
|
137
|
+
doesn't overwrite the previous attempt's handoff.
|
|
138
|
+
"""
|
|
139
|
+
handoff_dir = paths.agentic_dir(project_dir) / "handoff"
|
|
140
|
+
handoff_dir.mkdir(parents=True, exist_ok=True)
|
|
141
|
+
outbox = paths.outbox(project_dir)
|
|
142
|
+
|
|
143
|
+
progress = outbox / f"PROGRESS-{todo_id}.md"
|
|
144
|
+
# H2 fix: support legacy short form DONE-{short_id}.md
|
|
145
|
+
from .signals import find_signal_file
|
|
146
|
+
done_path = find_signal_file(outbox, "DONE", todo_id, ".md") or (outbox / f"DONE-{todo_id}.md")
|
|
147
|
+
|
|
148
|
+
parts: list[str] = [
|
|
149
|
+
f"# Handoff from `{role}` (TODO {todo_id})",
|
|
150
|
+
"",
|
|
151
|
+
"**Generated:** by awf orchestrator (BD-15)",
|
|
152
|
+
f"**Stage role:** {role}",
|
|
153
|
+
f"**TODO:** {todo_id}",
|
|
154
|
+
"",
|
|
155
|
+
]
|
|
156
|
+
|
|
157
|
+
has_output = False
|
|
158
|
+
done_has_body = False
|
|
159
|
+
if done_path.is_file():
|
|
160
|
+
body = done_path.read_text(encoding="utf-8").strip()
|
|
161
|
+
if body:
|
|
162
|
+
parts += ["## DONE summary (from worker)", "", body, ""]
|
|
163
|
+
has_output = True
|
|
164
|
+
done_has_body = True
|
|
165
|
+
|
|
166
|
+
if progress.is_file():
|
|
167
|
+
body = progress.read_text(encoding="utf-8").strip()
|
|
168
|
+
if body:
|
|
169
|
+
parts += ["## PROGRESS notes (from worker)", "", body, ""]
|
|
170
|
+
has_output = True
|
|
171
|
+
elif not done_has_body:
|
|
172
|
+
# Only warn about missing PROGRESS if DONE summary is also absent.
|
|
173
|
+
# DONE carries the same info — warning when DONE exists is noise.
|
|
174
|
+
parts += [
|
|
175
|
+
"## ⚠️ Worker did not leave progress notes",
|
|
176
|
+
"",
|
|
177
|
+
f"Role `{role}` did not write PROGRESS-{todo_id}.md.",
|
|
178
|
+
"The work may still be valid — inspect git diff and DONE report.",
|
|
179
|
+
"",
|
|
180
|
+
]
|
|
181
|
+
|
|
182
|
+
if not has_output:
|
|
183
|
+
parts += [
|
|
184
|
+
"## ⚠️ NO OUTPUT FROM PREVIOUS STAGE",
|
|
185
|
+
"",
|
|
186
|
+
f"Role `{role}` did not write PROGRESS-{todo_id}.md or DONE-{todo_id}.md.",
|
|
187
|
+
"Likely causes: worker crashed, hit turn-budget, or signalled",
|
|
188
|
+
"BLOCKED without leaving notes. Treat prior stage work as",
|
|
189
|
+
"unverified — inspect `git diff` against baseline before",
|
|
190
|
+
"proceeding. If this is unexpected, escalate via BLOCKED signal.",
|
|
191
|
+
"",
|
|
192
|
+
]
|
|
193
|
+
|
|
194
|
+
# Git diff summary vs baseline
|
|
195
|
+
context_dir = paths.context_dir(project_dir)
|
|
196
|
+
sha_file = context_dir / f"BASELINE-{todo_id}.sha"
|
|
197
|
+
if sha_file.is_file():
|
|
198
|
+
base_sha = sha_file.read_text(encoding="utf-8").strip().split("\n")[0]
|
|
199
|
+
if base_sha:
|
|
200
|
+
try:
|
|
201
|
+
diff = subprocess.run(
|
|
202
|
+
["git", "diff", "--stat", base_sha],
|
|
203
|
+
cwd=str(project_dir),
|
|
204
|
+
capture_output=True,
|
|
205
|
+
text=True,
|
|
206
|
+
check=False,
|
|
207
|
+
timeout=10,
|
|
208
|
+
)
|
|
209
|
+
if diff is not None and getattr(diff, "stdout", "").strip():
|
|
210
|
+
parts += ["## Git diff summary (vs baseline)", "", "```", diff.stdout.strip(), "```", ""]
|
|
211
|
+
except (subprocess.TimeoutExpired, OSError) as e:
|
|
212
|
+
_log(logs_dir, f"handoff git-diff failed: {e}")
|
|
213
|
+
|
|
214
|
+
try:
|
|
215
|
+
last = subprocess.run(
|
|
216
|
+
["git", "log", "-1", "--pretty=%h %s"],
|
|
217
|
+
cwd=str(project_dir),
|
|
218
|
+
capture_output=True,
|
|
219
|
+
text=True,
|
|
220
|
+
check=False,
|
|
221
|
+
timeout=5,
|
|
222
|
+
)
|
|
223
|
+
if last is not None and (getattr(last, "stdout", "") or "").strip():
|
|
224
|
+
parts += ["## Latest commit", "", f"`{last.stdout.strip()}`", ""]
|
|
225
|
+
except (subprocess.TimeoutExpired, OSError):
|
|
226
|
+
pass
|
|
227
|
+
|
|
228
|
+
parts += [
|
|
229
|
+
"## What the next role should know",
|
|
230
|
+
"",
|
|
231
|
+
"- Read this handoff first. The TODO file declares the goal; this file shows what's done.",
|
|
232
|
+
"- Pick up where this role left off. Do NOT redo work already done.",
|
|
233
|
+
"- Write your own handoff at `.agentic/handoff/<your-role>.md` when finished.",
|
|
234
|
+
"",
|
|
235
|
+
]
|
|
236
|
+
|
|
237
|
+
out_file = handoff_dir / f"{role}-{todo_id}.md"
|
|
238
|
+
# QA-C: handoff write atomic (was direct write_text — inconsistent with
|
|
239
|
+
# rest of codebase which uses atomic_write_text for crash safety).
|
|
240
|
+
atomic_write_text(out_file, "\n".join(parts))
|
|
241
|
+
print(f"Handoff out: {out_file}")
|
|
242
|
+
_log(logs_dir, f"Handoff written: {out_file}")
|
|
243
|
+
return out_file
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def resolve_prev_handoffs(
|
|
247
|
+
pipeline_stages: list[Stage],
|
|
248
|
+
current_stage_idx: int,
|
|
249
|
+
project_dir: Path,
|
|
250
|
+
todo_id: str = "",
|
|
251
|
+
) -> list[Path]:
|
|
252
|
+
"""BD-15/19: return handoff paths for all AGENT stages before current_stage_idx."""
|
|
253
|
+
handoff_dir = paths.agentic_dir(project_dir) / "handoff"
|
|
254
|
+
result: list[Path] = []
|
|
255
|
+
for i in range(current_stage_idx):
|
|
256
|
+
st = pipeline_stages[i]
|
|
257
|
+
if st.role == "supervisor":
|
|
258
|
+
continue
|
|
259
|
+
if todo_id:
|
|
260
|
+
result.append(handoff_dir / f"{st.role}-{todo_id}.md")
|
|
261
|
+
else:
|
|
262
|
+
# P2: sort by name (numeric ID) instead of mtime — deterministic
|
|
263
|
+
# even when rapid retry creates files in the same second.
|
|
264
|
+
import re
|
|
265
|
+
def _sort_key(p: Path) -> tuple[int, str]:
|
|
266
|
+
m = re.search(r"(\d+)", p.name)
|
|
267
|
+
return (int(m.group(1)) if m else 0, p.name)
|
|
268
|
+
candidates = sorted(
|
|
269
|
+
handoff_dir.glob(f"{st.role}- *.md".replace(" ", "")),
|
|
270
|
+
key=_sort_key,
|
|
271
|
+
reverse=True,
|
|
272
|
+
)
|
|
273
|
+
if candidates:
|
|
274
|
+
result.append(candidates[0])
|
|
275
|
+
return result
|
awf/api/__init__.py
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
"""Public API for agentic-workflow.
|
|
2
|
+
|
|
3
|
+
All external callers (CLI, MCP plugin) MUST go through this module.
|
|
4
|
+
Business logic lives in submodules — this file re-exports the public
|
|
5
|
+
surface so callers can ``from awf import api; api.init_project(...)``.
|
|
6
|
+
|
|
7
|
+
Layout:
|
|
8
|
+
- ``_errors`` — AwfApiError
|
|
9
|
+
- ``_results`` — Result dataclasses (InitResult, StatusResult,
|
|
10
|
+
DispatchTodoResult, ApplyIncrementPlanResult, ...)
|
|
11
|
+
- ``_stack`` — detect_stack, derive_project_name
|
|
12
|
+
- ``_templates`` — _CONFIG_TEMPLATE, _ROLE_TEMPLATE, update_gitignore
|
|
13
|
+
- ``_helpers`` — require_agentic, require_git_repo, read helpers
|
|
14
|
+
- ``_background`` — start_in_background, check_pipeline_running, PipelineArgs
|
|
15
|
+
- ``lifecycle`` — init_project, get_status, get_report, reset_runtime,
|
|
16
|
+
list_orphans, remove_orphans
|
|
17
|
+
- ``pipeline`` — start_pipeline, continue_pipeline, create_baseline,
|
|
18
|
+
rollback, approve_commit
|
|
19
|
+
- ``roles`` — add_role, analyze_roles
|
|
20
|
+
- ``setup`` — apply_project_setup (project-setup form materialization)
|
|
21
|
+
- ``dispatch`` — dispatch_todo (atomic TODO + baseline + signal)
|
|
22
|
+
- ``context`` — load_supervisor_context (aggregate bootstrap payload),
|
|
23
|
+
_extract_stage_info, _compute_expected_action
|
|
24
|
+
- ``planning`` — apply_increment_plan (increment variant persistence)
|
|
25
|
+
|
|
26
|
+
Error convention: every function returns a Result dataclass (success)
|
|
27
|
+
or raises AwfApiError with a human-readable message. Callers wrap in
|
|
28
|
+
try/except.
|
|
29
|
+
"""
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
# Public types
|
|
33
|
+
from ._errors import AwfApiError
|
|
34
|
+
from ._results import (
|
|
35
|
+
AddRoleResult,
|
|
36
|
+
AnalyzeRolesResult,
|
|
37
|
+
ApplyIncrementPlanResult,
|
|
38
|
+
ApplyProjectSetupResult,
|
|
39
|
+
ApproveResult,
|
|
40
|
+
BaselineResult,
|
|
41
|
+
DispatchTodoResult,
|
|
42
|
+
InitResult,
|
|
43
|
+
ReportResult,
|
|
44
|
+
ResetResult,
|
|
45
|
+
RollbackResult,
|
|
46
|
+
StartResult,
|
|
47
|
+
StatusResult,
|
|
48
|
+
SupervisorContextResult,
|
|
49
|
+
WaitEventResult,
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
# Public functions — organized by submodule for clarity
|
|
53
|
+
from ._stack import derive_project_name, detect_stack
|
|
54
|
+
from .context import load_supervisor_context
|
|
55
|
+
from .dispatch import dispatch_todo
|
|
56
|
+
from .lifecycle import (
|
|
57
|
+
get_report,
|
|
58
|
+
get_status,
|
|
59
|
+
init_project,
|
|
60
|
+
list_orphans,
|
|
61
|
+
remove_orphans,
|
|
62
|
+
reset_runtime,
|
|
63
|
+
)
|
|
64
|
+
from .model_check import check_model_config
|
|
65
|
+
from .pipeline import (
|
|
66
|
+
approve_commit,
|
|
67
|
+
continue_pipeline,
|
|
68
|
+
create_baseline,
|
|
69
|
+
kill_pipeline,
|
|
70
|
+
rollback,
|
|
71
|
+
start_pipeline,
|
|
72
|
+
)
|
|
73
|
+
from .planning import apply_increment_plan
|
|
74
|
+
from .roles import add_role, analyze_roles
|
|
75
|
+
from .setup import apply_project_setup
|
|
76
|
+
from .wait_event import wait_for_event
|
|
77
|
+
|
|
78
|
+
__all__ = [
|
|
79
|
+
# Exception
|
|
80
|
+
"AwfApiError",
|
|
81
|
+
# Result dataclasses
|
|
82
|
+
"InitResult",
|
|
83
|
+
"StatusResult",
|
|
84
|
+
"BaselineResult",
|
|
85
|
+
"RollbackResult",
|
|
86
|
+
"ApproveResult",
|
|
87
|
+
"ReportResult",
|
|
88
|
+
"ResetResult",
|
|
89
|
+
"AddRoleResult",
|
|
90
|
+
"StartResult",
|
|
91
|
+
"AnalyzeRolesResult",
|
|
92
|
+
"ApplyProjectSetupResult",
|
|
93
|
+
"DispatchTodoResult",
|
|
94
|
+
"SupervisorContextResult",
|
|
95
|
+
"ApplyIncrementPlanResult",
|
|
96
|
+
"WaitEventResult",
|
|
97
|
+
# Stack detection
|
|
98
|
+
"detect_stack",
|
|
99
|
+
"derive_project_name",
|
|
100
|
+
# Lifecycle
|
|
101
|
+
"init_project",
|
|
102
|
+
"get_status",
|
|
103
|
+
"get_report",
|
|
104
|
+
"reset_runtime",
|
|
105
|
+
"list_orphans",
|
|
106
|
+
"remove_orphans",
|
|
107
|
+
# Pipeline
|
|
108
|
+
"start_pipeline",
|
|
109
|
+
"continue_pipeline",
|
|
110
|
+
"kill_pipeline",
|
|
111
|
+
"create_baseline",
|
|
112
|
+
"rollback",
|
|
113
|
+
"approve_commit",
|
|
114
|
+
# Roles
|
|
115
|
+
"add_role",
|
|
116
|
+
"analyze_roles",
|
|
117
|
+
# Project-setup materialization
|
|
118
|
+
"apply_project_setup",
|
|
119
|
+
# TODO dispatch + supervisor context (dogfood-2 automation)
|
|
120
|
+
"dispatch_todo",
|
|
121
|
+
"load_supervisor_context",
|
|
122
|
+
# Increment planning (dogfood-7 — user picks decomposition variant)
|
|
123
|
+
"apply_increment_plan",
|
|
124
|
+
# Supervisor wake-up (DASH Phase 3 — no more polling)
|
|
125
|
+
"wait_for_event",
|
|
126
|
+
# Model configuration validation
|
|
127
|
+
"check_model_config",
|
|
128
|
+
]
|