flower-trellis 0.4.0-beta.4 → 0.4.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.
Files changed (25) hide show
  1. package/enhancements/0.6/.agents/skills/trellis-push/SKILL.md +17 -8
  2. package/enhancements/0.6/.agents/skills/trellis-route/SKILL.md +8 -3
  3. package/enhancements/0.6/.claude/skills/trellis-push/SKILL.md +17 -8
  4. package/enhancements/0.6/.claude/skills/trellis-route/SKILL.md +8 -3
  5. package/enhancements/0.6/overrides/skills/trellis-finish-work.md +14 -0
  6. package/enhancements/0.6/overrides/workflow-states/in_progress-inline.md +2 -2
  7. package/enhancements/0.6/overrides/workflow-states/in_progress.md +2 -2
  8. package/enhancements/0.6/overrides/workflow-states/no_task.md +2 -2
  9. package/enhancements/0.6/overrides/workflow-states/planning-inline.md +1 -1
  10. package/enhancements/0.6/overrides/workflow-states/planning.md +1 -1
  11. package/enhancements/0.6/overrides/workflow.md +18 -10
  12. package/enhancements/0.6/scripts/push_snapshot.py +387 -0
  13. package/enhancements/0.6/scripts/spec_router.py +244 -33
  14. package/enhancements/MANIFEST.json +3 -4
  15. package/package.json +1 -1
  16. package/src/lib/copy-scripts.js +2 -0
  17. package/src/lib/skill-catalog.js +0 -1
  18. package/enhancements/common/.common/.claude/skills/sub2api-account-json-fix/SKILL.md +0 -47
  19. package/enhancements/common/.common/.claude/skills/sub2api-account-json-fix/env/push.env.example +0 -8
  20. package/enhancements/common/.common/.claude/skills/sub2api-account-json-fix/scripts/run.sh +0 -27
  21. package/enhancements/common/.common/.codex/skills/sub2api-account-json-fix/SKILL.md +0 -126
  22. package/enhancements/common/.common/.codex/skills/sub2api-account-json-fix/agents/openai.yaml +0 -4
  23. package/enhancements/common/.common/.codex/skills/sub2api-account-json-fix/env/push.env.example +0 -8
  24. package/enhancements/common/.common/.codex/skills/sub2api-account-json-fix/scripts/fix_exported_account_json.py +0 -890
  25. package/enhancements/common/.common/.codex/skills/sub2api-account-json-fix/scripts/run.sh +0 -26
@@ -0,0 +1,387 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ """读取和写入 Trellis 任务的 last_push_snapshot。"""
4
+
5
+ from __future__ import annotations
6
+
7
+ import argparse
8
+ import json
9
+ import sys
10
+ from pathlib import Path
11
+ from typing import Any
12
+
13
+
14
+ DIR_WORKFLOW = ".trellis"
15
+ DIR_TASKS = "tasks"
16
+ FILE_TASK_JSON = "task.json"
17
+ REQUIRED_FIELDS = {
18
+ "snapshot_at",
19
+ "branch",
20
+ "pushed_commits",
21
+ "completed_steps",
22
+ "next_step",
23
+ }
24
+ OPTIONAL_STRING_FIELDS = {"partial_step", "notes"}
25
+
26
+
27
+ def _find_repo_root(start: Path) -> Path | None:
28
+ """从当前位置向上查找 Trellis 项目根目录。"""
29
+ current = start.resolve()
30
+ if current.is_file():
31
+ current = current.parent
32
+ while True:
33
+ if (current / DIR_WORKFLOW).is_dir():
34
+ return current
35
+ if current == current.parent:
36
+ return None
37
+ current = current.parent
38
+
39
+
40
+ def _scripts_dir(repo_root: Path) -> Path:
41
+ """返回当前项目的 Trellis scripts 目录。"""
42
+ return repo_root / DIR_WORKFLOW / "scripts"
43
+
44
+
45
+ def _load_common_modules(repo_root: Path) -> None:
46
+ """把 `.trellis/scripts` 加入 import path 以复用 Trellis 公共模块。"""
47
+ scripts_dir = _scripts_dir(repo_root)
48
+ if str(scripts_dir) not in sys.path:
49
+ sys.path.insert(0, str(scripts_dir))
50
+
51
+
52
+ def _read_json(path: Path) -> dict[str, Any] | None:
53
+ """读取 JSON 对象,失败或非对象时返回 None。"""
54
+ try:
55
+ data = json.loads(path.read_text(encoding="utf-8"))
56
+ except (FileNotFoundError, json.JSONDecodeError, OSError):
57
+ return None
58
+ return data if isinstance(data, dict) else None
59
+
60
+
61
+ def _write_json(path: Path, data: dict[str, Any]) -> None:
62
+ """用 Trellis 任务文件常见格式写回 JSON。"""
63
+ path.write_text(
64
+ json.dumps(data, indent=2, ensure_ascii=False) + "\n",
65
+ encoding="utf-8",
66
+ )
67
+
68
+
69
+ def _rel_path(repo_root: Path, path: Path) -> str:
70
+ """尽量输出 repo-root 相对路径。"""
71
+ try:
72
+ return path.relative_to(repo_root).as_posix()
73
+ except ValueError:
74
+ return str(path)
75
+
76
+
77
+ def _resolve_task_dir(repo_root: Path, task_ref: str) -> Path:
78
+ """解析任务目录引用。"""
79
+ _load_common_modules(repo_root)
80
+ from common.task_utils import resolve_task_dir # type: ignore[import-not-found]
81
+
82
+ return resolve_task_dir(task_ref, repo_root)
83
+
84
+
85
+ def _current_task_dir(repo_root: Path) -> Path | None:
86
+ """读取当前 session 的 active task 目录。"""
87
+ _load_common_modules(repo_root)
88
+ from common.active_task import resolve_active_task # type: ignore[import-not-found]
89
+
90
+ active = resolve_active_task(repo_root)
91
+ if not active.task_path:
92
+ return None
93
+ task_dir = Path(active.task_path)
94
+ if task_dir.is_absolute():
95
+ return task_dir
96
+ return repo_root / task_dir
97
+
98
+
99
+ def _task_json_path(task_dir: Path) -> Path:
100
+ """返回任务 task.json 路径。"""
101
+ return task_dir / FILE_TASK_JSON
102
+
103
+
104
+ def _snapshot_summary(snapshot: dict[str, Any]) -> dict[str, Any]:
105
+ """提取恢复提示需要的精简 snapshot 字段。"""
106
+ return {
107
+ "completed_steps": snapshot.get("completed_steps", []),
108
+ "partial_step": snapshot.get("partial_step"),
109
+ "next_step": snapshot.get("next_step"),
110
+ }
111
+
112
+
113
+ def _load_task_snapshot(repo_root: Path, task_dir: Path) -> dict[str, Any]:
114
+ """读取单个任务的 snapshot 状态。"""
115
+ task_json = _task_json_path(task_dir)
116
+ data = _read_json(task_json)
117
+ task_rel = _rel_path(repo_root, task_dir)
118
+ if data is None:
119
+ return {
120
+ "status": "error",
121
+ "reason": "invalid-task-json",
122
+ "task": task_rel,
123
+ "path": _rel_path(repo_root, task_json),
124
+ }
125
+ snapshot = data.get("last_push_snapshot")
126
+ if not isinstance(snapshot, dict):
127
+ return {"status": "no-snapshot", "task": task_rel}
128
+ return {
129
+ "status": "ok",
130
+ "task": task_rel,
131
+ "snapshot": snapshot,
132
+ "summary": _snapshot_summary(snapshot),
133
+ }
134
+
135
+
136
+ def _iter_active_task_dirs(repo_root: Path) -> list[Path]:
137
+ """列出 active task tree 下的一层任务目录。"""
138
+ tasks_dir = repo_root / DIR_WORKFLOW / DIR_TASKS
139
+ try:
140
+ return [
141
+ path for path in sorted(tasks_dir.iterdir())
142
+ if path.is_dir() and path.name != "archive"
143
+ ]
144
+ except OSError:
145
+ return []
146
+
147
+
148
+ def _snapshot_candidates(repo_root: Path) -> list[dict[str, Any]]:
149
+ """扫描 in_progress 且带 last_push_snapshot 的任务候选。"""
150
+ candidates: list[dict[str, Any]] = []
151
+ for task_dir in _iter_active_task_dirs(repo_root):
152
+ task_json = _task_json_path(task_dir)
153
+ data = _read_json(task_json)
154
+ if data is None:
155
+ continue
156
+ if data.get("status") != "in_progress":
157
+ continue
158
+ snapshot = data.get("last_push_snapshot")
159
+ if not isinstance(snapshot, dict):
160
+ continue
161
+ candidates.append({
162
+ "task": _rel_path(repo_root, task_dir),
163
+ **_snapshot_summary(snapshot),
164
+ })
165
+ return candidates
166
+
167
+
168
+ def _validate_string(value: Any, field: str, errors: list[str]) -> None:
169
+ """校验字符串字段。"""
170
+ if not isinstance(value, str) or not value.strip():
171
+ errors.append(f"{field} 必须是非空字符串")
172
+
173
+
174
+ def _validate_string_or_object(value: Any, field: str, errors: list[str]) -> None:
175
+ """校验字符串或对象字段。"""
176
+ if isinstance(value, str) and value.strip():
177
+ return
178
+ if isinstance(value, dict):
179
+ return
180
+ errors.append(f"{field} 必须是非空字符串或对象")
181
+
182
+
183
+ def _validate_snapshot(snapshot: Any) -> tuple[dict[str, Any] | None, list[str]]:
184
+ """校验 last_push_snapshot schema。"""
185
+ if not isinstance(snapshot, dict):
186
+ return None, ["snapshot 必须是 JSON 对象"]
187
+
188
+ errors: list[str] = []
189
+ for field in sorted(REQUIRED_FIELDS):
190
+ if field not in snapshot:
191
+ errors.append(f"缺少必填字段 {field}")
192
+
193
+ if "snapshot_at" in snapshot:
194
+ _validate_string(snapshot.get("snapshot_at"), "snapshot_at", errors)
195
+ if "branch" in snapshot:
196
+ _validate_string_or_object(snapshot.get("branch"), "branch", errors)
197
+ if "pushed_commits" in snapshot:
198
+ _validate_string_or_object(
199
+ snapshot.get("pushed_commits"),
200
+ "pushed_commits",
201
+ errors,
202
+ )
203
+ completed_steps = snapshot.get("completed_steps")
204
+ if "completed_steps" in snapshot and (
205
+ not isinstance(completed_steps, list)
206
+ or any(not isinstance(item, str) for item in completed_steps)
207
+ ):
208
+ errors.append("completed_steps 必须是字符串数组")
209
+ if "next_step" in snapshot:
210
+ _validate_string(snapshot.get("next_step"), "next_step", errors)
211
+
212
+ for field in sorted(OPTIONAL_STRING_FIELDS):
213
+ if field in snapshot and snapshot.get(field) is not None and not isinstance(snapshot.get(field), str):
214
+ errors.append(f"{field} 存在时必须是字符串")
215
+
216
+ if errors:
217
+ return None, errors
218
+ return snapshot, []
219
+
220
+
221
+ def _print_json(data: dict[str, Any], exit_code: int = 0) -> int:
222
+ """输出紧凑 JSON。"""
223
+ print(json.dumps(data, ensure_ascii=False, sort_keys=True))
224
+ return exit_code
225
+
226
+
227
+ def _print_status_text(data: dict[str, Any]) -> int:
228
+ """输出给人和 AI 快速阅读的状态文本。"""
229
+ status = data.get("status")
230
+ if status == "ok":
231
+ summary = data.get("summary") if isinstance(data.get("summary"), dict) else {}
232
+ print(f"任务:{data.get('task')}")
233
+ print("snapshot:存在")
234
+ print(f"completed_steps: {summary.get('completed_steps', [])}")
235
+ print(f"partial_step: {summary.get('partial_step') or '(none)'}")
236
+ print(f"next_step: {summary.get('next_step') or '(none)'}")
237
+ return 0
238
+ if status == "candidates":
239
+ candidates = data.get("candidates")
240
+ print("无活动任务。snapshot 候选:")
241
+ if isinstance(candidates, list) and candidates:
242
+ for item in candidates:
243
+ print(f"- {item.get('task')}: next_step={item.get('next_step') or '(none)'}")
244
+ else:
245
+ print("(none)")
246
+ return 0
247
+ if status == "no-snapshot":
248
+ print(f"任务没有 last_push_snapshot:{data.get('task')}")
249
+ return 0
250
+ if status == "no-current-task":
251
+ print("没有活动任务,也没有可用的 snapshot 候选。")
252
+ return 0
253
+ print(f"错误:{data.get('reason') or status}", file=sys.stderr)
254
+ return 1
255
+
256
+
257
+ def cmd_status(args: argparse.Namespace, repo_root: Path) -> int:
258
+ """执行 status 子命令。"""
259
+ if args.task:
260
+ task_dir = _resolve_task_dir(repo_root, args.task)
261
+ result = _load_task_snapshot(repo_root, task_dir)
262
+ else:
263
+ task_dir = _current_task_dir(repo_root)
264
+ if task_dir is None:
265
+ candidates = _snapshot_candidates(repo_root)
266
+ result = {
267
+ "status": "candidates" if candidates else "no-current-task",
268
+ "candidates": candidates,
269
+ }
270
+ else:
271
+ result = _load_task_snapshot(repo_root, task_dir)
272
+
273
+ exit_code = 1 if result.get("status") == "error" else 0
274
+ if args.json:
275
+ return _print_json(result, exit_code)
276
+ return _print_status_text(result)
277
+
278
+
279
+ def cmd_write(args: argparse.Namespace, repo_root: Path) -> int:
280
+ """执行 write 子命令。"""
281
+ task_dir = _resolve_task_dir(repo_root, args.task)
282
+ task_json = _task_json_path(task_dir)
283
+ data = _read_json(task_json)
284
+ if data is None:
285
+ result = {
286
+ "status": "error",
287
+ "reason": "invalid-task-json",
288
+ "task": _rel_path(repo_root, task_dir),
289
+ }
290
+ if args.json:
291
+ return _print_json(result, 1)
292
+ print(f"错误:{result['reason']}", file=sys.stderr)
293
+ return 1
294
+
295
+ try:
296
+ raw_snapshot = json.loads(args.snapshot_json)
297
+ except json.JSONDecodeError as exc:
298
+ result = {
299
+ "status": "error",
300
+ "reason": "invalid-snapshot-json",
301
+ "message": str(exc),
302
+ }
303
+ if args.json:
304
+ return _print_json(result, 1)
305
+ print(f"错误:{result['reason']}:{result['message']}", file=sys.stderr)
306
+ return 1
307
+
308
+ snapshot, errors = _validate_snapshot(raw_snapshot)
309
+ if snapshot is None:
310
+ result = {
311
+ "status": "error",
312
+ "reason": "invalid-snapshot-schema",
313
+ "errors": errors,
314
+ }
315
+ if args.json:
316
+ return _print_json(result, 1)
317
+ print("错误:invalid-snapshot-schema", file=sys.stderr)
318
+ for error in errors:
319
+ print(f"- {error}", file=sys.stderr)
320
+ return 1
321
+
322
+ data["last_push_snapshot"] = snapshot
323
+ try:
324
+ _write_json(task_json, data)
325
+ except OSError as exc:
326
+ result = {
327
+ "status": "error",
328
+ "reason": "write-failed",
329
+ "message": str(exc),
330
+ "task": _rel_path(repo_root, task_dir),
331
+ }
332
+ if args.json:
333
+ return _print_json(result, 1)
334
+ print(f"错误:write-failed:{exc}", file=sys.stderr)
335
+ return 1
336
+
337
+ result = {
338
+ "status": "written",
339
+ "task": _rel_path(repo_root, task_dir),
340
+ "path": _rel_path(repo_root, task_json),
341
+ "summary": _snapshot_summary(snapshot),
342
+ }
343
+ if args.json:
344
+ return _print_json(result)
345
+ print(f"✓ 已更新 last_push_snapshot:{result['path']}")
346
+ print(f"next_step: {result['summary'].get('next_step') or '(none)'}")
347
+ return 0
348
+
349
+
350
+ def build_parser() -> argparse.ArgumentParser:
351
+ """创建命令行解析器。"""
352
+ parser = argparse.ArgumentParser(
353
+ description="读取或写入 Trellis 任务的 last_push_snapshot。",
354
+ )
355
+ subparsers = parser.add_subparsers(dest="command", required=True)
356
+
357
+ status = subparsers.add_parser("status", help="读取 last_push_snapshot")
358
+ status.add_argument("--task", help="任务目录、路径或任务名")
359
+ status.add_argument("--json", action="store_true", help="输出 JSON")
360
+
361
+ write = subparsers.add_parser("write", help="写入 last_push_snapshot")
362
+ write.add_argument("--task", required=True, help="任务目录、路径或任务名")
363
+ write.add_argument("--snapshot-json", required=True, help="snapshot JSON 对象")
364
+ write.add_argument("--json", action="store_true", help="输出 JSON")
365
+
366
+ return parser
367
+
368
+
369
+ def main() -> int:
370
+ """命令入口。"""
371
+ repo_root = _find_repo_root(Path.cwd())
372
+ if repo_root is None:
373
+ print("错误:不是 Trellis 项目(缺少 .trellis/)", file=sys.stderr)
374
+ return 1
375
+
376
+ parser = build_parser()
377
+ args = parser.parse_args()
378
+ if args.command == "status":
379
+ return cmd_status(args, repo_root)
380
+ if args.command == "write":
381
+ return cmd_write(args, repo_root)
382
+ parser.print_help()
383
+ return 1
384
+
385
+
386
+ if __name__ == "__main__":
387
+ sys.exit(main())