flower-trellis 0.3.1 → 0.4.0-beta.0
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.
- package/README.md +1 -1
- package/enhancements/0.6/.agents/skills/trellis-auto-loop/SKILL.md +131 -0
- package/enhancements/0.6/.agents/skills/trellis-push/SKILL.md +16 -1
- package/enhancements/0.6/.agents/skills/trellis-route/SKILL.md +21 -16
- package/enhancements/0.6/.agents/skills/trellis-route/scripts/route_state.py +92 -3
- package/enhancements/0.6/.claude/skills/trellis-auto-loop/SKILL.md +131 -0
- package/enhancements/0.6/.claude/skills/trellis-push/SKILL.md +16 -1
- package/enhancements/0.6/.claude/skills/trellis-route/SKILL.md +21 -16
- package/enhancements/0.6/.claude/skills/trellis-route/scripts/route_state.py +92 -3
- package/enhancements/0.6/overrides/workflow-states/planning-inline.md +1 -0
- package/enhancements/0.6/overrides/workflow-states/planning.md +1 -0
- package/enhancements/0.6/overrides/workflow.md +9 -1
- package/enhancements/0.6/scripts/auto_loop.py +782 -0
- package/enhancements/MANIFEST.json +11 -4
- package/package.json +2 -2
- package/src/cli.js +2 -1
- package/src/constants.js +3 -0
- package/src/lib/apply-enhancements.js +11 -2
- package/src/lib/copy-scripts.js +36 -0
- package/src/lib/pick-platforms.js +4 -1
|
@@ -0,0 +1,782 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Trellis auto loop 的可恢复流程控制器。"""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import argparse
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
import socket
|
|
10
|
+
import subprocess
|
|
11
|
+
import sys
|
|
12
|
+
from datetime import datetime, timezone
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
SCHEMA_VERSION = 1
|
|
17
|
+
DEFAULT_PROFILE = "commit-only"
|
|
18
|
+
MAX_FIX_RECHECK = 3
|
|
19
|
+
VALID_IMPLEMENT_ROUTES = {"inline", "subagent"}
|
|
20
|
+
VALID_CHECK_ROUTES = {"check-all-inline", "check-all-subagent"}
|
|
21
|
+
STEP_ACTIONS = {
|
|
22
|
+
"refresh_brief": "refresh_brief",
|
|
23
|
+
"start_task": "start_task",
|
|
24
|
+
"implement": "run_implement",
|
|
25
|
+
"check": "run_check_all",
|
|
26
|
+
"fix": "run_fix",
|
|
27
|
+
"recheck": "run_recheck",
|
|
28
|
+
"spec_update": "run_spec_update",
|
|
29
|
+
"commit_only": "commit_only",
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _utc_now() -> str:
|
|
34
|
+
"""返回秒级 UTC 时间字符串。"""
|
|
35
|
+
return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _repo_root() -> Path | None:
|
|
39
|
+
"""从当前目录向上寻找 Trellis 项目根。"""
|
|
40
|
+
current = Path.cwd().resolve()
|
|
41
|
+
while True:
|
|
42
|
+
if (current / ".trellis").is_dir():
|
|
43
|
+
return current
|
|
44
|
+
if current == current.parent:
|
|
45
|
+
return None
|
|
46
|
+
current = current.parent
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _print(data: dict[str, Any]) -> int:
|
|
50
|
+
"""输出给 agent 消费的紧凑 JSON。"""
|
|
51
|
+
print(json.dumps(data, ensure_ascii=False, sort_keys=True))
|
|
52
|
+
return 0
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _read_json(path: Path) -> dict[str, Any]:
|
|
56
|
+
"""读取 JSON 对象,失败返回空对象。"""
|
|
57
|
+
try:
|
|
58
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
59
|
+
except (FileNotFoundError, json.JSONDecodeError, OSError):
|
|
60
|
+
return {}
|
|
61
|
+
return data if isinstance(data, dict) else {}
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _write_json(path: Path, data: dict[str, Any]) -> None:
|
|
65
|
+
"""写入格式化 JSON。"""
|
|
66
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
67
|
+
path.write_text(json.dumps(data, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _rel_path(repo_root: Path, path: Path) -> str:
|
|
71
|
+
"""尽量返回相对项目根的 POSIX 路径。"""
|
|
72
|
+
try:
|
|
73
|
+
return path.relative_to(repo_root).as_posix()
|
|
74
|
+
except ValueError:
|
|
75
|
+
return str(path)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _auto_dir(repo_root: Path) -> Path:
|
|
79
|
+
"""返回 auto-loop runtime 目录。"""
|
|
80
|
+
return repo_root / ".trellis/.runtime/auto-loop"
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _current_pointer(repo_root: Path) -> Path:
|
|
84
|
+
"""返回当前 auto run 指针文件。"""
|
|
85
|
+
return _auto_dir(repo_root) / "current.json"
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _read_route_prefs(repo_root: Path) -> dict[str, str]:
|
|
89
|
+
"""读取个人 route 默认配置,用于 start gate 判断是否需要 JSONL context。"""
|
|
90
|
+
path = repo_root / ".trellis/.route-prefs.tmp"
|
|
91
|
+
try:
|
|
92
|
+
lines = path.read_text(encoding="utf-8").splitlines()
|
|
93
|
+
except OSError:
|
|
94
|
+
return {}
|
|
95
|
+
|
|
96
|
+
prefs: dict[str, str] = {}
|
|
97
|
+
for raw in lines:
|
|
98
|
+
if "=" not in raw:
|
|
99
|
+
continue
|
|
100
|
+
key, value = raw.split("=", 1)
|
|
101
|
+
key = key.strip()
|
|
102
|
+
value = value.strip()
|
|
103
|
+
if key == "implement" and value in VALID_IMPLEMENT_ROUTES:
|
|
104
|
+
prefs[key] = value
|
|
105
|
+
elif key == "check" and value in VALID_CHECK_ROUTES:
|
|
106
|
+
prefs[key] = value
|
|
107
|
+
return prefs
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _effective_route_authorization(repo_root: Path, route_authorization: Any) -> dict[str, str]:
|
|
111
|
+
"""按 route 优先级估算 start gate 需要的 context 类型。"""
|
|
112
|
+
effective: dict[str, str] = {}
|
|
113
|
+
if isinstance(route_authorization, dict):
|
|
114
|
+
implement = route_authorization.get("implement")
|
|
115
|
+
check = route_authorization.get("check")
|
|
116
|
+
if implement in VALID_IMPLEMENT_ROUTES:
|
|
117
|
+
effective["implement"] = str(implement)
|
|
118
|
+
if check in VALID_CHECK_ROUTES:
|
|
119
|
+
effective["check"] = str(check)
|
|
120
|
+
|
|
121
|
+
# 个人默认优先于 auto 临时授权;start gate 的 JSONL 判断也要遵守同一优先级,
|
|
122
|
+
# 否则可能在个人 subagent 默认下误放行,或在个人 inline 默认下误阻塞。
|
|
123
|
+
effective.update(_read_route_prefs(repo_root))
|
|
124
|
+
return effective
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _run_path(repo_root: Path, run_id: str) -> Path:
|
|
128
|
+
"""返回指定 auto run 状态文件。"""
|
|
129
|
+
return _auto_dir(repo_root) / f"{run_id}.json"
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _new_run_id() -> str:
|
|
133
|
+
"""生成短小稳定的 run id。"""
|
|
134
|
+
return "auto-" + datetime.now(timezone.utc).strftime("%Y%m%d%H%M%S")
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _normalize_task_ref(repo_root: Path, task_ref: str) -> str:
|
|
138
|
+
"""把任务引用规范化为 `.trellis/tasks/<dir>`。"""
|
|
139
|
+
raw = task_ref.strip()
|
|
140
|
+
path_obj = Path(raw)
|
|
141
|
+
candidates: list[Path]
|
|
142
|
+
if path_obj.is_absolute():
|
|
143
|
+
candidates = [path_obj]
|
|
144
|
+
elif raw.startswith(".trellis/"):
|
|
145
|
+
candidates = [repo_root / raw]
|
|
146
|
+
elif raw.startswith("tasks/"):
|
|
147
|
+
candidates = [repo_root / ".trellis" / raw]
|
|
148
|
+
else:
|
|
149
|
+
candidates = [repo_root / ".trellis/tasks" / raw, repo_root / raw]
|
|
150
|
+
|
|
151
|
+
for candidate in candidates:
|
|
152
|
+
if candidate.is_dir():
|
|
153
|
+
return _rel_path(repo_root, candidate)
|
|
154
|
+
raise ValueError(f"任务不存在:{task_ref}")
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _task_dir(repo_root: Path, task_ref: str) -> Path:
|
|
158
|
+
"""把规范化任务引用解析为绝对路径。"""
|
|
159
|
+
if task_ref.startswith(".trellis/"):
|
|
160
|
+
return repo_root / task_ref
|
|
161
|
+
return repo_root / ".trellis/tasks" / task_ref
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _load_task_json(repo_root: Path, task_ref: str) -> dict[str, Any]:
|
|
165
|
+
"""读取任务 task.json。"""
|
|
166
|
+
return _read_json(_task_dir(repo_root, task_ref) / "task.json")
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _task_status(repo_root: Path, task_ref: str) -> str:
|
|
170
|
+
"""读取任务状态。"""
|
|
171
|
+
return str(_load_task_json(repo_root, task_ref).get("status") or "unknown")
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _has_real_jsonl_entries(path: Path, repo_root: Path) -> bool:
|
|
175
|
+
"""判断 JSONL 上下文清单是否至少有一条真实且存在的 file entry。"""
|
|
176
|
+
try:
|
|
177
|
+
lines = path.read_text(encoding="utf-8").splitlines()
|
|
178
|
+
except OSError:
|
|
179
|
+
return False
|
|
180
|
+
|
|
181
|
+
for line in lines:
|
|
182
|
+
if not line.strip():
|
|
183
|
+
continue
|
|
184
|
+
try:
|
|
185
|
+
data = json.loads(line)
|
|
186
|
+
except json.JSONDecodeError:
|
|
187
|
+
continue
|
|
188
|
+
file_path = data.get("file")
|
|
189
|
+
if not isinstance(file_path, str) or not file_path.strip():
|
|
190
|
+
continue
|
|
191
|
+
if (repo_root / file_path).exists():
|
|
192
|
+
return True
|
|
193
|
+
return False
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _open_questions(text: str) -> list[str]:
|
|
197
|
+
"""提取 PRD 中仍存在的 open question 条目。"""
|
|
198
|
+
lines = text.splitlines()
|
|
199
|
+
collecting = False
|
|
200
|
+
questions: list[str] = []
|
|
201
|
+
for line in lines:
|
|
202
|
+
if line.startswith("## "):
|
|
203
|
+
collecting = line.strip().lower() == "## open questions"
|
|
204
|
+
continue
|
|
205
|
+
if collecting and line.strip().startswith("-"):
|
|
206
|
+
item = line.strip().lstrip("-").strip()
|
|
207
|
+
if item and item.upper() not in {"TBD", "N/A"}:
|
|
208
|
+
questions.append(item)
|
|
209
|
+
return questions
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _start_gate(
|
|
213
|
+
repo_root: Path,
|
|
214
|
+
task_ref: str,
|
|
215
|
+
route_authorization: dict[str, Any] | None = None,
|
|
216
|
+
) -> tuple[str, dict[str, Any]]:
|
|
217
|
+
"""检查 planning -> start 前置条件。"""
|
|
218
|
+
task_dir = _task_dir(repo_root, task_ref)
|
|
219
|
+
prd = task_dir / "prd.md"
|
|
220
|
+
if not prd.is_file():
|
|
221
|
+
return "blocked", {"reason": "missing-prd", "message": "缺少 prd.md"}
|
|
222
|
+
|
|
223
|
+
questions = _open_questions(prd.read_text(encoding="utf-8"))
|
|
224
|
+
if questions:
|
|
225
|
+
return "blocked", {
|
|
226
|
+
"reason": "open-questions",
|
|
227
|
+
"message": "PRD 仍有阻塞性 Open Questions",
|
|
228
|
+
"questions": questions,
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
design = task_dir / "design.md"
|
|
232
|
+
implement = task_dir / "implement.md"
|
|
233
|
+
if design.exists() != implement.exists():
|
|
234
|
+
missing = "implement.md" if design.exists() else "design.md"
|
|
235
|
+
return "blocked", {"reason": "incomplete-complex-artifacts", "message": f"复杂任务缺少 {missing}"}
|
|
236
|
+
|
|
237
|
+
auth = route_authorization if isinstance(route_authorization, dict) else {}
|
|
238
|
+
needs_implement_context = auth.get("implement") != "inline"
|
|
239
|
+
needs_check_context = auth.get("check") != "check-all-inline"
|
|
240
|
+
if needs_implement_context and not _has_real_jsonl_entries(task_dir / "implement.jsonl", repo_root):
|
|
241
|
+
return "blocked", {
|
|
242
|
+
"reason": "missing-implement-context",
|
|
243
|
+
"message": "implement.jsonl 未 curated(当前 route 可能需要 sub-agent context)",
|
|
244
|
+
}
|
|
245
|
+
if needs_check_context and not _has_real_jsonl_entries(task_dir / "check.jsonl", repo_root):
|
|
246
|
+
return "blocked", {
|
|
247
|
+
"reason": "missing-check-context",
|
|
248
|
+
"message": "check.jsonl 未 curated(当前 route 可能需要 sub-agent context)",
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
if not (task_dir / "brief.md").is_file():
|
|
252
|
+
return "action", {
|
|
253
|
+
"action": "refresh_brief",
|
|
254
|
+
"message": "缺少 brief.md,需先用 trellis-task-brief 生成任务摘要",
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
return "ok", {"message": "start gate satisfied"}
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def _current_session_key(repo_root: Path) -> str | None:
|
|
261
|
+
"""尽力解析当前 session key,用于把 auto run 绑定给 route helper。"""
|
|
262
|
+
override = os.environ.get("TRELLIS_CONTEXT_ID")
|
|
263
|
+
if override:
|
|
264
|
+
return override.strip() or None
|
|
265
|
+
|
|
266
|
+
result = subprocess.run(
|
|
267
|
+
["python3", str(repo_root / ".trellis/scripts/task.py"), "current", "--source"],
|
|
268
|
+
cwd=repo_root,
|
|
269
|
+
check=False,
|
|
270
|
+
text=True,
|
|
271
|
+
capture_output=True,
|
|
272
|
+
)
|
|
273
|
+
for line in result.stdout.splitlines():
|
|
274
|
+
if line.startswith("Source: "):
|
|
275
|
+
source = line.split(": ", 1)[1].strip()
|
|
276
|
+
if source.startswith(("session:", "session-fallback:")):
|
|
277
|
+
return source.split(":", 1)[1].strip()
|
|
278
|
+
return None
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def _link_session_run(repo_root: Path, run_id: str) -> None:
|
|
282
|
+
"""把当前 auto run 写入 session runtime,方便 route_state.py 精确恢复。"""
|
|
283
|
+
context_key = _current_session_key(repo_root)
|
|
284
|
+
if not context_key:
|
|
285
|
+
return
|
|
286
|
+
path = repo_root / ".trellis/.runtime/sessions" / f"{context_key}.json"
|
|
287
|
+
context = _read_json(path)
|
|
288
|
+
context.setdefault("platform", context_key.split("_", 1)[0] if "_" in context_key else "session")
|
|
289
|
+
context["last_seen_at"] = _utc_now()
|
|
290
|
+
context["current_auto_run"] = run_id
|
|
291
|
+
_write_json(path, context)
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def _load_current_state(repo_root: Path, run_id: str | None = None) -> tuple[Path, dict[str, Any]]:
|
|
295
|
+
"""加载指定或当前 auto run 状态。"""
|
|
296
|
+
if run_id:
|
|
297
|
+
path = _run_path(repo_root, run_id)
|
|
298
|
+
state = _read_json(path)
|
|
299
|
+
if not state:
|
|
300
|
+
raise ValueError(f"auto run 不存在:{run_id}")
|
|
301
|
+
return path, state
|
|
302
|
+
|
|
303
|
+
pointer = _read_json(_current_pointer(repo_root))
|
|
304
|
+
current = pointer.get("run_id")
|
|
305
|
+
if isinstance(current, str) and current:
|
|
306
|
+
path = _run_path(repo_root, current)
|
|
307
|
+
state = _read_json(path)
|
|
308
|
+
if state:
|
|
309
|
+
return path, state
|
|
310
|
+
|
|
311
|
+
running: list[tuple[Path, dict[str, Any]]] = []
|
|
312
|
+
for path in sorted(_auto_dir(repo_root).glob("auto-*.json")):
|
|
313
|
+
state = _read_json(path)
|
|
314
|
+
if state.get("status") == "running":
|
|
315
|
+
running.append((path, state))
|
|
316
|
+
if len(running) == 1:
|
|
317
|
+
return running[0]
|
|
318
|
+
raise ValueError("没有可恢复的唯一 auto run")
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def _write_state(path: Path, state: dict[str, Any]) -> None:
|
|
322
|
+
"""刷新 auto run 状态和更新时间。"""
|
|
323
|
+
state["updated_at"] = _utc_now()
|
|
324
|
+
state["resume_capsule"] = _resume_capsule(state)
|
|
325
|
+
_write_json(path, state)
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def _write_pointer(repo_root: Path, run_id: str) -> None:
|
|
329
|
+
"""写入当前 auto run 指针。"""
|
|
330
|
+
_write_json(_current_pointer(repo_root), {"run_id": run_id, "updated_at": _utc_now()})
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def _resume_capsule(state: dict[str, Any]) -> dict[str, Any]:
|
|
334
|
+
"""生成短小的人类可读恢复摘要。"""
|
|
335
|
+
queue = state.get("queue") if isinstance(state.get("queue"), list) else []
|
|
336
|
+
current = None
|
|
337
|
+
for item in queue:
|
|
338
|
+
if isinstance(item, dict) and item.get("status") in {"pending", "running"}:
|
|
339
|
+
current = item
|
|
340
|
+
break
|
|
341
|
+
return {
|
|
342
|
+
"run_id": state.get("run_id"),
|
|
343
|
+
"status": state.get("status"),
|
|
344
|
+
"current_task": current.get("task") if current else None,
|
|
345
|
+
"next_step": current.get("current_step") if current else "done",
|
|
346
|
+
"completed": sum(1 for item in queue if isinstance(item, dict) and item.get("status") == "completed"),
|
|
347
|
+
"blocked": sum(1 for item in queue if isinstance(item, dict) and item.get("status") == "blocked"),
|
|
348
|
+
"remaining": sum(1 for item in queue if isinstance(item, dict) and item.get("status") in {"pending", "running"}),
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def _summary(state: dict[str, Any]) -> dict[str, Any]:
|
|
353
|
+
"""返回队列状态摘要。"""
|
|
354
|
+
queue = state.get("queue") if isinstance(state.get("queue"), list) else []
|
|
355
|
+
outstanding_action = None
|
|
356
|
+
for item in queue:
|
|
357
|
+
if not isinstance(item, dict) or item.get("status") != "running":
|
|
358
|
+
continue
|
|
359
|
+
last_action = item.get("last_action")
|
|
360
|
+
if isinstance(last_action, dict):
|
|
361
|
+
outstanding_action = {
|
|
362
|
+
"task": item.get("task"),
|
|
363
|
+
"action": last_action.get("action"),
|
|
364
|
+
"current_step": last_action.get("current_step"),
|
|
365
|
+
"issued_at": last_action.get("issued_at"),
|
|
366
|
+
}
|
|
367
|
+
break
|
|
368
|
+
return {
|
|
369
|
+
"run_id": state.get("run_id"),
|
|
370
|
+
"run_status": state.get("status"),
|
|
371
|
+
"profile": state.get("profile"),
|
|
372
|
+
"current_index": state.get("current_index"),
|
|
373
|
+
"outstanding_action": outstanding_action,
|
|
374
|
+
"completed": [i.get("task") for i in queue if isinstance(i, dict) and i.get("status") == "completed"],
|
|
375
|
+
"blocked": [
|
|
376
|
+
{"task": i.get("task"), "blocked": i.get("blocked")}
|
|
377
|
+
for i in queue
|
|
378
|
+
if isinstance(i, dict) and i.get("status") == "blocked"
|
|
379
|
+
],
|
|
380
|
+
"pending": [i.get("task") for i in queue if isinstance(i, dict) and i.get("status") in {"pending", "running"}],
|
|
381
|
+
"resume_capsule": state.get("resume_capsule"),
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
def _make_item(repo_root: Path, task_ref: str) -> dict[str, Any]:
|
|
386
|
+
"""构造队列任务项。"""
|
|
387
|
+
normalized = _normalize_task_ref(repo_root, task_ref)
|
|
388
|
+
status = _task_status(repo_root, normalized)
|
|
389
|
+
step = "start_task" if status == "planning" else "implement"
|
|
390
|
+
return {
|
|
391
|
+
"task": normalized,
|
|
392
|
+
"status": "pending",
|
|
393
|
+
"task_status": status,
|
|
394
|
+
"current_step": step,
|
|
395
|
+
"attempts": {"fix_recheck": 0},
|
|
396
|
+
"last_failure": None,
|
|
397
|
+
"last_action": None,
|
|
398
|
+
"commit": None,
|
|
399
|
+
"blocked": None,
|
|
400
|
+
"updated_at": _utc_now(),
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
def _action(action: str, item: dict[str, Any], extra: dict[str, Any] | None = None) -> dict[str, Any]:
|
|
405
|
+
"""构造下一步动作对象。"""
|
|
406
|
+
task = item.get("task")
|
|
407
|
+
base: dict[str, Any] = {
|
|
408
|
+
"status": "action",
|
|
409
|
+
"action": action,
|
|
410
|
+
"task": task,
|
|
411
|
+
"current_step": item.get("current_step"),
|
|
412
|
+
}
|
|
413
|
+
if action == "start_task":
|
|
414
|
+
task_name = Path(str(task)).name
|
|
415
|
+
base["command"] = f"python3 ./.trellis/scripts/task.py start {task_name}"
|
|
416
|
+
elif action == "refresh_brief":
|
|
417
|
+
base["instruction"] = "运行 trellis-task-brief 生成 brief.md,然后 record --result ok。"
|
|
418
|
+
elif action == "run_implement":
|
|
419
|
+
base["instruction"] = "进入 Phase 2.1 implement route,并执行实现。"
|
|
420
|
+
elif action == "run_check_all":
|
|
421
|
+
base["instruction"] = "进入 Phase 2.2 check route,默认执行 check-all。"
|
|
422
|
+
elif action == "run_fix":
|
|
423
|
+
base["instruction"] = "根据最近失败摘要修复问题。"
|
|
424
|
+
elif action == "run_recheck":
|
|
425
|
+
base["instruction"] = "修复后重新执行 check-all。"
|
|
426
|
+
elif action == "run_spec_update":
|
|
427
|
+
base["instruction"] = "若有代码/测试证据支撑,执行 trellis-update-spec;否则直接 record ok。"
|
|
428
|
+
elif action == "commit_only":
|
|
429
|
+
base["instruction"] = "按 trellis-auto-loop / trellis-push commit-only 预授权语义提交当前任务可归属文件,不 push。"
|
|
430
|
+
if extra:
|
|
431
|
+
base.update(extra)
|
|
432
|
+
return base
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
def _remember_action(item: dict[str, Any], action_data: dict[str, Any]) -> dict[str, Any]:
|
|
436
|
+
"""记录 runner 已发出的待回写 action。"""
|
|
437
|
+
item["last_action"] = {
|
|
438
|
+
"action": action_data.get("action"),
|
|
439
|
+
"current_step": action_data.get("current_step"),
|
|
440
|
+
"issued_at": _utc_now(),
|
|
441
|
+
}
|
|
442
|
+
item["updated_at"] = _utc_now()
|
|
443
|
+
return action_data
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
def _outstanding_action_name(item: dict[str, Any]) -> str | None:
|
|
447
|
+
"""返回当前任务等待 record 回写的 action 名。"""
|
|
448
|
+
last_action = item.get("last_action")
|
|
449
|
+
if isinstance(last_action, dict) and isinstance(last_action.get("action"), str):
|
|
450
|
+
return last_action["action"]
|
|
451
|
+
return None
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
def _block_item(item: dict[str, Any], reason: str, summary: str, detail: dict[str, Any] | None = None) -> None:
|
|
455
|
+
"""把当前任务标记为 blocked。"""
|
|
456
|
+
item["status"] = "blocked"
|
|
457
|
+
item["blocked"] = {
|
|
458
|
+
"reason": reason,
|
|
459
|
+
"summary": summary,
|
|
460
|
+
"detail": detail or {},
|
|
461
|
+
"blocked_at": _utc_now(),
|
|
462
|
+
}
|
|
463
|
+
item["updated_at"] = _utc_now()
|
|
464
|
+
|
|
465
|
+
|
|
466
|
+
def _next_item(repo_root: Path, state: dict[str, Any]) -> tuple[dict[str, Any] | None, dict[str, Any]]:
|
|
467
|
+
"""计算并更新队列中的下一步动作。"""
|
|
468
|
+
queue = state.get("queue") if isinstance(state.get("queue"), list) else []
|
|
469
|
+
for index, item in enumerate(queue):
|
|
470
|
+
if not isinstance(item, dict) or item.get("status") not in {"pending", "running"}:
|
|
471
|
+
continue
|
|
472
|
+
|
|
473
|
+
state["current_index"] = index
|
|
474
|
+
item["status"] = "running"
|
|
475
|
+
task = str(item.get("task"))
|
|
476
|
+
item["task_status"] = _task_status(repo_root, task)
|
|
477
|
+
|
|
478
|
+
if item["task_status"] == "planning":
|
|
479
|
+
gate_status, gate = _start_gate(repo_root, task, _effective_route_authorization(repo_root, state.get("route_authorization")))
|
|
480
|
+
if gate_status == "blocked":
|
|
481
|
+
_block_item(item, gate["reason"], gate["message"], gate)
|
|
482
|
+
continue
|
|
483
|
+
if gate_status == "action":
|
|
484
|
+
item["current_step"] = "refresh_brief"
|
|
485
|
+
return item, _remember_action(item, _action("refresh_brief", item, gate))
|
|
486
|
+
item["current_step"] = "start_task"
|
|
487
|
+
return item, _remember_action(item, _action("start_task", item))
|
|
488
|
+
|
|
489
|
+
step = item.get("current_step") or "implement"
|
|
490
|
+
if step in {"start_task", "implement"}:
|
|
491
|
+
item["current_step"] = "implement"
|
|
492
|
+
return item, _remember_action(item, _action("run_implement", item))
|
|
493
|
+
if step == "check":
|
|
494
|
+
return item, _remember_action(item, _action("run_check_all", item))
|
|
495
|
+
if step == "fix":
|
|
496
|
+
attempts = item.setdefault("attempts", {}).get("fix_recheck", 0)
|
|
497
|
+
if attempts >= MAX_FIX_RECHECK:
|
|
498
|
+
_block_item(item, "retry-budget-exhausted", "fix/recheck 已达到默认 3 轮预算")
|
|
499
|
+
continue
|
|
500
|
+
return item, _remember_action(
|
|
501
|
+
item,
|
|
502
|
+
_action("run_fix", item, {"attempt": attempts, "max_attempts": MAX_FIX_RECHECK}),
|
|
503
|
+
)
|
|
504
|
+
if step == "recheck":
|
|
505
|
+
return item, _remember_action(item, _action("run_recheck", item))
|
|
506
|
+
if step == "spec_update":
|
|
507
|
+
return item, _remember_action(item, _action("run_spec_update", item))
|
|
508
|
+
if step == "commit_only":
|
|
509
|
+
return item, _remember_action(item, _action("commit_only", item))
|
|
510
|
+
|
|
511
|
+
_block_item(item, "unknown-step", f"未知 current_step:{step}")
|
|
512
|
+
|
|
513
|
+
state["status"] = "completed"
|
|
514
|
+
return None, {"status": "done", "summary": _summary(state)}
|
|
515
|
+
|
|
516
|
+
|
|
517
|
+
def cmd_start(args: argparse.Namespace) -> int:
|
|
518
|
+
"""创建 auto run。"""
|
|
519
|
+
repo_root = _repo_root()
|
|
520
|
+
if repo_root is None:
|
|
521
|
+
return _print({"status": "error", "reason": "not-trellis-project"})
|
|
522
|
+
|
|
523
|
+
try:
|
|
524
|
+
current_path, current = _load_current_state(repo_root)
|
|
525
|
+
if current.get("status") == "running" and not args.force:
|
|
526
|
+
return _print({
|
|
527
|
+
"status": "error",
|
|
528
|
+
"reason": "auto-run-already-running",
|
|
529
|
+
"run_id": current.get("run_id"),
|
|
530
|
+
"path": _rel_path(repo_root, current_path),
|
|
531
|
+
})
|
|
532
|
+
except ValueError:
|
|
533
|
+
pass
|
|
534
|
+
|
|
535
|
+
route_authorization: dict[str, str] = {}
|
|
536
|
+
if args.route_implement:
|
|
537
|
+
route_authorization["implement"] = args.route_implement
|
|
538
|
+
if args.route_check:
|
|
539
|
+
route_authorization["check"] = args.route_check
|
|
540
|
+
|
|
541
|
+
run_id = args.run_id or _new_run_id()
|
|
542
|
+
queue = [_make_item(repo_root, task) for task in args.tasks]
|
|
543
|
+
now = _utc_now()
|
|
544
|
+
state = {
|
|
545
|
+
"schema_version": SCHEMA_VERSION,
|
|
546
|
+
"run_id": run_id,
|
|
547
|
+
"status": "running",
|
|
548
|
+
"profile": args.profile,
|
|
549
|
+
"created_at": now,
|
|
550
|
+
"updated_at": now,
|
|
551
|
+
"owner": {"host": socket.gethostname(), "pid": os.getpid()},
|
|
552
|
+
"current_index": 0,
|
|
553
|
+
"route_authorization": route_authorization,
|
|
554
|
+
"queue": queue,
|
|
555
|
+
"resume_capsule": {},
|
|
556
|
+
}
|
|
557
|
+
path = _run_path(repo_root, run_id)
|
|
558
|
+
_write_state(path, state)
|
|
559
|
+
_write_pointer(repo_root, run_id)
|
|
560
|
+
_link_session_run(repo_root, run_id)
|
|
561
|
+
return _print({"status": "started", "path": _rel_path(repo_root, path), **_summary(state)})
|
|
562
|
+
|
|
563
|
+
|
|
564
|
+
def cmd_resume(args: argparse.Namespace) -> int:
|
|
565
|
+
"""恢复 auto run 摘要。"""
|
|
566
|
+
repo_root = _repo_root()
|
|
567
|
+
if repo_root is None:
|
|
568
|
+
return _print({"status": "error", "reason": "not-trellis-project"})
|
|
569
|
+
try:
|
|
570
|
+
path, state = _load_current_state(repo_root, args.run_id)
|
|
571
|
+
except ValueError as exc:
|
|
572
|
+
return _print({"status": "error", "reason": "resume-failed", "message": str(exc)})
|
|
573
|
+
_link_session_run(repo_root, str(state.get("run_id")))
|
|
574
|
+
return _print({"status": "resumed", "path": _rel_path(repo_root, path), **_summary(state)})
|
|
575
|
+
|
|
576
|
+
|
|
577
|
+
def cmd_next(args: argparse.Namespace) -> int:
|
|
578
|
+
"""计算下一步动作。"""
|
|
579
|
+
repo_root = _repo_root()
|
|
580
|
+
if repo_root is None:
|
|
581
|
+
return _print({"status": "error", "reason": "not-trellis-project"})
|
|
582
|
+
try:
|
|
583
|
+
path, state = _load_current_state(repo_root, args.run_id)
|
|
584
|
+
except ValueError as exc:
|
|
585
|
+
return _print({"status": "error", "reason": "next-failed", "message": str(exc)})
|
|
586
|
+
_link_session_run(repo_root, str(state.get("run_id")))
|
|
587
|
+
if state.get("status") != "running":
|
|
588
|
+
output_status = "done" if state.get("status") == "completed" else state.get("status") or "unknown"
|
|
589
|
+
return _print({"run_id": state.get("run_id"), "status": output_status, "summary": _summary(state)})
|
|
590
|
+
_, action = _next_item(repo_root, state)
|
|
591
|
+
_write_state(path, state)
|
|
592
|
+
if action.get("status") == "done":
|
|
593
|
+
action["summary"] = _summary(state)
|
|
594
|
+
return _print({"run_id": state.get("run_id"), **action})
|
|
595
|
+
|
|
596
|
+
|
|
597
|
+
def _find_record_item(state: dict[str, Any], task_ref: str | None) -> dict[str, Any] | None:
|
|
598
|
+
"""找到 record 要更新的队列项。"""
|
|
599
|
+
queue = state.get("queue") if isinstance(state.get("queue"), list) else []
|
|
600
|
+
if task_ref:
|
|
601
|
+
for item in queue:
|
|
602
|
+
if isinstance(item, dict) and item.get("task") == task_ref:
|
|
603
|
+
return item
|
|
604
|
+
for item in queue:
|
|
605
|
+
if isinstance(item, dict) and item.get("status") == "running":
|
|
606
|
+
return item
|
|
607
|
+
return None
|
|
608
|
+
|
|
609
|
+
|
|
610
|
+
def _advance_after_ok(item: dict[str, Any], action: str, args: argparse.Namespace) -> None:
|
|
611
|
+
"""根据成功动作推进 current_step。"""
|
|
612
|
+
if action == "refresh_brief":
|
|
613
|
+
item["current_step"] = "start_task"
|
|
614
|
+
elif action == "start_task":
|
|
615
|
+
item["current_step"] = "implement"
|
|
616
|
+
elif action in {"run_implement", "run_fix"}:
|
|
617
|
+
item["current_step"] = "check" if action == "run_implement" else "recheck"
|
|
618
|
+
elif action in {"run_check_all", "run_recheck"}:
|
|
619
|
+
item["current_step"] = "spec_update"
|
|
620
|
+
elif action == "run_spec_update":
|
|
621
|
+
item["current_step"] = "commit_only"
|
|
622
|
+
elif action == "commit_only":
|
|
623
|
+
item["status"] = "completed"
|
|
624
|
+
item["current_step"] = "done"
|
|
625
|
+
item["commit"] = args.commit
|
|
626
|
+
item["updated_at"] = _utc_now()
|
|
627
|
+
|
|
628
|
+
|
|
629
|
+
def _record_failure(item: dict[str, Any], action: str, args: argparse.Namespace) -> None:
|
|
630
|
+
"""记录失败并决定是否进入 fix 或 blocked。"""
|
|
631
|
+
item["last_failure"] = {
|
|
632
|
+
"action": action,
|
|
633
|
+
"failure_type": args.failure_type,
|
|
634
|
+
"summary": args.summary,
|
|
635
|
+
"files": args.files or [],
|
|
636
|
+
"failed_at": _utc_now(),
|
|
637
|
+
}
|
|
638
|
+
if action in {"run_implement", "run_check_all", "run_fix", "run_recheck"}:
|
|
639
|
+
attempts = item.setdefault("attempts", {})
|
|
640
|
+
attempts["fix_recheck"] = int(attempts.get("fix_recheck", 0)) + 1
|
|
641
|
+
if attempts["fix_recheck"] > MAX_FIX_RECHECK:
|
|
642
|
+
_block_item(item, "retry-budget-exhausted", args.summary or "fix/recheck 达到默认 3 轮预算")
|
|
643
|
+
else:
|
|
644
|
+
item["current_step"] = "fix"
|
|
645
|
+
item["updated_at"] = _utc_now()
|
|
646
|
+
return
|
|
647
|
+
_block_item(item, args.failure_type or "action-failed", args.summary or f"{action} 执行失败")
|
|
648
|
+
|
|
649
|
+
|
|
650
|
+
def cmd_record(args: argparse.Namespace) -> int:
|
|
651
|
+
"""记录 agent 执行结果。"""
|
|
652
|
+
repo_root = _repo_root()
|
|
653
|
+
if repo_root is None:
|
|
654
|
+
return _print({"status": "error", "reason": "not-trellis-project"})
|
|
655
|
+
try:
|
|
656
|
+
path, state = _load_current_state(repo_root, args.run_id)
|
|
657
|
+
except ValueError as exc:
|
|
658
|
+
return _print({"status": "error", "reason": "record-failed", "message": str(exc)})
|
|
659
|
+
if state.get("status") != "running":
|
|
660
|
+
return _print({
|
|
661
|
+
"status": "error",
|
|
662
|
+
"reason": "auto-run-not-running",
|
|
663
|
+
"run_status": state.get("status"),
|
|
664
|
+
"summary": _summary(state),
|
|
665
|
+
})
|
|
666
|
+
|
|
667
|
+
task = _normalize_task_ref(repo_root, args.task) if args.task else None
|
|
668
|
+
item = _find_record_item(state, task)
|
|
669
|
+
if item is None:
|
|
670
|
+
return _print({"status": "error", "reason": "no-running-task"})
|
|
671
|
+
|
|
672
|
+
if not args.action:
|
|
673
|
+
return _print({"status": "error", "reason": "missing-action", "message": "record 必须显式传入 --action"})
|
|
674
|
+
expected_action = _outstanding_action_name(item)
|
|
675
|
+
if expected_action is None:
|
|
676
|
+
return _print({
|
|
677
|
+
"status": "error",
|
|
678
|
+
"reason": "no-outstanding-action",
|
|
679
|
+
"message": "没有等待回写的 action;请先运行 next 获取下一步。",
|
|
680
|
+
"task": item.get("task"),
|
|
681
|
+
"current_step": item.get("current_step"),
|
|
682
|
+
})
|
|
683
|
+
if args.action != expected_action:
|
|
684
|
+
return _print({
|
|
685
|
+
"status": "error",
|
|
686
|
+
"reason": "action-mismatch",
|
|
687
|
+
"expected_action": expected_action,
|
|
688
|
+
"actual_action": args.action,
|
|
689
|
+
"task": item.get("task"),
|
|
690
|
+
"current_step": item.get("current_step"),
|
|
691
|
+
})
|
|
692
|
+
|
|
693
|
+
action = args.action
|
|
694
|
+
item["last_action"] = None
|
|
695
|
+
if args.result == "ok":
|
|
696
|
+
_advance_after_ok(item, action, args)
|
|
697
|
+
elif args.result == "failed":
|
|
698
|
+
_record_failure(item, action, args)
|
|
699
|
+
else:
|
|
700
|
+
_block_item(item, args.failure_type or "blocked", args.summary or "agent 标记 blocked")
|
|
701
|
+
|
|
702
|
+
_write_state(path, state)
|
|
703
|
+
return _print({"status": "recorded", "run_id": state.get("run_id"), "task": item.get("task"), "item": item, "summary": _summary(state)})
|
|
704
|
+
|
|
705
|
+
|
|
706
|
+
def cmd_status(args: argparse.Namespace) -> int:
|
|
707
|
+
"""输出 auto run 状态。"""
|
|
708
|
+
repo_root = _repo_root()
|
|
709
|
+
if repo_root is None:
|
|
710
|
+
return _print({"status": "error", "reason": "not-trellis-project"})
|
|
711
|
+
try:
|
|
712
|
+
path, state = _load_current_state(repo_root, args.run_id)
|
|
713
|
+
except ValueError as exc:
|
|
714
|
+
return _print({"status": "error", "reason": "status-failed", "message": str(exc)})
|
|
715
|
+
return _print({"status": "ok", "path": _rel_path(repo_root, path), **_summary(state)})
|
|
716
|
+
|
|
717
|
+
|
|
718
|
+
def cmd_stop(args: argparse.Namespace) -> int:
|
|
719
|
+
"""停止 auto run。"""
|
|
720
|
+
repo_root = _repo_root()
|
|
721
|
+
if repo_root is None:
|
|
722
|
+
return _print({"status": "error", "reason": "not-trellis-project"})
|
|
723
|
+
try:
|
|
724
|
+
path, state = _load_current_state(repo_root, args.run_id)
|
|
725
|
+
except ValueError as exc:
|
|
726
|
+
return _print({"status": "error", "reason": "stop-failed", "message": str(exc)})
|
|
727
|
+
state["status"] = "stopped"
|
|
728
|
+
state["stop_reason"] = args.reason
|
|
729
|
+
_write_state(path, state)
|
|
730
|
+
return _print({"status": "stopped", "run_id": state.get("run_id"), "path": _rel_path(repo_root, path), "reason": args.reason})
|
|
731
|
+
|
|
732
|
+
|
|
733
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
734
|
+
"""构造命令行解析器。"""
|
|
735
|
+
parser = argparse.ArgumentParser(description="Trellis auto loop runner.")
|
|
736
|
+
subparsers = parser.add_subparsers(dest="command", required=True)
|
|
737
|
+
|
|
738
|
+
start = subparsers.add_parser("start", help="create an auto loop run")
|
|
739
|
+
start.add_argument("--tasks", nargs="+", required=True)
|
|
740
|
+
start.add_argument("--run-id")
|
|
741
|
+
start.add_argument("--profile", choices=(DEFAULT_PROFILE,), default=DEFAULT_PROFILE)
|
|
742
|
+
start.add_argument("--route-implement", choices=sorted(VALID_IMPLEMENT_ROUTES))
|
|
743
|
+
start.add_argument("--route-check", choices=sorted(VALID_CHECK_ROUTES))
|
|
744
|
+
start.add_argument("--force", action="store_true")
|
|
745
|
+
start.set_defaults(func=cmd_start)
|
|
746
|
+
|
|
747
|
+
for name, func in (("resume", cmd_resume), ("next", cmd_next), ("status", cmd_status)):
|
|
748
|
+
sub = subparsers.add_parser(name)
|
|
749
|
+
sub.add_argument("--run-id")
|
|
750
|
+
sub.set_defaults(func=func)
|
|
751
|
+
|
|
752
|
+
record = subparsers.add_parser("record")
|
|
753
|
+
record.add_argument("--run-id")
|
|
754
|
+
record.add_argument("--task")
|
|
755
|
+
record.add_argument("--action")
|
|
756
|
+
record.add_argument("--result", choices=("ok", "failed", "blocked"), required=True)
|
|
757
|
+
record.add_argument("--failure-type")
|
|
758
|
+
record.add_argument("--summary")
|
|
759
|
+
record.add_argument("--files", nargs="*")
|
|
760
|
+
record.add_argument("--commit")
|
|
761
|
+
record.set_defaults(func=cmd_record)
|
|
762
|
+
|
|
763
|
+
stop = subparsers.add_parser("stop")
|
|
764
|
+
stop.add_argument("--run-id")
|
|
765
|
+
stop.add_argument("--reason")
|
|
766
|
+
stop.set_defaults(func=cmd_stop)
|
|
767
|
+
|
|
768
|
+
return parser
|
|
769
|
+
|
|
770
|
+
|
|
771
|
+
def main() -> int:
|
|
772
|
+
"""脚本入口。"""
|
|
773
|
+
parser = build_parser()
|
|
774
|
+
args = parser.parse_args()
|
|
775
|
+
try:
|
|
776
|
+
return args.func(args)
|
|
777
|
+
except ValueError as exc:
|
|
778
|
+
return _print({"status": "error", "reason": "invalid-input", "message": str(exc)})
|
|
779
|
+
|
|
780
|
+
|
|
781
|
+
if __name__ == "__main__":
|
|
782
|
+
sys.exit(main())
|