flower-trellis 0.5.0-beta.5 → 0.5.0-beta.7

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 (83) hide show
  1. package/enhancements/0.6/.agents/skills/trellis-auto-loop/SKILL.md +12 -0
  2. package/enhancements/0.6/.agents/skills/trellis-route/scripts/route_state.py +112 -26
  3. package/enhancements/0.6/.claude/skills/trellis-auto-loop/SKILL.md +12 -0
  4. package/enhancements/0.6/.claude/skills/trellis-route/scripts/route_state.py +112 -26
  5. package/enhancements/0.6/overrides/bundles/control-plane-integrity.json +10 -0
  6. package/enhancements/0.6/overrides/bundles/intent-routing.json +5 -1
  7. package/enhancements/0.6/overrides/conflicts.json +196 -0
  8. package/enhancements/0.6/overrides/patches/hooks/session-start/pre-check-hold/claude-content.py +17 -0
  9. package/enhancements/0.6/overrides/patches/hooks/session-start/pre-check-hold/claude-selector.py +3 -0
  10. package/enhancements/0.6/overrides/patches/hooks/session-start/pre-check-hold/codex-content.py +17 -0
  11. package/enhancements/0.6/overrides/patches/hooks/session-start/pre-check-hold/codex-selector.py +3 -0
  12. package/enhancements/0.6/overrides/patches/hooks/session-start/pre-check-hold/patch.json +35 -0
  13. package/enhancements/0.6/overrides/patches/scripts/runtime-state-integrity/active-task-fallback-content.py +20 -0
  14. package/enhancements/0.6/overrides/patches/scripts/runtime-state-integrity/active-task-fallback-selector.py +23 -0
  15. package/enhancements/0.6/overrides/patches/scripts/runtime-state-integrity/active-task-resolution-content.py +22 -0
  16. package/enhancements/0.6/overrides/patches/scripts/runtime-state-integrity/active-task-resolution-selector.py +27 -0
  17. package/enhancements/0.6/overrides/patches/scripts/runtime-state-integrity/active-task-runtime-io-content.py +24 -0
  18. package/enhancements/0.6/overrides/patches/scripts/runtime-state-integrity/active-task-runtime-io-selector.py +18 -0
  19. package/enhancements/0.6/overrides/patches/scripts/runtime-state-integrity/active-task-set-content.py +26 -0
  20. package/enhancements/0.6/overrides/patches/scripts/runtime-state-integrity/active-task-set-selector.py +27 -0
  21. package/enhancements/0.6/overrides/patches/scripts/runtime-state-integrity/patch.json +55 -0
  22. package/enhancements/0.6/overrides/patches/scripts/task-start-brief-gate/degraded-content.py +19 -0
  23. package/enhancements/0.6/overrides/patches/scripts/task-start-brief-gate/degraded-selector.py +25 -0
  24. package/enhancements/0.6/overrides/patches/scripts/task-start-brief-gate/guard-content.py +2 -0
  25. package/enhancements/0.6/overrides/patches/scripts/task-start-brief-gate/guard-selector.py +1 -0
  26. package/enhancements/0.6/overrides/patches/scripts/task-start-brief-gate/helper-content.py +98 -0
  27. package/enhancements/0.6/overrides/patches/scripts/task-start-brief-gate/helper-selector.py +3 -0
  28. package/enhancements/0.6/overrides/patches/scripts/task-start-brief-gate/patch.json +37 -0
  29. package/enhancements/0.6/overrides/patches/scripts/task-start-brief-gate/session-content.py +23 -0
  30. package/enhancements/0.6/overrides/patches/scripts/task-start-brief-gate/session-selector.py +20 -0
  31. package/enhancements/0.6/overrides/patches/scripts/task-store-write-integrity/add-subtask-content.py +23 -0
  32. package/enhancements/0.6/overrides/patches/scripts/task-store-write-integrity/add-subtask-selector.py +5 -0
  33. package/enhancements/0.6/overrides/patches/scripts/task-store-write-integrity/archive-guard-content.py +4 -0
  34. package/enhancements/0.6/overrides/patches/scripts/task-store-write-integrity/archive-guard-selector.py +3 -0
  35. package/enhancements/0.6/overrides/patches/scripts/task-store-write-integrity/archive-write-content.py +5 -0
  36. package/enhancements/0.6/overrides/patches/scripts/task-store-write-integrity/archive-write-selector.py +3 -0
  37. package/enhancements/0.6/overrides/patches/scripts/task-store-write-integrity/create-active-content.py +17 -0
  38. package/enhancements/0.6/overrides/patches/scripts/task-store-write-integrity/create-active-selector.py +10 -0
  39. package/enhancements/0.6/overrides/patches/scripts/task-store-write-integrity/create-duplicate-content.py +4 -0
  40. package/enhancements/0.6/overrides/patches/scripts/task-store-write-integrity/create-duplicate-selector.py +4 -0
  41. package/enhancements/0.6/overrides/patches/scripts/task-store-write-integrity/create-parent-content.py +38 -0
  42. package/enhancements/0.6/overrides/patches/scripts/task-store-write-integrity/create-parent-selector.py +21 -0
  43. package/enhancements/0.6/overrides/patches/scripts/task-store-write-integrity/create-write-content.py +8 -0
  44. package/enhancements/0.6/overrides/patches/scripts/task-store-write-integrity/create-write-selector.py +3 -0
  45. package/enhancements/0.6/overrides/patches/scripts/task-store-write-integrity/helpers-content.py +36 -0
  46. package/enhancements/0.6/overrides/patches/scripts/task-store-write-integrity/helpers-selector.py +6 -0
  47. package/enhancements/0.6/overrides/patches/scripts/task-store-write-integrity/patch.json +93 -0
  48. package/enhancements/0.6/overrides/patches/scripts/task-store-write-integrity/remove-subtask-content.py +23 -0
  49. package/enhancements/0.6/overrides/patches/scripts/task-store-write-integrity/remove-subtask-selector.py +5 -0
  50. package/enhancements/0.6/overrides/patches/scripts/task-store-write-integrity/set-base-branch-content.py +4 -0
  51. package/enhancements/0.6/overrides/patches/scripts/task-store-write-integrity/set-base-branch-selector.py +2 -0
  52. package/enhancements/0.6/overrides/patches/scripts/task-store-write-integrity/set-branch-content.py +4 -0
  53. package/enhancements/0.6/overrides/patches/scripts/task-store-write-integrity/set-branch-selector.py +2 -0
  54. package/enhancements/0.6/overrides/patches/scripts/task-store-write-integrity/set-scope-content.py +4 -0
  55. package/enhancements/0.6/overrides/patches/scripts/task-store-write-integrity/set-scope-selector.py +2 -0
  56. package/enhancements/0.6/overrides/patches/skills/trellis-brainstorm/planning-handoff/content.md +7 -0
  57. package/enhancements/0.6/overrides/patches/skills/trellis-brainstorm/planning-handoff/patch.json +57 -0
  58. package/enhancements/0.6/overrides/patches/skills/trellis-brainstorm/planning-handoff/quality-bar-baseline.md +13 -0
  59. package/enhancements/0.6/overrides/patches/skills/trellis-brainstorm/planning-handoff/readiness-content.md +3 -0
  60. package/enhancements/0.6/overrides/patches/skills/trellis-brainstorm/planning-handoff/readiness-selector.md +3 -0
  61. package/enhancements/0.6/overrides/patches/workflow/hub/content.md +13 -9
  62. package/enhancements/0.6/overrides/patches/workflow/phase-ownership/phase-2-check-content.md +2 -0
  63. package/enhancements/0.6/overrides/patches/workflow/phase-ownership/phase-2-implement-content.md +11 -0
  64. package/enhancements/0.6/overrides/patches/workflow/state-missing-task/active-task-clear-fallback-content.py +14 -6
  65. package/enhancements/0.6/overrides/patches/workflow/state-missing-task/active-task-read-result-content.py +15 -0
  66. package/enhancements/0.6/overrides/patches/workflow/state-missing-task/active-task-result-content.py +25 -0
  67. package/enhancements/0.6/overrides/patches/workflow/state-missing-task/active-task-result-selector.py +1 -0
  68. package/enhancements/0.6/overrides/patches/workflow/state-missing-task/patch.json +50 -0
  69. package/enhancements/0.6/overrides/patches/workflow/state-missing-task/paths-clear-current-content.py +26 -0
  70. package/enhancements/0.6/overrides/patches/workflow/state-missing-task/paths-clear-current-selector.py +24 -0
  71. package/enhancements/0.6/overrides/patches/workflow/state-missing-task/task-finish-content.py +20 -0
  72. package/enhancements/0.6/overrides/patches/workflow/state-missing-task/task-finish-selector.py +19 -0
  73. package/enhancements/0.6/overrides/patches/workflow/task-brief-review/patch.json +13 -0
  74. package/enhancements/0.6/overrides/patches/workflow/task-brief-review/phase-1-activate-baseline.md +13 -0
  75. package/enhancements/0.6/overrides/patches/workflow/task-brief-review/phase-1-activate-content.md +13 -0
  76. package/enhancements/0.6/scripts/auto_loop.py +287 -45
  77. package/enhancements/0.6/scripts/pre_check_state.py +353 -0
  78. package/enhancements/0.6/scripts/task_intent.py +8 -1
  79. package/enhancements/0.6/scripts/task_progress.py +43 -7
  80. package/enhancements/MANIFEST.json +68 -3
  81. package/package.json +3 -3
  82. package/src/commands/self-update.js +1 -1
  83. package/src/lib/copy-scripts.js +11 -0
@@ -22,11 +22,19 @@ description: "启动、恢复和推进 Trellis 自动任务循环。用于用户
22
22
  - auto-loop 启动前若 implement 与 check 都缺 route,优先展示 auto-loop 专用的合并选择,不要把 `trellis-route` 的两套完整 fallback 原样贴给用户。仍允许用户回复高级格式 `implement 1, check 1`。
23
23
  - 检查深度由 run 级 `--check-depth auto|light|full` 控制,默认 `auto`,与 `--route-check` 独立;历史 run 缺少该字段时按 `full` 兼容。
24
24
  - 代码提交必须复用 `trellis-push` 的内部 commit-only 执行能力;auto-loop 自己负责预授权校验和 runner 回写,不要裸 `git commit` / `git push`。
25
+ - `Open Questions` 的确定性状态由 runner 处理:`- [ ]` 阻塞,`- [x]` 不阻塞,无章节或空章节放行。历史无 checkbox 列表只能按 `review_open_questions` action 做语义复核,不能自行跳过 runner 回写。
26
+ - 启动或恢复 validated auto-loop 前先运行 `python3 ./.trellis/scripts/pre_check_state.py clear`,静默清除当前任务的交互式 Check-All 前暂缓偏好。清理返回 miss 或 task mismatch 视为无状态;runtime 损坏只记录诊断,不得阻断 runner,也不得让 runner 读取该偏好。
25
27
 
26
28
  ## 启动
27
29
 
28
30
  用户给了任务列表时,按原顺序传入;用户只说当前任务时,用 `task.py current --source` 的当前任务。
29
31
 
32
+ 启动前先静默清理交互式暂缓偏好:
33
+
34
+ ```bash
35
+ python3 ./.trellis/scripts/pre_check_state.py clear
36
+ ```
37
+
30
38
  启动前对当前任务执行 route 准备度检查:
31
39
 
32
40
  ```bash
@@ -81,6 +89,7 @@ python3 ./.trellis/scripts/auto_loop.py next
81
89
  压缩、重开会话、用户说“继续自动跑 / continue auto loop”时:
82
90
 
83
91
  ```bash
92
+ python3 ./.trellis/scripts/pre_check_state.py clear
84
93
  python3 ./.trellis/scripts/auto_loop.py resume
85
94
  python3 ./.trellis/scripts/auto_loop.py next
86
95
  ```
@@ -107,6 +116,7 @@ python3 ./.trellis/scripts/auto_loop.py next
107
116
 
108
117
  | runner action | 主 agent 动作 | 成功 record |
109
118
  | --- | --- | --- |
119
+ | `review_open_questions` | 结合 PRD 语义判断历史裸列表是已解决、仍阻塞或无法确定 | 已解决:`record --action review_open_questions --result ok --review-verdict resolved --summary "<摘要>"`;仍阻塞/无法确定:使用 `--result blocked --review-verdict blocking|ambiguous` |
110
120
  | `refresh_brief` | 使用 `trellis-task-brief` 生成并展示 brief | `record --action refresh_brief --result ok` |
111
121
  | `start_task` | 执行返回的 `task.py start ...` 命令 | `record --action start_task --result ok` |
112
122
  | `run_implement` | 进入 Phase 2.1,先用 `trellis-route(target=implement)` 决定 inline/subagent,再实现 | `record --action run_implement --result ok --route-mode <mode> --route-source <source>` |
@@ -143,6 +153,8 @@ python3 ./.trellis/scripts/auto_loop.py record \
143
153
 
144
154
  runner 会按 3 轮 fix/recheck 预算决定继续、跳过当前任务或结束队列。
145
155
 
156
+ `review_open_questions` 必须只依据 action 返回的 `questions` 和当前 PRD 判断。runner 会把结果绑定到 action 发出时的 PRD SHA-256;PRD 在复核期间发生变化时,record 会返回 `stale-open-questions-review`,此时重新运行 `next`,不得复用旧判断。`ambiguous` 必须保守阻塞,交由用户或 planning 收敛。
157
+
146
158
  `record` 默认只返回当前 item 的 `task`、`item_status`、`current_step`、`commit` 和紧凑 `summary`;只有排查状态漂移时才加 `--verbose` 查看完整 `item`。
147
159
 
148
160
  route action 成功回写时必须带上 `trellis-route` 输出里的真实 `mode` / `source`,例如
@@ -5,8 +5,10 @@ from __future__ import annotations
5
5
 
6
6
  import argparse
7
7
  import json
8
+ import os
8
9
  import subprocess
9
10
  import sys
11
+ import tempfile
10
12
  from datetime import datetime, timezone
11
13
  from pathlib import Path
12
14
  from typing import Any
@@ -50,22 +52,45 @@ def _utc_now() -> str:
50
52
  )
51
53
 
52
54
 
53
- def _read_json(path: Path) -> dict[str, Any]:
54
- """读取 JSON 对象,失败时返回空对象。"""
55
+ def _read_json_result(path: Path) -> dict[str, Any]:
56
+ """读取 runtime JSON,并区分缺失、损坏和 I/O 错误。"""
55
57
  try:
56
58
  data = json.loads(path.read_text(encoding="utf-8"))
57
- except (FileNotFoundError, json.JSONDecodeError, OSError):
58
- return {}
59
+ except FileNotFoundError:
60
+ return {"status": "missing", "data": None, "error": None}
61
+ except json.JSONDecodeError as exc:
62
+ return {"status": "corrupt", "data": None, "error": str(exc)}
63
+ except OSError as exc:
64
+ return {"status": "io_error", "data": None, "error": str(exc)}
65
+ if not isinstance(data, dict):
66
+ return {"status": "corrupt", "data": None, "error": "JSON 根节点不是对象"}
67
+ return {"status": "ok", "data": data, "error": None}
68
+
69
+
70
+ def _read_json(path: Path) -> dict[str, Any]:
71
+ """兼容非关键扫描;仅返回成功解析的 JSON 对象。"""
72
+ result = _read_json_result(path)
73
+ data = result.get("data")
59
74
  return data if isinstance(data, dict) else {}
60
75
 
61
76
 
62
77
  def _write_json(path: Path, data: dict[str, Any]) -> None:
63
- """原子性要求不高的 runtime 状态写入。"""
78
+ """使用同目录临时文件原子写入 runtime 状态。"""
64
79
  path.parent.mkdir(parents=True, exist_ok=True)
65
- path.write_text(
66
- json.dumps(data, indent=2, ensure_ascii=False) + "\n",
67
- encoding="utf-8",
68
- )
80
+ fd, temp_name = tempfile.mkstemp(prefix=f".{path.name}.", suffix=".tmp", dir=path.parent)
81
+ temp_path = Path(temp_name)
82
+ try:
83
+ with os.fdopen(fd, "w", encoding="utf-8") as handle:
84
+ handle.write(json.dumps(data, indent=2, ensure_ascii=False) + "\n")
85
+ handle.flush()
86
+ os.fsync(handle.fileno())
87
+ os.replace(temp_path, path)
88
+ except Exception:
89
+ try:
90
+ temp_path.unlink()
91
+ except FileNotFoundError:
92
+ pass
93
+ raise
69
94
 
70
95
 
71
96
  def _print(data: dict[str, Any]) -> int:
@@ -187,7 +212,27 @@ def _auto_state_path(repo_root: Path, run_id: Any) -> Path | None:
187
212
  return _auto_loop_dir(repo_root) / f"{run_id.strip()}.json"
188
213
 
189
214
 
190
- def _auto_route_mode(repo_root: Path, context_key: str, target: str) -> tuple[str | None, Path | None, str | None]:
215
+ def _run_contains_task(state: dict[str, Any], current_task: str) -> bool:
216
+ """判断当前任务是否属于 run 的未完成队列。"""
217
+ queue = state.get("queue")
218
+ if not isinstance(queue, list):
219
+ return False
220
+ normalized = current_task.replace("\\", "/")
221
+ for item in queue:
222
+ if not isinstance(item, dict) or item.get("status") not in {"pending", "running"}:
223
+ continue
224
+ task = item.get("task")
225
+ if isinstance(task, str) and task.replace("\\", "/") == normalized:
226
+ return True
227
+ return False
228
+
229
+
230
+ def _auto_route_mode(
231
+ repo_root: Path,
232
+ context_key: str,
233
+ current_task: str,
234
+ target: str,
235
+ ) -> tuple[str | None, Path | None, str | None]:
191
236
  """读取当前 running auto-loop run 的临时 route 授权。
192
237
 
193
238
  auto 授权低于个人 `.route-prefs.tmp`,只在当前 session runtime 绑定了
@@ -195,15 +240,26 @@ def _auto_route_mode(repo_root: Path, context_key: str, target: str) -> tuple[st
195
240
  如果这些指针 stale,则忽略 stale pointer,并 fallback 到唯一 running run。
196
241
  """
197
242
  session_path = _session_path(repo_root, context_key)
198
- session = _read_json(session_path)
243
+ session_result = _read_json_result(session_path)
244
+ if session_result["status"] in {"corrupt", "io_error"}:
245
+ return None, session_path, f"session-runtime-{session_result['status']}"
246
+ session = session_result["data"] if isinstance(session_result.get("data"), dict) else {}
199
247
  candidate_paths: list[Path] = []
200
248
  stale_paths: list[Path] = []
201
249
 
202
- for run_id in (session.get("current_auto_run"), _read_json(_auto_loop_pointer(repo_root)).get("run_id")):
250
+ pointer_result = _read_json_result(_auto_loop_pointer(repo_root))
251
+ pointer = pointer_result["data"] if isinstance(pointer_result.get("data"), dict) else {}
252
+ for source, run_id in (
253
+ ("session", session.get("current_auto_run")),
254
+ ("pointer", pointer.get("run_id")),
255
+ ):
203
256
  path = _auto_state_path(repo_root, run_id)
204
257
  if path is None:
205
258
  continue
206
- state = _read_json(path)
259
+ result = _read_json_result(path)
260
+ if source == "session" and result["status"] in {"corrupt", "io_error"}:
261
+ return None, path, f"session-auto-run-{result['status']}"
262
+ state = result["data"] if isinstance(result.get("data"), dict) else {}
207
263
  if state.get("status") == "running":
208
264
  candidate_paths.append(path)
209
265
  else:
@@ -227,6 +283,8 @@ def _auto_route_mode(repo_root: Path, context_key: str, target: str) -> tuple[st
227
283
 
228
284
  path = unique_paths[0]
229
285
  state = _read_json(path)
286
+ if not _run_contains_task(state, current_task):
287
+ return None, path, "auto-run-task-mismatch"
230
288
 
231
289
  auth = state.get("route_authorization")
232
290
  if not isinstance(auth, dict):
@@ -302,7 +360,10 @@ def _write_runtime_decision(
302
360
  ) -> tuple[Path, dict[str, str]]:
303
361
  """写入 session runtime 文件中的 route_decisions 字段。"""
304
362
  path = _session_path(repo_root, context_key)
305
- context = _read_json(path)
363
+ context_result = _read_json_result(path)
364
+ if context_result["status"] in {"corrupt", "io_error"}:
365
+ raise ValueError(f"session-runtime-{context_result['status']}")
366
+ context = context_result["data"] if isinstance(context_result.get("data"), dict) else {}
306
367
  now = _utc_now()
307
368
  context.setdefault("platform", context_key.split("_", 1)[0] if "_" in context_key else "session")
308
369
  context["last_seen_at"] = now
@@ -349,7 +410,14 @@ def read_runtime(args: argparse.Namespace) -> int:
349
410
  assert current_task is not None and context_key is not None
350
411
 
351
412
  path = _session_path(repo_root, context_key)
352
- context = _read_json(path)
413
+ context_result = _read_json_result(path)
414
+ if context_result["status"] in {"corrupt", "io_error"}:
415
+ return _output(
416
+ args,
417
+ {"status": "miss", "reason": f"session-runtime-{context_result['status']}"},
418
+ {"path": _rel_path(repo_root, path), "error": context_result.get("error")},
419
+ )
420
+ context = context_result["data"] if isinstance(context_result.get("data"), dict) else {}
353
421
  decision = context.get("route_decisions", {}).get(args.target)
354
422
  normalized = _normalized_decision(decision, args.target, current_task)
355
423
  if normalized is not None:
@@ -393,7 +461,14 @@ def resolve_route(args: argparse.Namespace) -> int:
393
461
  assert current_task is not None and context_key is not None
394
462
 
395
463
  path = _session_path(repo_root, context_key)
396
- context = _read_json(path)
464
+ context_result = _read_json_result(path)
465
+ if context_result["status"] in {"corrupt", "io_error"}:
466
+ return _output(
467
+ args,
468
+ {"status": "miss", "reason": f"session-runtime-{context_result['status']}"},
469
+ {"path": _rel_path(repo_root, path), "error": context_result.get("error")},
470
+ )
471
+ context = context_result["data"] if isinstance(context_result.get("data"), dict) else {}
397
472
  decision = context.get("route_decisions", {}).get(args.target)
398
473
  normalized = _normalized_decision(decision, args.target, current_task)
399
474
  if normalized is not None:
@@ -451,7 +526,12 @@ def resolve_route(args: argparse.Namespace) -> int:
451
526
  }
452
527
  )
453
528
 
454
- auto_mode, auto_path, auto_reason = _auto_route_mode(repo_root, context_key, args.target)
529
+ auto_mode, auto_path, auto_reason = _auto_route_mode(
530
+ repo_root,
531
+ context_key,
532
+ current_task,
533
+ args.target,
534
+ )
455
535
  if auto_mode in PREF_MODES[args.target]:
456
536
  written_path, auto_decision = _write_runtime_decision(
457
537
  repo_root,
@@ -535,14 +615,17 @@ def write_route(args: argparse.Namespace) -> int:
535
615
  if not context_key:
536
616
  return _print({"status": "skipped", "reason": "no-session-context", "source": source})
537
617
 
538
- path, decision = _write_runtime_decision(
539
- repo_root,
540
- context_key,
541
- current_task,
542
- args.target,
543
- mode,
544
- args.source,
545
- )
618
+ try:
619
+ path, decision = _write_runtime_decision(
620
+ repo_root,
621
+ context_key,
622
+ current_task,
623
+ args.target,
624
+ mode,
625
+ args.source,
626
+ )
627
+ except (OSError, ValueError) as exc:
628
+ return _print({"status": "error", "reason": "runtime-write-failed", "message": str(exc)})
546
629
  return _output(
547
630
  args,
548
631
  {
@@ -617,7 +700,10 @@ def main() -> int:
617
700
  """脚本入口。"""
618
701
  parser = build_parser()
619
702
  args = parser.parse_args()
620
- return args.func(args)
703
+ try:
704
+ return args.func(args)
705
+ except (OSError, ValueError) as exc:
706
+ return _print({"status": "error", "reason": "runtime-io-error", "message": str(exc)})
621
707
 
622
708
 
623
709
  if __name__ == "__main__":
@@ -22,11 +22,19 @@ description: "启动、恢复和推进 Trellis 自动任务循环。用于用户
22
22
  - auto-loop 启动前若 implement 与 check 都缺 route,优先展示 auto-loop 专用的合并选择,不要把 `trellis-route` 的两套完整 fallback 原样贴给用户。仍允许用户回复高级格式 `implement 1, check 1`。
23
23
  - 检查深度由 run 级 `--check-depth auto|light|full` 控制,默认 `auto`,与 `--route-check` 独立;历史 run 缺少该字段时按 `full` 兼容。
24
24
  - 代码提交必须复用 `trellis-push` 的内部 commit-only 执行能力;auto-loop 自己负责预授权校验和 runner 回写,不要裸 `git commit` / `git push`。
25
+ - `Open Questions` 的确定性状态由 runner 处理:`- [ ]` 阻塞,`- [x]` 不阻塞,无章节或空章节放行。历史无 checkbox 列表只能按 `review_open_questions` action 做语义复核,不能自行跳过 runner 回写。
26
+ - 启动或恢复 validated auto-loop 前先运行 `python3 ./.trellis/scripts/pre_check_state.py clear`,静默清除当前任务的交互式 Check-All 前暂缓偏好。清理返回 miss 或 task mismatch 视为无状态;runtime 损坏只记录诊断,不得阻断 runner,也不得让 runner 读取该偏好。
25
27
 
26
28
  ## 启动
27
29
 
28
30
  用户给了任务列表时,按原顺序传入;用户只说当前任务时,用 `task.py current --source` 的当前任务。
29
31
 
32
+ 启动前先静默清理交互式暂缓偏好:
33
+
34
+ ```bash
35
+ python3 ./.trellis/scripts/pre_check_state.py clear
36
+ ```
37
+
30
38
  启动前对当前任务执行 route 准备度检查:
31
39
 
32
40
  ```bash
@@ -81,6 +89,7 @@ python3 ./.trellis/scripts/auto_loop.py next
81
89
  压缩、重开会话、用户说“继续自动跑 / continue auto loop”时:
82
90
 
83
91
  ```bash
92
+ python3 ./.trellis/scripts/pre_check_state.py clear
84
93
  python3 ./.trellis/scripts/auto_loop.py resume
85
94
  python3 ./.trellis/scripts/auto_loop.py next
86
95
  ```
@@ -107,6 +116,7 @@ python3 ./.trellis/scripts/auto_loop.py next
107
116
 
108
117
  | runner action | 主 agent 动作 | 成功 record |
109
118
  | --- | --- | --- |
119
+ | `review_open_questions` | 结合 PRD 语义判断历史裸列表是已解决、仍阻塞或无法确定 | 已解决:`record --action review_open_questions --result ok --review-verdict resolved --summary "<摘要>"`;仍阻塞/无法确定:使用 `--result blocked --review-verdict blocking|ambiguous` |
110
120
  | `refresh_brief` | 使用 `trellis-task-brief` 生成并展示 brief | `record --action refresh_brief --result ok` |
111
121
  | `start_task` | 执行返回的 `task.py start ...` 命令 | `record --action start_task --result ok` |
112
122
  | `run_implement` | 进入 Phase 2.1,先用 `trellis-route(target=implement)` 决定 inline/subagent,再实现 | `record --action run_implement --result ok --route-mode <mode> --route-source <source>` |
@@ -143,6 +153,8 @@ python3 ./.trellis/scripts/auto_loop.py record \
143
153
 
144
154
  runner 会按 3 轮 fix/recheck 预算决定继续、跳过当前任务或结束队列。
145
155
 
156
+ `review_open_questions` 必须只依据 action 返回的 `questions` 和当前 PRD 判断。runner 会把结果绑定到 action 发出时的 PRD SHA-256;PRD 在复核期间发生变化时,record 会返回 `stale-open-questions-review`,此时重新运行 `next`,不得复用旧判断。`ambiguous` 必须保守阻塞,交由用户或 planning 收敛。
157
+
146
158
  `record` 默认只返回当前 item 的 `task`、`item_status`、`current_step`、`commit` 和紧凑 `summary`;只有排查状态漂移时才加 `--verbose` 查看完整 `item`。
147
159
 
148
160
  route action 成功回写时必须带上 `trellis-route` 输出里的真实 `mode` / `source`,例如
@@ -5,8 +5,10 @@ from __future__ import annotations
5
5
 
6
6
  import argparse
7
7
  import json
8
+ import os
8
9
  import subprocess
9
10
  import sys
11
+ import tempfile
10
12
  from datetime import datetime, timezone
11
13
  from pathlib import Path
12
14
  from typing import Any
@@ -50,22 +52,45 @@ def _utc_now() -> str:
50
52
  )
51
53
 
52
54
 
53
- def _read_json(path: Path) -> dict[str, Any]:
54
- """读取 JSON 对象,失败时返回空对象。"""
55
+ def _read_json_result(path: Path) -> dict[str, Any]:
56
+ """读取 runtime JSON,并区分缺失、损坏和 I/O 错误。"""
55
57
  try:
56
58
  data = json.loads(path.read_text(encoding="utf-8"))
57
- except (FileNotFoundError, json.JSONDecodeError, OSError):
58
- return {}
59
+ except FileNotFoundError:
60
+ return {"status": "missing", "data": None, "error": None}
61
+ except json.JSONDecodeError as exc:
62
+ return {"status": "corrupt", "data": None, "error": str(exc)}
63
+ except OSError as exc:
64
+ return {"status": "io_error", "data": None, "error": str(exc)}
65
+ if not isinstance(data, dict):
66
+ return {"status": "corrupt", "data": None, "error": "JSON 根节点不是对象"}
67
+ return {"status": "ok", "data": data, "error": None}
68
+
69
+
70
+ def _read_json(path: Path) -> dict[str, Any]:
71
+ """兼容非关键扫描;仅返回成功解析的 JSON 对象。"""
72
+ result = _read_json_result(path)
73
+ data = result.get("data")
59
74
  return data if isinstance(data, dict) else {}
60
75
 
61
76
 
62
77
  def _write_json(path: Path, data: dict[str, Any]) -> None:
63
- """原子性要求不高的 runtime 状态写入。"""
78
+ """使用同目录临时文件原子写入 runtime 状态。"""
64
79
  path.parent.mkdir(parents=True, exist_ok=True)
65
- path.write_text(
66
- json.dumps(data, indent=2, ensure_ascii=False) + "\n",
67
- encoding="utf-8",
68
- )
80
+ fd, temp_name = tempfile.mkstemp(prefix=f".{path.name}.", suffix=".tmp", dir=path.parent)
81
+ temp_path = Path(temp_name)
82
+ try:
83
+ with os.fdopen(fd, "w", encoding="utf-8") as handle:
84
+ handle.write(json.dumps(data, indent=2, ensure_ascii=False) + "\n")
85
+ handle.flush()
86
+ os.fsync(handle.fileno())
87
+ os.replace(temp_path, path)
88
+ except Exception:
89
+ try:
90
+ temp_path.unlink()
91
+ except FileNotFoundError:
92
+ pass
93
+ raise
69
94
 
70
95
 
71
96
  def _print(data: dict[str, Any]) -> int:
@@ -187,7 +212,27 @@ def _auto_state_path(repo_root: Path, run_id: Any) -> Path | None:
187
212
  return _auto_loop_dir(repo_root) / f"{run_id.strip()}.json"
188
213
 
189
214
 
190
- def _auto_route_mode(repo_root: Path, context_key: str, target: str) -> tuple[str | None, Path | None, str | None]:
215
+ def _run_contains_task(state: dict[str, Any], current_task: str) -> bool:
216
+ """判断当前任务是否属于 run 的未完成队列。"""
217
+ queue = state.get("queue")
218
+ if not isinstance(queue, list):
219
+ return False
220
+ normalized = current_task.replace("\\", "/")
221
+ for item in queue:
222
+ if not isinstance(item, dict) or item.get("status") not in {"pending", "running"}:
223
+ continue
224
+ task = item.get("task")
225
+ if isinstance(task, str) and task.replace("\\", "/") == normalized:
226
+ return True
227
+ return False
228
+
229
+
230
+ def _auto_route_mode(
231
+ repo_root: Path,
232
+ context_key: str,
233
+ current_task: str,
234
+ target: str,
235
+ ) -> tuple[str | None, Path | None, str | None]:
191
236
  """读取当前 running auto-loop run 的临时 route 授权。
192
237
 
193
238
  auto 授权低于个人 `.route-prefs.tmp`,只在当前 session runtime 绑定了
@@ -195,15 +240,26 @@ def _auto_route_mode(repo_root: Path, context_key: str, target: str) -> tuple[st
195
240
  如果这些指针 stale,则忽略 stale pointer,并 fallback 到唯一 running run。
196
241
  """
197
242
  session_path = _session_path(repo_root, context_key)
198
- session = _read_json(session_path)
243
+ session_result = _read_json_result(session_path)
244
+ if session_result["status"] in {"corrupt", "io_error"}:
245
+ return None, session_path, f"session-runtime-{session_result['status']}"
246
+ session = session_result["data"] if isinstance(session_result.get("data"), dict) else {}
199
247
  candidate_paths: list[Path] = []
200
248
  stale_paths: list[Path] = []
201
249
 
202
- for run_id in (session.get("current_auto_run"), _read_json(_auto_loop_pointer(repo_root)).get("run_id")):
250
+ pointer_result = _read_json_result(_auto_loop_pointer(repo_root))
251
+ pointer = pointer_result["data"] if isinstance(pointer_result.get("data"), dict) else {}
252
+ for source, run_id in (
253
+ ("session", session.get("current_auto_run")),
254
+ ("pointer", pointer.get("run_id")),
255
+ ):
203
256
  path = _auto_state_path(repo_root, run_id)
204
257
  if path is None:
205
258
  continue
206
- state = _read_json(path)
259
+ result = _read_json_result(path)
260
+ if source == "session" and result["status"] in {"corrupt", "io_error"}:
261
+ return None, path, f"session-auto-run-{result['status']}"
262
+ state = result["data"] if isinstance(result.get("data"), dict) else {}
207
263
  if state.get("status") == "running":
208
264
  candidate_paths.append(path)
209
265
  else:
@@ -227,6 +283,8 @@ def _auto_route_mode(repo_root: Path, context_key: str, target: str) -> tuple[st
227
283
 
228
284
  path = unique_paths[0]
229
285
  state = _read_json(path)
286
+ if not _run_contains_task(state, current_task):
287
+ return None, path, "auto-run-task-mismatch"
230
288
 
231
289
  auth = state.get("route_authorization")
232
290
  if not isinstance(auth, dict):
@@ -302,7 +360,10 @@ def _write_runtime_decision(
302
360
  ) -> tuple[Path, dict[str, str]]:
303
361
  """写入 session runtime 文件中的 route_decisions 字段。"""
304
362
  path = _session_path(repo_root, context_key)
305
- context = _read_json(path)
363
+ context_result = _read_json_result(path)
364
+ if context_result["status"] in {"corrupt", "io_error"}:
365
+ raise ValueError(f"session-runtime-{context_result['status']}")
366
+ context = context_result["data"] if isinstance(context_result.get("data"), dict) else {}
306
367
  now = _utc_now()
307
368
  context.setdefault("platform", context_key.split("_", 1)[0] if "_" in context_key else "session")
308
369
  context["last_seen_at"] = now
@@ -349,7 +410,14 @@ def read_runtime(args: argparse.Namespace) -> int:
349
410
  assert current_task is not None and context_key is not None
350
411
 
351
412
  path = _session_path(repo_root, context_key)
352
- context = _read_json(path)
413
+ context_result = _read_json_result(path)
414
+ if context_result["status"] in {"corrupt", "io_error"}:
415
+ return _output(
416
+ args,
417
+ {"status": "miss", "reason": f"session-runtime-{context_result['status']}"},
418
+ {"path": _rel_path(repo_root, path), "error": context_result.get("error")},
419
+ )
420
+ context = context_result["data"] if isinstance(context_result.get("data"), dict) else {}
353
421
  decision = context.get("route_decisions", {}).get(args.target)
354
422
  normalized = _normalized_decision(decision, args.target, current_task)
355
423
  if normalized is not None:
@@ -393,7 +461,14 @@ def resolve_route(args: argparse.Namespace) -> int:
393
461
  assert current_task is not None and context_key is not None
394
462
 
395
463
  path = _session_path(repo_root, context_key)
396
- context = _read_json(path)
464
+ context_result = _read_json_result(path)
465
+ if context_result["status"] in {"corrupt", "io_error"}:
466
+ return _output(
467
+ args,
468
+ {"status": "miss", "reason": f"session-runtime-{context_result['status']}"},
469
+ {"path": _rel_path(repo_root, path), "error": context_result.get("error")},
470
+ )
471
+ context = context_result["data"] if isinstance(context_result.get("data"), dict) else {}
397
472
  decision = context.get("route_decisions", {}).get(args.target)
398
473
  normalized = _normalized_decision(decision, args.target, current_task)
399
474
  if normalized is not None:
@@ -451,7 +526,12 @@ def resolve_route(args: argparse.Namespace) -> int:
451
526
  }
452
527
  )
453
528
 
454
- auto_mode, auto_path, auto_reason = _auto_route_mode(repo_root, context_key, args.target)
529
+ auto_mode, auto_path, auto_reason = _auto_route_mode(
530
+ repo_root,
531
+ context_key,
532
+ current_task,
533
+ args.target,
534
+ )
455
535
  if auto_mode in PREF_MODES[args.target]:
456
536
  written_path, auto_decision = _write_runtime_decision(
457
537
  repo_root,
@@ -535,14 +615,17 @@ def write_route(args: argparse.Namespace) -> int:
535
615
  if not context_key:
536
616
  return _print({"status": "skipped", "reason": "no-session-context", "source": source})
537
617
 
538
- path, decision = _write_runtime_decision(
539
- repo_root,
540
- context_key,
541
- current_task,
542
- args.target,
543
- mode,
544
- args.source,
545
- )
618
+ try:
619
+ path, decision = _write_runtime_decision(
620
+ repo_root,
621
+ context_key,
622
+ current_task,
623
+ args.target,
624
+ mode,
625
+ args.source,
626
+ )
627
+ except (OSError, ValueError) as exc:
628
+ return _print({"status": "error", "reason": "runtime-write-failed", "message": str(exc)})
546
629
  return _output(
547
630
  args,
548
631
  {
@@ -617,7 +700,10 @@ def main() -> int:
617
700
  """脚本入口。"""
618
701
  parser = build_parser()
619
702
  args = parser.parse_args()
620
- return args.func(args)
703
+ try:
704
+ return args.func(args)
705
+ except (OSError, ValueError) as exc:
706
+ return _print({"status": "error", "reason": "runtime-io-error", "message": str(exc)})
621
707
 
622
708
 
623
709
  if __name__ == "__main__":
@@ -0,0 +1,10 @@
1
+ {
2
+ "schemaVersion": 1,
3
+ "id": "control-plane-integrity",
4
+ "aliases": [],
5
+ "installMode": "full-only",
6
+ "patches": [
7
+ "scripts/task-store-write-integrity",
8
+ "scripts/runtime-state-integrity"
9
+ ]
10
+ }
@@ -6,6 +6,7 @@
6
6
  "patches": [
7
7
  "workflow/hub",
8
8
  "workflow/phase-ownership",
9
+ "workflow/task-brief-review",
9
10
  "workflow/state-no-task",
10
11
  "workflow/state-missing-task",
11
12
  "workflow/runtime-contract-reference",
@@ -23,8 +24,11 @@
23
24
  "hooks/codex-session-start/missing-task-routing",
24
25
  "hooks/claude-session-start/no-task-routing",
25
26
  "hooks/claude-session-start/missing-task-routing",
27
+ "hooks/session-start/pre-check-hold",
26
28
  "skills/trellis-start/no-task-routing",
27
29
  "skills/trellis-brainstorm/planning-authorization",
28
- "skills/trellis-brainstorm/auto-task-create"
30
+ "skills/trellis-brainstorm/auto-task-create",
31
+ "skills/trellis-brainstorm/planning-handoff",
32
+ "scripts/task-start-brief-gate"
29
33
  ]
30
34
  }