specmodule 0.1.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.
module_harness/cli.py ADDED
@@ -0,0 +1,1492 @@
1
+ # module_harness/cli.py
2
+ """specmodule CLI — 使用者层面入口(run / status / review / resume / checkpoints / snapshot / rollback / init)。
3
+
4
+ 用法(无打包,与 ``python -m tickflow`` 一致)::
5
+
6
+ python -m module_harness.cli run --module academic_writer --spec-file spec.json
7
+ python -m module_harness.cli run --module academic_writer --spec '{"raw_text": "..."}'
8
+ python -m module_harness.cli resume <rollback> --module <名> [--spec ...]
9
+ python -m module_harness.cli status [--run-id xxx] [--json]
10
+ python -m module_harness.cli review [--run-id xxx] [--tick N] [--node xxx] [--failed] [--json]
11
+ python -m module_harness.cli checkpoints [--run-id xxx] [--json]
12
+ python -m module_harness.cli snapshot [<tick>] [--run-id xxx] [--json] [--out FILE]
13
+ python -m module_harness.cli rollback <目标> --module <名> [--spec ...]
14
+ python -m module_harness.cli checkpoint <label> [<tick>] [--run-id xxx]
15
+ python -m module_harness.cli visualize --module <名> [--tasklist x.json | --run-id xxx] [--out FILE]
16
+
17
+ 场景归属:使用者层面(usage scenario)——第二级用户只写 spec/tasklist,
18
+ 不写 Python。模块按名选择,入口注册由开发者在 ``modules/<name>.py`` 声明。
19
+ 查询组合逻辑只 import 共享层(module_harness.query),绝不重实现。
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import argparse
25
+ import asyncio
26
+ import json
27
+ import sys
28
+ from dataclasses import asdict
29
+ from pathlib import Path
30
+ from typing import Any
31
+
32
+ from llm import LLMConfig, create_llm_client
33
+ from llm.client import LLMResponse
34
+
35
+ from tickflow.persistence import SqliteBackend
36
+
37
+ from .checkpoint import ModuleInputStore, ResumeError
38
+ from .entry import discover_modules
39
+ from .events import EventBus
40
+ from .feed import RunFeedServer
41
+ from .graph_builder import TasklistTranslator
42
+ from .module import Module, _persist_dir
43
+ from .query import (
44
+ CheckpointList,
45
+ ReviewTimeline,
46
+ build_checkpoints,
47
+ build_timeline,
48
+ checkpoints_to_dict,
49
+ filter_failed,
50
+ filter_node,
51
+ filter_tick,
52
+ timeline_to_dict,
53
+ )
54
+ from .registry import HarnessRegistry
55
+ from .scaffold import scaffold, scaffold_dir
56
+ from . import store
57
+ from .spec import Spec, Tasklist
58
+ from .status import query_run_status
59
+ from .translator import TemplateLoader
60
+
61
+
62
+ class MockLLMClient:
63
+ """--mock 冒烟用:通用假客户端(免 key / 免网络)。
64
+
65
+ output_format=json_object 时返回宽松合法 JSON(通过 validator);text
66
+ 时返回占位文本。翻译通道(script 翻译器)不经 LLM,天然可用。
67
+ """
68
+
69
+ async def complete(self, **kwargs: Any) -> LLMResponse:
70
+ fmt = kwargs.get("output_format") or {}
71
+ if fmt.get("type") == "json_object":
72
+ content = json.dumps(
73
+ {"result": "mock output", "summary": "mock", "issues": []}
74
+ )
75
+ else:
76
+ content = "mock output"
77
+ return LLMResponse(content=content)
78
+
79
+
80
+ def _preview(value: Any, width: int = 80) -> str:
81
+ """产出预览:JSON 序列化(失败回退 str)→ 单行 → 截断。"""
82
+ if value is None:
83
+ return ""
84
+ try:
85
+ text = json.dumps(value, ensure_ascii=False)
86
+ except (TypeError, ValueError):
87
+ text = str(value)
88
+ text = " ".join(text.split())
89
+ return text if len(text) <= width else text[:width] + "…"
90
+
91
+
92
+ class RunDisplay:
93
+ """三级实时显示(--verbose 1..3),由 runner hooks 驱动。
94
+
95
+ L1(默认):``tick 3 Organize ✓`` 一行;失败节点附加 error + 产出预览
96
+ L2:L1 + 全部节点产出预览(约 80 字符截断)
97
+ L3:完整详情块(tick 分隔线 + 输入摘要 + 完整产出 + error)
98
+
99
+ 回调均为 sync——tickflow ``_maybe_await`` 自动兼容;异常由 tickflow 吞。
100
+ """
101
+
102
+ _STATUS_ICON = {"ok": "✓", "failed": "✗", "aborted": "✗"}
103
+
104
+ def __init__(self, verbose: int = 1, stream: Any = None) -> None:
105
+ self.verbose = verbose
106
+ self._out = stream or sys.stdout
107
+ self.firings: list = [] # 全部 NodeState(结束汇总用)
108
+
109
+ def hooks(self) -> dict:
110
+ return {"on_tick_start": self._on_tick_start, "on_fire": self._on_fire}
111
+
112
+ def _write(self, text: str) -> None:
113
+ print(text, file=self._out)
114
+
115
+ def _on_tick_start(self, tick: int, fireable: list[str]) -> None:
116
+ if self.verbose >= 3:
117
+ self._write(f"═══ tick {tick} ═══ fireable: {', '.join(fireable) or '—'}")
118
+
119
+ def _on_fire(self, ns: Any) -> None:
120
+ self.firings.append(ns)
121
+ icon = self._STATUS_ICON.get(ns.status, ns.status)
122
+ if self.verbose >= 3:
123
+ self._write(f"── tick {ns.tick} {ns.node} [{ns.status}]")
124
+ if ns.inputs:
125
+ self._write(f" inputs : {_preview(ns.inputs, width=200)}")
126
+ if ns.output is not None:
127
+ self._write(f" output : {_preview(ns.output, width=2000)}")
128
+ if ns.error:
129
+ self._write(f" error : {ns.error}")
130
+ return
131
+ line = f"tick {ns.tick} {ns.node:<24} {icon}"
132
+ if ns.status != "ok":
133
+ line += f" error={ns.error}"
134
+ if ns.output is not None:
135
+ line += f" output={_preview(ns.output)}"
136
+ elif self.verbose >= 2:
137
+ line += f" output={_preview(ns.output)}"
138
+ self._write(line)
139
+
140
+
141
+ def _resolve_spec(entry: Any, args: argparse.Namespace) -> dict[str, Any]:
142
+ """spec 解析优先级:--spec > --spec-file > entry.default_spec。"""
143
+ if args.spec:
144
+ try:
145
+ data = json.loads(args.spec)
146
+ except json.JSONDecodeError as e:
147
+ raise ValueError(f"--spec 不是合法 JSON: {e}")
148
+ if not isinstance(data, dict):
149
+ raise ValueError("--spec 必须是 JSON 对象")
150
+ return data
151
+ if args.spec_file:
152
+ path = Path(args.spec_file)
153
+ if not path.exists():
154
+ raise ValueError(f"--spec-file 不存在: {path}")
155
+ try:
156
+ data = json.loads(path.read_text(encoding="utf-8"))
157
+ except json.JSONDecodeError as e:
158
+ raise ValueError(f"--spec-file 不是合法 JSON: {e}")
159
+ except OSError as e:
160
+ raise ValueError(f"--spec-file 读取失败: {e}")
161
+ if not isinstance(data, dict):
162
+ raise ValueError("--spec-file 内容必须是 JSON 对象")
163
+ return data
164
+ if entry.default_spec is not None:
165
+ return dict(entry.default_spec)
166
+ raise ValueError("缺少 spec——请用 --spec(内联 JSON)或 --spec-file(文件)")
167
+
168
+
169
+ _TYPE_CHECKS = {
170
+ "str": str,
171
+ "int": int,
172
+ "float": (int, float),
173
+ "bool": bool,
174
+ "list": list,
175
+ "dict": dict,
176
+ }
177
+
178
+
179
+ def _check_spec_schema(entry: Any, spec: dict[str, Any]) -> None:
180
+ """可选的 spec_schema 校验:{字段: 类型名},失败列出全部错误。"""
181
+ if not entry.spec_schema:
182
+ return
183
+ errors: list[str] = []
184
+ for field, type_name in entry.spec_schema.items():
185
+ if field not in spec:
186
+ errors.append(f"缺少字段 '{field}'(期望 {type_name})")
187
+ continue
188
+ check = _TYPE_CHECKS.get(str(type_name))
189
+ if check is not None and not isinstance(spec[field], check):
190
+ errors.append(
191
+ f"字段 '{field}' 应为 {type_name},实际 {type(spec[field]).__name__}"
192
+ )
193
+ if errors:
194
+ raise ValueError("spec 校验失败:\n" + "\n".join(f" - {e}" for e in errors))
195
+
196
+
197
+ def _build_llm_client(mock: bool) -> Any:
198
+ """--mock 用内置假客户端;否则从环境加载(失败提示 --mock)。"""
199
+ if mock:
200
+ return MockLLMClient()
201
+ try:
202
+ config = LLMConfig.from_env(store_root=store.store_home())
203
+ except ValueError as e:
204
+ raise ValueError(
205
+ f"LLM 环境配置失败: {e}\n提示:可加 --mock 免 key 冒烟运行"
206
+ )
207
+ if not config.is_configured:
208
+ raise ValueError(
209
+ "LLM 未配置 API key——请配置 config.json + .env(项目根或 "
210
+ f"store 家目录 {store.store_home()}),或加 --mock 冒烟"
211
+ )
212
+ return create_llm_client(config)
213
+
214
+
215
+ def _load_tasklist(path_str: str) -> Tasklist:
216
+ """加载 tasklist JSON 文件({Tasks, Flow} 结构)。"""
217
+ path = Path(path_str)
218
+ if not path.exists():
219
+ raise ValueError(f"--tasklist 文件不存在: {path}")
220
+ try:
221
+ data = json.loads(path.read_text(encoding="utf-8"))
222
+ except (json.JSONDecodeError, OSError) as e:
223
+ raise ValueError(f"--tasklist 读取失败: {e}")
224
+ return Tasklist.from_json(data)
225
+
226
+
227
+ def _print_available(modules: dict) -> None:
228
+ """打印可用模块列表(模块未找到时)。"""
229
+ if not modules:
230
+ print(
231
+ "modules_dir 中未发现任何模块(modules/<name>.py + entry 声明)",
232
+ file=sys.stderr,
233
+ )
234
+ return
235
+ print("可用模块:", file=sys.stderr)
236
+ for name, entry in modules.items():
237
+ print(f" {name}: {entry.description}", file=sys.stderr)
238
+
239
+
240
+ def _print_final_summary(
241
+ display: RunDisplay, entry: Any, module_id: str, label: str = "运行完成"
242
+ ) -> None:
243
+ """结束汇总:运行/续跑完成 + firing 总数 + 节点输出摘要(run/resume 共用)。"""
244
+ print(f"\n{label}: module={entry.name} run_id={module_id}")
245
+ print(f"共 {len(display.firings)} 次节点 firing")
246
+ by_node: dict[str, Any] = {
247
+ ns.node: ns.output for ns in display.firings if ns.status == "ok"
248
+ }
249
+ if by_node:
250
+ print("节点最新输出摘要:")
251
+ for node, out in by_node.items():
252
+ print(f" {node}: {_preview(out)}")
253
+
254
+
255
+ class _ResolvedModule:
256
+ """统一模块解析结果:entry(单文件)或 packed(目录/ pip 分发)。
257
+
258
+ run/resume/rollback/visualize 共用;打包形态经 ``ModuleLoader`` 加载为
259
+ SubModule 实例(校验阶段不实例化 LLM client——llm_client 延后到运行)。
260
+ """
261
+
262
+ def __init__(self, name: str, source: Any):
263
+ self.name = name
264
+ self.source = source # store.ModuleSource
265
+ self.entry = None # kind="entry" 时:ModuleEntry
266
+ self.submodule = None # kind="packed" 时:加载后的 SubModule 实例
267
+
268
+ @property
269
+ def kind(self) -> str:
270
+ return self.source.kind
271
+
272
+ @property
273
+ def description(self) -> str:
274
+ if self.entry is not None:
275
+ return self.entry.description
276
+ return self.source.description
277
+
278
+ @property
279
+ def default_template(self) -> str | None:
280
+ return None if self.submodule is not None else self.entry.default_template
281
+
282
+ @property
283
+ def default_spec(self) -> dict[str, Any] | None:
284
+ if self.submodule is not None:
285
+ return None
286
+ return self.entry.default_spec
287
+
288
+ @property
289
+ def spec_schema(self) -> dict[str, str] | None:
290
+ if self.submodule is not None:
291
+ schema = self.submodule.spec_schema
292
+ return schema.input if schema else None
293
+ return self.entry.spec_schema
294
+
295
+ @property
296
+ def submodules(self) -> dict[str, Any]:
297
+ if self.submodule is not None:
298
+ return self.submodule.modules
299
+ return self.entry.submodules
300
+
301
+ @property
302
+ def templates(self) -> dict[str, dict]:
303
+ return {} if self.submodule is not None else self.entry.templates
304
+
305
+
306
+ def _resolve_module_cmd(args: argparse.Namespace) -> _ResolvedModule | None:
307
+ """按搜索路径统一解析模块(--modules-dir 兼容保留为最高优先)。
308
+
309
+ 解析顺序:显式 ``--modules-dir`` 的 entry(旧语义)→ 统一搜索路径
310
+ (cwd/modules + $SPECMODULE_PATH + store/modules + pip)。packed 形态
311
+ 在此只做轻量加载(ModuleLoader.load 不创建 LLM client——client 由
312
+ ``_build_llm_client`` 延后,校验/渲染零 LLM)。
313
+ 未找到 → 打印可用清单并返回 None。
314
+ """
315
+ # 1. 显式 --modules-dir:保持旧语义(只找 entry 单文件)
316
+ if args.modules_dir != "modules" or (args.modules_dir == "modules"
317
+ and not (Path.cwd() / "modules").is_dir()):
318
+ # --modules-dir 显式给出:仅该目录的 entry 形态
319
+ entries = discover_modules(Path(args.modules_dir))
320
+ entry = entries.get(args.module)
321
+ if entry is not None:
322
+ res = _ResolvedModule(args.module, store.ModuleSource(
323
+ name=args.module, kind="entry",
324
+ path=Path(args.modules_dir) / f"{args.module}.py",
325
+ ))
326
+ res.entry = entry
327
+ return res
328
+
329
+ # 2. 统一搜索路径
330
+ src = store.resolve_module(args.module)
331
+ if src is None:
332
+ print(
333
+ f"模块 '{args.module}' 未找到——可用: specmodule list 查看全部",
334
+ file=sys.stderr,
335
+ )
336
+ _print_available(discover_modules(Path(args.modules_dir)))
337
+ return None
338
+ res = _ResolvedModule(args.module, src)
339
+ if src.is_packed:
340
+ try:
341
+ from .loader import ModuleLoader
342
+
343
+ res.submodule = ModuleLoader().load(src.path, lazy_client=True)
344
+ except Exception as e:
345
+ print(f"模块 '{args.module}' 加载失败: {e}", file=sys.stderr)
346
+ return None
347
+ else:
348
+ entries = discover_modules(src.path.parent)
349
+ res.entry = entries.get(args.module)
350
+ if res.entry is None:
351
+ print(f"模块 '{args.module}' 入口解析失败", file=sys.stderr)
352
+ return None
353
+ return res
354
+
355
+
356
+ def _cmd_run(args: argparse.Namespace) -> int:
357
+ res = _resolve_module_cmd(args)
358
+ if res is None:
359
+ return 1
360
+ if args.tasklist and args.template:
361
+ print("--tasklist 与 --template 互斥——只能二选一", file=sys.stderr)
362
+ return 1
363
+ # 预绑定:Ctrl+C 可能落在 display/mod 赋值前(pre-run 阶段),
364
+ # 避免 KeyboardInterrupt 处理器引用未绑定变量抛 NameError(探索定稿已知缺陷)。
365
+ display = None
366
+ mod = None
367
+ try:
368
+ spec = _resolve_spec(res, args)
369
+ _check_spec_schema(res, spec)
370
+ llm_client = _build_llm_client(args.mock)
371
+ template_name = args.template or res.default_template
372
+ if args.tasklist:
373
+ # tasklist 路径:跳过翻译,template_name 置 None(与 Module
374
+ # "template/tasklist 二选一"不变量对齐)
375
+ template_name = None
376
+ elif template_name is not None and template_name not in res.templates:
377
+ raise ValueError(
378
+ f"模板 '{template_name}' 未注册——可用: {', '.join(res.templates)}"
379
+ )
380
+ loader = TemplateLoader()
381
+ for name, data in res.templates.items():
382
+ loader.register(name, data)
383
+ event_bus = EventBus()
384
+ display = RunDisplay(args.verbose)
385
+ if res.submodule is not None:
386
+ # packed 形态:SubModule.run(tasklist 固定,审核关闭)
387
+ sub = res.submodule
388
+ if args.template:
389
+ print("--template 不适用于已打包模块(tasklist 固定)", file=sys.stderr)
390
+ return 1
391
+ asyncio.run(sub.run(
392
+ spec,
393
+ tasklist=_load_tasklist(args.tasklist) if args.tasklist else None,
394
+ llm_client=llm_client,
395
+ event_bus=event_bus,
396
+ hooks=display.hooks(),
397
+ max_ticks=args.max_ticks,
398
+ ))
399
+ run_id = sub._module_id()
400
+ else:
401
+ entry = res.entry
402
+ if entry.build_registry is not None:
403
+ registry = entry.build_registry(llm_client, template_name, event_bus)
404
+ else:
405
+ registry = HarnessRegistry(llm_client=llm_client, event_bus=event_bus)
406
+ mod = Module(
407
+ spec=spec,
408
+ template_name=template_name,
409
+ tasklist=_load_tasklist(args.tasklist) if args.tasklist else None,
410
+ llm_client=llm_client,
411
+ event_bus=event_bus,
412
+ template_loader=loader,
413
+ module_id=args.run_id or args.module,
414
+ registry=registry,
415
+ review_harness=entry.review_harness,
416
+ modules=entry.submodules,
417
+ hooks=display.hooks(),
418
+ )
419
+ asyncio.run(mod.run(max_ticks=args.max_ticks))
420
+ run_id = mod.module_id
421
+ except KeyboardInterrupt:
422
+ n = len(display.firings) if display is not None else 0
423
+ run_id = mod.module_id if mod is not None else (args.run_id or args.module)
424
+ print(
425
+ f"\n已中断:已执行 {n} 次节点 firing。"
426
+ f"运行数据已落盘 .specmodule/runs/{run_id}/(status/review 可查)",
427
+ file=sys.stderr,
428
+ )
429
+ return 2
430
+ except (ValueError, ModuleNotFoundError) as e:
431
+ print(f"错误: {e}", file=sys.stderr)
432
+ return 1
433
+ # 结束汇总(display 此处必已赋值;mod 仅取 module_id)
434
+ assert display is not None
435
+ _print_final_summary(display, res, run_id)
436
+ return 0
437
+
438
+
439
+ def _run_resume_cmd(args: argparse.Namespace, *, require_target: bool) -> int:
440
+ """续跑/回退共用实现:镜像 run 接线,最后一步换 Module.resume。
441
+
442
+ 回退目标:显式 tick 号 / ``manual:<label>`` 直传库解析(无效目标由库
443
+ KeyError 携带可用清单);``require_target=False``(resume)缺省 = 最新
444
+ tick 快照(CLI 侧读取 backend,与 module.py resume 内部同一读取方式);
445
+ ``require_target=True``(rollback)目标必填,防"想回退却续了最新"。
446
+ """
447
+ res = _resolve_module_cmd(args)
448
+ if res is None:
449
+ return 1
450
+ if args.tasklist and args.template:
451
+ print("--tasklist 与 --template 互斥——只能二选一", file=sys.stderr)
452
+ return 1
453
+ module_id = args.run_id or args.module
454
+ # 前置:续跑目标运行必须已落盘(run.sqlite = 快照 backend)
455
+ db_path = _persist_dir(module_id)
456
+ if not db_path.exists():
457
+ print(f"无运行记录: {module_id}(先执行 specmodule run)", file=sys.stderr)
458
+ return 1
459
+ # 预绑定:Ctrl+C 可能落在 display/mod 赋值前,避免处理器引用未绑定变量
460
+ display = None
461
+ try:
462
+ spec = _resolve_spec(res, args)
463
+ _check_spec_schema(res, spec)
464
+ llm_client = _build_llm_client(args.mock)
465
+ template_name = args.template or res.default_template
466
+ if args.tasklist:
467
+ # tasklist 路径:跳过翻译,template_name 置 None(与 Module
468
+ # "template/tasklist 二选一"不变量对齐)
469
+ template_name = None
470
+ elif template_name is not None and template_name not in res.templates:
471
+ raise ValueError(
472
+ f"模板 '{template_name}' 未注册——可用: {', '.join(res.templates)}"
473
+ )
474
+ loader = TemplateLoader()
475
+ for name, data in res.templates.items():
476
+ loader.register(name, data)
477
+ event_bus = EventBus()
478
+ if res.submodule is not None:
479
+ # packed 形态:与 run 相同的 SubModule 内部接线(固定 tasklist)
480
+ registry = res.submodule._build_registry(
481
+ False, llm_client=llm_client, event_bus=event_bus
482
+ )
483
+ tasklist = res.submodule.tasklist
484
+ modules = res.submodule.modules
485
+ else:
486
+ entry = res.entry
487
+ if entry.build_registry is not None:
488
+ registry = entry.build_registry(llm_client, template_name, event_bus)
489
+ else:
490
+ registry = HarnessRegistry(llm_client=llm_client, event_bus=event_bus)
491
+ tasklist = _load_tasklist(args.tasklist) if args.tasklist else None
492
+ modules = entry.submodules
493
+ # 回退目标:显式直传;缺省 = 最新 tick 快照
494
+ rollback_to = args.rollback
495
+ if rollback_to is None:
496
+ if require_target:
497
+ print(
498
+ "rollback 需要显式回退目标——可用: specmodule checkpoints --run-id "
499
+ f"{module_id} 查看全部回退点(resume <目标> 亦可,缺省续最新)",
500
+ file=sys.stderr,
501
+ )
502
+ return 1
503
+ backend = SqliteBackend(db_path)
504
+ try:
505
+ ticks = backend.list_snapshots(module_id)
506
+ finally:
507
+ backend.close()
508
+ if not ticks:
509
+ print(
510
+ f"无可恢复快照: {module_id}(运行未产生任何 tick 快照)",
511
+ file=sys.stderr,
512
+ )
513
+ return 1
514
+ rollback_to = max(ticks)
515
+ display = RunDisplay(args.verbose)
516
+ mod = Module(
517
+ spec=spec,
518
+ template_name=template_name,
519
+ tasklist=tasklist,
520
+ llm_client=llm_client,
521
+ event_bus=event_bus,
522
+ template_loader=loader,
523
+ module_id=module_id,
524
+ registry=registry,
525
+ review_harness=None if res.submodule is not None else res.entry.review_harness,
526
+ modules=modules,
527
+ hooks=display.hooks(),
528
+ )
529
+ asyncio.run(mod.resume(rollback_to=rollback_to, max_ticks=args.max_ticks))
530
+ except KeyboardInterrupt:
531
+ n = len(display.firings) if display is not None else 0
532
+ print(
533
+ f"\n已中断:已执行 {n} 次节点 firing。"
534
+ f"运行数据已落盘 .specmodule/runs/{module_id}/(status/review 可查)",
535
+ file=sys.stderr,
536
+ )
537
+ return 2
538
+ except KeyError as e:
539
+ print(f"错误: {e}", file=sys.stderr)
540
+ print(
541
+ "可用回退点清单: specmodule checkpoints --run-id "
542
+ f"{module_id}(resume/rollback 目标即其中 target)",
543
+ file=sys.stderr,
544
+ )
545
+ return 1
546
+ except (ValueError, ModuleNotFoundError, ResumeError) as e:
547
+ print(f"错误: {e}", file=sys.stderr)
548
+ return 1
549
+ # 结束汇总(display 此处必已赋值)
550
+ assert display is not None
551
+ _print_final_summary(display, entry, module_id, label="续跑完成")
552
+ return 0
553
+
554
+
555
+ def _cmd_resume(args: argparse.Namespace) -> int:
556
+ """从中断处续跑模块(tick 截断 / Ctrl+C 后):缺省 = 最新 tick 快照。"""
557
+ return _run_resume_cmd(args, require_target=False)
558
+
559
+
560
+ def _cmd_rollback(args: argparse.Namespace) -> int:
561
+ """回退到指定 tick/manual 检查点并重跑(目标必填)。"""
562
+ return _run_resume_cmd(args, require_target=True)
563
+
564
+
565
+ def _latest_run_id() -> str | None:
566
+ """扫描 .specmodule/runs/ 取最新修改的子目录名(status/review 缺省)。"""
567
+ runs = Path.cwd() / ".specmodule" / "runs"
568
+ if not runs.is_dir():
569
+ return None
570
+ try:
571
+ dirs = [d for d in runs.iterdir() if d.is_dir()]
572
+ except OSError:
573
+ return None
574
+ if not dirs:
575
+ return None
576
+ return max(dirs, key=lambda d: d.stat().st_mtime).name
577
+
578
+
579
+ def _cmd_init(args: argparse.Namespace) -> int:
580
+ """生成模块脚手架:--as-dir 目录形态(pack 同构)或单文件形态 + 项目文件补齐。"""
581
+ try:
582
+ if args.as_dir:
583
+ result = scaffold_dir(
584
+ args.name,
585
+ base_dir=args.dir,
586
+ force=args.force,
587
+ description=args.description or "",
588
+ )
589
+ else:
590
+ result = scaffold(
591
+ args.name,
592
+ base_dir=args.dir,
593
+ force=args.force,
594
+ description=args.description or "",
595
+ )
596
+ except ValueError as e:
597
+ print(f"错误: {e}", file=sys.stderr)
598
+ return 1
599
+ for p in result.created:
600
+ print(f"创建 {p}")
601
+ for p in result.skipped:
602
+ print(f"跳过(已存在) {p}")
603
+ print(f"\n完成:{len(result.created)} 个文件创建,{len(result.skipped)} 个跳过。")
604
+ if args.as_dir:
605
+ print(f"冒烟验收:python -m module_harness.cli run --module {args.name} --mock")
606
+ print(f"发布:specmodule publish {args.name} --from {args.dir}")
607
+ else:
608
+ print(f"冒烟验收:python -m module_harness.cli run --module {args.name} --mock")
609
+ return 0
610
+
611
+
612
+ def _cmd_status(args: argparse.Namespace) -> int:
613
+ run_id = args.run_id or _latest_run_id()
614
+ if run_id is None:
615
+ print("无运行记录(先执行 specmodule run)", file=sys.stderr)
616
+ return 1
617
+ st = query_run_status(run_id)
618
+ if st is None:
619
+ print(f"无运行记录: {run_id}(先执行 specmodule run)", file=sys.stderr)
620
+ return 1
621
+ if args.json:
622
+ print(json.dumps(asdict(st), ensure_ascii=False, indent=2))
623
+ return 0
624
+ line = f"模块 {st.module_id}: phase={st.phase}"
625
+ if st.tick is not None:
626
+ line += f" tick={st.tick}"
627
+ print(line)
628
+ if st.status:
629
+ print(f"runner: {st.status}")
630
+ if st.fired:
631
+ print(f"本 tick fired: {', '.join(st.fired)}")
632
+ if st.error:
633
+ print(f"error: {st.error}")
634
+ return 0
635
+
636
+
637
+ def _cmd_checkpoints(args: argparse.Namespace) -> int:
638
+ """列出可用回退点(tick 快照 + manual 检查点)——resume/rollback 目标清单。
639
+
640
+ 数据组合走共享层 ``build_checkpoints``(CLI/MCP/Web 复用),渲染是本命令。
641
+ """
642
+ run_id = args.run_id or _latest_run_id()
643
+ if run_id is None:
644
+ print("无运行记录(先执行 specmodule run)", file=sys.stderr)
645
+ return 1
646
+ cl: CheckpointList | None = build_checkpoints(run_id)
647
+ if cl is None:
648
+ print(f"无运行记录: {run_id}(先执行 specmodule run)", file=sys.stderr)
649
+ return 1
650
+ if args.json:
651
+ print(json.dumps(checkpoints_to_dict(cl), ensure_ascii=False, indent=2))
652
+ return 0
653
+ if not cl.entries:
654
+ print(f"无可回退检查点: {run_id}", file=sys.stderr)
655
+ return 1
656
+ print(f"可用回退点 (run_id={run_id}):")
657
+ for e in cl.entries:
658
+ if e.kind == "tick":
659
+ fired = ", ".join(e.fired) or "—"
660
+ print(f" tick {e.tick:<6} fired: {fired}")
661
+ else:
662
+ print(f" manual: {e.label} (tick {e.tick})")
663
+ print(
664
+ "\n回退: specmodule resume <目标> --module <名>"
665
+ "(缺省续最新;rollback <目标> 须显式指定目标)"
666
+ )
667
+ return 0
668
+
669
+
670
+ def _cmd_snapshot(args: argparse.Namespace) -> int:
671
+ """检视/导出指定 tick 的运行时快照。
672
+
673
+ 默认:文本摘要(状态 / fired / fireable / 各节点最新输出,输出从 firings
674
+ 表取——轻量快照剥离 records);``--json``:stdout 打印完整 runner 快照
675
+ JSON(即 ``runner.restore()`` 输入);``--out FILE``:写完整快照 JSON 到
676
+ 文件(自包含,可 restore 到新 runner,跨进程调试素材)。缺省 tick = 最新。
677
+ """
678
+ run_id = args.run_id or _latest_run_id()
679
+ if run_id is None:
680
+ print("无运行记录(先执行 specmodule run)", file=sys.stderr)
681
+ return 1
682
+ db_path = _persist_dir(run_id)
683
+ if not db_path.exists():
684
+ print(f"无运行记录: {run_id}(先执行 specmodule run)", file=sys.stderr)
685
+ return 1
686
+ backend = SqliteBackend(db_path)
687
+ try:
688
+ ticks = backend.list_snapshots(run_id)
689
+ if not ticks:
690
+ print(
691
+ f"无可恢复快照: {run_id}(运行未产生任何 tick 快照)",
692
+ file=sys.stderr,
693
+ )
694
+ return 1
695
+ tick = args.tick if args.tick is not None else max(ticks)
696
+ if tick not in ticks:
697
+ print(
698
+ f"快照 tick {tick} 不存在(可用: {ticks or '无'})",
699
+ file=sys.stderr,
700
+ )
701
+ return 1
702
+ snap = backend.load_snapshot(run_id, tick)
703
+ if snap is None:
704
+ print(f"快照 tick {tick} 读取失败(数据损坏?)", file=sys.stderr)
705
+ return 1
706
+ if args.json or args.out:
707
+ text = json.dumps(snap, ensure_ascii=False, indent=2)
708
+ if args.json:
709
+ print(text)
710
+ if args.out:
711
+ Path(args.out).write_text(text, encoding="utf-8")
712
+ print(f"快照已导出: {args.out}({len(text)} 字节)")
713
+ return 0
714
+ # 各节点最新输出:firings 表取(S3 后快照无 records/edges)
715
+ latest = backend.latest_firings(run_id)
716
+ outputs = {d["node"]: d.get("output") for d in latest if d.get("node")}
717
+ finally:
718
+ backend.close()
719
+ print(f"快照 (run_id={run_id}):")
720
+ print(f" tick: {snap.get('tick', tick)}")
721
+ print(f" status: {snap.get('status', '?')}")
722
+ if snap.get("cancel_reason"):
723
+ print(f" cancel_reason: {snap['cancel_reason']}")
724
+ if snap.get("fireable"):
725
+ print(f" fireable: {', '.join(snap['fireable'])}")
726
+ fired = list(snap.get("fired", []))
727
+ if fired:
728
+ print(f" fired: {', '.join(fired)}")
729
+ if outputs:
730
+ print(" 各节点最新输出:")
731
+ for node, out in outputs.items():
732
+ print(f" {node}: {_preview(out)}")
733
+ return 0
734
+
735
+
736
+ def _cmd_checkpoint(args: argparse.Namespace) -> int:
737
+ """给指定 tick 快照起命名检查点(复制到 checkpoints 表,manual 永久保留)。
738
+
739
+ 纯数据操作、跨进程:不依赖运行中的 runner——快照已每 tick 落盘,
740
+ 命名 = 给已有快照加人类标签,之后 ``resume/rollback manual:<label>``
741
+ 按名回退。label 自动补 ``manual:`` 前缀(库侧目标解析要求)。
742
+ """
743
+ run_id = args.run_id or _latest_run_id()
744
+ if run_id is None:
745
+ print("无运行记录(先执行 specmodule run)", file=sys.stderr)
746
+ return 1
747
+ db_path = _persist_dir(run_id)
748
+ if not db_path.exists():
749
+ print(f"无运行记录: {run_id}(先执行 specmodule run)", file=sys.stderr)
750
+ return 1
751
+ label = args.label if args.label.startswith("manual:") else "manual:" + args.label
752
+ backend = SqliteBackend(db_path)
753
+ try:
754
+ ticks = backend.list_snapshots(run_id)
755
+ if not ticks:
756
+ print(
757
+ f"无可恢复快照: {run_id}(运行未产生任何 tick 快照)",
758
+ file=sys.stderr,
759
+ )
760
+ return 1
761
+ tick = args.tick if args.tick is not None else max(ticks)
762
+ if tick not in ticks:
763
+ print(
764
+ f"快照 tick {tick} 不存在(可用: {ticks or '无'})",
765
+ file=sys.stderr,
766
+ )
767
+ return 1
768
+ snap = backend.load_snapshot(run_id, tick)
769
+ if snap is None:
770
+ print(f"快照 tick {tick} 读取失败(数据损坏?)", file=sys.stderr)
771
+ return 1
772
+ old_labels = [lbl for lbl, _ in backend.list_checkpoints(run_id)]
773
+ backend.save_checkpoint(run_id, label, snap)
774
+ finally:
775
+ backend.close()
776
+ msg = f"已创建检查点 {label}(tick {tick})"
777
+ if label in old_labels:
778
+ msg += "(覆盖同名)"
779
+ print(msg)
780
+ print(f"回退: specmodule rollback {label} --module <名>(或 resume {label})")
781
+ return 0
782
+
783
+
784
+ def _cmd_visualize(args: argparse.Namespace) -> int:
785
+ """渲染 tasklist 对应图(mermaid)——看"这次流水线长什么样"。
786
+
787
+ 数据源:``--tasklist <file>``(未运行的 tasklist 直接渲染)优先;
788
+ 否则读 run.sqlite 的 module_inputs 存档(最近一次 run/resume 的输入)。
789
+
790
+ 纯静态:只重建 Graph 并 ``to_mermaid()``,零执行、不读快照。registry
791
+ 由模块入口构建(graph 校验需要已注册的 guard/body),llm_client 用
792
+ Mock 占位(渲染不调用 LLM,免 key 可用)。
793
+ """
794
+ modules = discover_modules(Path(args.modules_dir))
795
+ res = _resolve_module_cmd(args)
796
+ if res is None:
797
+ return 1
798
+ # 数据源:--tasklist 文件优先;否则 module_inputs 存档。
799
+ # run_id 缺省 = 模块同名运行目录(run 的 run_id 默认即模块名)——不
800
+ # 用全局最新(_latest_run_id),避免其他模块/测试残留目录干扰。
801
+ if args.tasklist:
802
+ tasklist = _load_tasklist(args.tasklist)
803
+ spec = None
804
+ run_id = args.module
805
+ else:
806
+ run_id = args.run_id or args.module
807
+ store = ModuleInputStore(run_id)
808
+ try:
809
+ inputs = store.load_module_inputs()
810
+ finally:
811
+ store.close()
812
+ if inputs is None:
813
+ print(
814
+ f"无运行记录: {run_id}(先执行 specmodule run,或传 --tasklist 直接渲染)",
815
+ file=sys.stderr,
816
+ )
817
+ return 1
818
+ tasklist = Tasklist.from_json(inputs["tasklist"])
819
+ spec = Spec(inputs["spec"])
820
+ # registry 构建(与 run 同款模组接线;渲染零 LLM,Mock 占位)
821
+ event_bus = EventBus()
822
+ template_hint: str | None = None # entry 形态可用模板(校验失败时提示)
823
+ if res.submodule is not None:
824
+ # packed 形态:SubModule 的固定 tasklist + 注册表
825
+ tasklist = tasklist or res.submodule.tasklist
826
+ registry = res.submodule._build_registry(
827
+ False, llm_client=MockLLMClient(), event_bus=event_bus
828
+ )
829
+ modules = res.submodule.modules
830
+ else:
831
+ entry = res.entry
832
+ template_hint = "、".join(entry.templates)
833
+ if entry.build_registry is not None:
834
+ registry = entry.build_registry(
835
+ MockLLMClient(), args.template or entry.default_template, event_bus
836
+ )
837
+ else:
838
+ registry = HarnessRegistry(llm_client=MockLLMClient(), event_bus=event_bus)
839
+ modules = entry.submodules
840
+ try:
841
+ builder = TasklistTranslator(
842
+ registry, module_id=run_id,
843
+ modules=modules, llm_client=MockLLMClient(),
844
+ )
845
+ graph, _ = builder.build(tasklist, spec)
846
+ except ValueError as e:
847
+ print(f"错误: {e}", file=sys.stderr)
848
+ if template_hint:
849
+ print(
850
+ "提示: registry 按模板 "
851
+ f"'{args.template or entry.default_template}' 构建,"
852
+ "tasklist 与之不匹配时会出现未注册元件——可用模板: "
853
+ f"{template_hint};存档/文件的 tasklist 可能来自其他模板,"
854
+ "试试对应 --template(如仍失败可传 --tasklist 直接渲染文件)",
855
+ file=sys.stderr,
856
+ )
857
+ return 1
858
+ text = graph.to_mermaid()
859
+ if args.out:
860
+ Path(args.out).write_text(text, encoding="utf-8")
861
+ print(f"mermaid 已导出: {args.out}({len(text)} 字节)")
862
+ else:
863
+ print(text)
864
+ return 0
865
+
866
+
867
+ def _render_timeline(timeline: ReviewTimeline, show_outputs: bool = False) -> None:
868
+ """按 tick 分组文本时间线(失败节点高亮 + error 详情)。
869
+
870
+ ``show_outputs``(--tick/--node 过滤时)对每条 entry 附加产出预览。
871
+ """
872
+ if not timeline.entries:
873
+ print("(空时间线——无节点 firing 记录)")
874
+ return
875
+ ticks: dict[int, list] = {}
876
+ for e in timeline.entries:
877
+ ticks.setdefault(e.tick, []).append(e)
878
+ for tick in sorted(ticks):
879
+ cells = [
880
+ f"{e.node} {RunDisplay._STATUS_ICON.get(e.status, e.status)}"
881
+ for e in ticks[tick]
882
+ ]
883
+ print(f"tick {tick}: " + ", ".join(cells))
884
+ for e in ticks[tick]:
885
+ if e.status != "ok":
886
+ print(f" ✗ {e.node}: {e.error or '无错误信息'}")
887
+ if show_outputs and e.output is not None:
888
+ print(f" {e.node} output: {_preview(e.output, width=200)}")
889
+ if timeline.latest_tick is not None:
890
+ print(f"\n最新 tick: {timeline.latest_tick}")
891
+
892
+
893
+ def _cmd_feed(args: argparse.Namespace) -> int:
894
+ """启动零依赖运行 feed(http.server):浏览器轮询查看运行状态/时间线。"""
895
+ server = RunFeedServer(("127.0.0.1" if args.host == "localhost" else args.host, args.port))
896
+ run_id = args.run_id or server.latest_run_id()
897
+ print(f"SpecModule feed 已启动: http://{args.host}:{args.port}/")
898
+ if run_id is not None:
899
+ print(f"查看运行: http://{args.host}:{args.port}/?run_id={run_id}")
900
+ print("Ctrl+C 停止。")
901
+ try:
902
+ server.serve_forever()
903
+ except KeyboardInterrupt:
904
+ print("\n已停止。", file=sys.stderr)
905
+ return 0
906
+ finally:
907
+ server.server_close()
908
+ return 0
909
+
910
+
911
+ def _cmd_install(args: argparse.Namespace) -> int:
912
+ """安装模块到 store:本地 pack 目录或 git URL(校验零落盘)。"""
913
+ target = args.source
914
+ if target.startswith(("http://", "https://")) or target.endswith(".git") or "git@" in target:
915
+ src = _clone_to_cache(target)
916
+ else:
917
+ src = Path(target)
918
+ if not src.is_dir():
919
+ print(f"安装源不存在: {src}", file=sys.stderr)
920
+ return 1
921
+ try:
922
+ dest = store.install_pack(src, source=target)
923
+ except ValueError as e:
924
+ print(f"安装失败: {e}", file=sys.stderr)
925
+ return 1
926
+ print(f"已安装: {dest.name} → {dest}")
927
+ print(f"来源: {target}(manifest: {store.manifests_dir() / (dest.name + '.json')})")
928
+ return 0
929
+
930
+
931
+ def _clone_to_cache(url: str) -> Path:
932
+ """git clone --depth 1 到 cache 临时目录;调用方负责清理(install 后删除)。"""
933
+ import shutil
934
+ import subprocess
935
+ import tempfile
936
+
937
+ tmp = Path(tempfile.mkdtemp(dir=store.cache_dir()))
938
+ try:
939
+ subprocess.run(
940
+ ["git", "clone", "--depth", "1", url, str(tmp / "src")],
941
+ check=True, capture_output=True,
942
+ )
943
+ return tmp / "src"
944
+ except subprocess.CalledProcessError as e:
945
+ shutil.rmtree(tmp, ignore_errors=True)
946
+ raise ValueError(
947
+ f"git clone 失败: {e.stderr.decode('utf-8', 'replace').strip() or e}"
948
+ ) from e
949
+
950
+
951
+ def _cmd_list(args: argparse.Namespace) -> int:
952
+ """列出全部可用模块(同名多来源全量展示,含优先级)。"""
953
+ mods = store.list_modules()
954
+ if not mods:
955
+ print("无可用模块(搜索路径: cwd/modules + $SPECMODULE_PATH + store/modules + pip)")
956
+ return 0
957
+ rows = []
958
+ for name in sorted(mods):
959
+ for src in mods[name]:
960
+ kind = {"entry": "entry", "packed": "packed", "pip": "pip"}[src.kind]
961
+ rows.append((src.priority, name, src.version, kind, src.description, str(src.path)))
962
+ if args.json:
963
+ print(json.dumps([
964
+ {"name": n, "version": v, "kind": k, "description": d, "path": p}
965
+ for _, n, v, k, d, p in rows
966
+ ], ensure_ascii=False, indent=2))
967
+ return 0
968
+ print(f"{'模块':<20} {'版本':<10} {'形态':<8} 描述")
969
+ for _, name, ver, kind, desc, path in rows:
970
+ print(f"{name:<20} {ver:<10} {kind:<8} {desc or ''}")
971
+ return 0
972
+
973
+
974
+ def _cmd_info(args: argparse.Namespace) -> int:
975
+ """显示模块详情:元数据 + 来源 + 安装信息。"""
976
+ src = store.resolve_module(args.name)
977
+ if src is None:
978
+ print(f"模块 '{args.name}' 未找到——可用: specmodule list 查看全部", file=sys.stderr)
979
+ return 1
980
+ print(f"名称: {src.name}")
981
+ print(f"形态: {src.kind}")
982
+ print(f"描述: {src.description or '—'}")
983
+ print(f"版本: {src.version or '—'}")
984
+ print(f"路径: {src.path}")
985
+ if src.kind in ("packed", "pip"):
986
+ manifest = store.load_manifest(src.name) if src.kind == "packed" else None
987
+ if manifest:
988
+ print(f"来源: {manifest.get('source', '—')}")
989
+ print(f"安装时间: {manifest.get('installed_at', '—')}")
990
+ print(f"文件数: {len(manifest.get('files', {}))}")
991
+ return 0
992
+
993
+
994
+ def _cmd_uninstall(args: argparse.Namespace) -> int:
995
+ """从 store 移除模块(目录 + manifest)。"""
996
+ if not store.uninstall_pack(args.name):
997
+ print(f"模块 '{args.name}' 未安装(store 中不存在)", file=sys.stderr)
998
+ return 1
999
+ print(f"已卸载: {args.name}")
1000
+ return 0
1001
+
1002
+
1003
+ def _cmd_setup(args: argparse.Namespace) -> int:
1004
+ """一次性交互向导:provider/model/key → 写 store 级 .env + config.json。"""
1005
+ import shutil
1006
+
1007
+ from .scaffold import CONFIG_JSON
1008
+
1009
+ home = store.store_home()
1010
+ env_path = home / ".env"
1011
+ config_path = home / "config.json"
1012
+ existing_env = store.parse_dotenv(env_path)
1013
+ existing_cfg = {}
1014
+ if config_path.exists():
1015
+ try:
1016
+ existing_cfg = json.loads(config_path.read_text(encoding="utf-8"))
1017
+ except (ValueError, OSError):
1018
+ pass
1019
+
1020
+ print("SpecModule 配置向导(写入 store 家目录: %s)" % home)
1021
+ print("现有配置:", "有" if (existing_env or existing_cfg) else "无")
1022
+ if existing_env or existing_cfg:
1023
+ answer = input("覆盖既有配置?[y/N] ").strip().lower()
1024
+ if answer not in ("y", "yes"):
1025
+ print("已取消(保留现有配置)")
1026
+ return 0
1027
+
1028
+ provider = input("Provider (openai/anthropic/openai-compatible) [openai]: ").strip() or "openai"
1029
+ model = input("默认模型 [gpt-4o-mini]: ").strip() or "gpt-4o-mini"
1030
+ key_env = input("API key 环境变量名 [OPENAI_API_KEY]: ").strip() or "OPENAI_API_KEY"
1031
+ key = input(f"{key_env} 值: ").strip()
1032
+ base_url = input("Base URL(可选,留空跳过): ").strip() or None
1033
+
1034
+ # 写 .env(追加/更新 key 行)
1035
+ lines = []
1036
+ if env_path.exists():
1037
+ try:
1038
+ lines = env_path.read_text(encoding="utf-8").splitlines()
1039
+ except OSError:
1040
+ pass
1041
+ new_line = f"{key_env}={key}"
1042
+ lines = [ln for ln in lines if not ln.startswith(f"{key_env}=")]
1043
+ lines.append(new_line)
1044
+ env_path.write_text("\n".join(lines) + "\n", encoding="utf-8")
1045
+
1046
+ # 写 config.json(复用 scaffold 的 CONFIG_JSON 结构)
1047
+ cfg = dict(existing_cfg) if not (existing_env or existing_cfg) else {}
1048
+ providers = cfg.get("providers", [])
1049
+ if not providers:
1050
+ providers = [{
1051
+ "name": provider,
1052
+ "sdktype": provider,
1053
+ "api_key_env": key_env,
1054
+ "base_url": base_url,
1055
+ }]
1056
+ cfg["providers"] = providers
1057
+ models = cfg.get("models", [])
1058
+ if not any(m.get("name") == model for m in models):
1059
+ models.append({"name": model, "provider": provider})
1060
+ cfg["models"] = models
1061
+ config_path.write_text(json.dumps(cfg, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
1062
+
1063
+ print(f"已写入: {env_path}")
1064
+ print(f"已写入: {config_path}")
1065
+ print("现在可直接运行: specmodule run --module <名>(无需项目根配置)")
1066
+ return 0
1067
+
1068
+
1069
+ def _cmd_publish(args: argparse.Namespace) -> int:
1070
+ """发布模块到 store:目录形态直接校验复制;单文件形态经等价 SubModule 转化。"""
1071
+ src = Path(args.from_dir)
1072
+ if (src / "module.json").is_file():
1073
+ # 目录形态:与 install 同校验(D9)
1074
+ try:
1075
+ dest = store.install_pack(src, source=args.from_dir, name=args.name)
1076
+ except ValueError as e:
1077
+ print(f"发布失败: {e}", file=sys.stderr)
1078
+ return 1
1079
+ print(f"已发布: {dest.name} → {dest}")
1080
+ return 0
1081
+
1082
+ # 单文件 entry 形态:以 default_template 的 tasklist 驱动等价 SubModule
1083
+ # 导出(D9)。entry 组件注册在 build_registry 闭包内——用 Mock client
1084
+ # 构建 registry 后按 tasklist 引用提取,包体自包含(无闭包依赖)。
1085
+ entry_file = src / "modules" / f"{args.name}.py"
1086
+ if not entry_file.is_file():
1087
+ print(
1088
+ f"发布源无效: {src}(既不是 pack 目录,也没有 modules/{args.name}.py)",
1089
+ file=sys.stderr,
1090
+ )
1091
+ return 1
1092
+ from .builtins import BUILTIN_HARNESS_NAMES
1093
+ from .entry import discover_modules
1094
+ from .spec import SpecSchema
1095
+ from .submodule import SubModule
1096
+
1097
+ entries = discover_modules(src / "modules")
1098
+ entry = entries.get(args.name)
1099
+ if entry is None:
1100
+ print(f"模块 '{args.name}' 未找到({entry_file} 无 entry 声明)", file=sys.stderr)
1101
+ return 1
1102
+ if entry.default_template is None:
1103
+ print(
1104
+ f"单文件形态 publish 失败({args.name}):entry 未声明 default_template。"
1105
+ "请用目录形态:specmodule init <name> --as-dir",
1106
+ file=sys.stderr,
1107
+ )
1108
+ return 1
1109
+ template_name = entry.default_template
1110
+ if template_name not in entry.templates:
1111
+ print(
1112
+ f"单文件形态 publish 失败({args.name}):default_template "
1113
+ f"'{template_name}' 不在 templates 中",
1114
+ file=sys.stderr,
1115
+ )
1116
+ return 1
1117
+
1118
+ # 构建 registry(Mock 占位,零 LLM)→ 提取组件
1119
+ event_bus = EventBus()
1120
+ if entry.build_registry is not None:
1121
+ registry = entry.build_registry(MockLLMClient(), template_name, event_bus)
1122
+ else:
1123
+ registry = HarnessRegistry(llm_client=MockLLMClient(), event_bus=event_bus)
1124
+ template = entry.templates[template_name]
1125
+ tasklist = Tasklist.from_json(template["tasklist"])
1126
+
1127
+ # 按 tasklist 引用提取组件(不含内置 harness)
1128
+ harnesses = []
1129
+ scripts: dict[str, Any] = {}
1130
+ commands = []
1131
+ for key, task in tasklist.tasks.items():
1132
+ if task.type == "harness" and task.harness not in BUILTIN_HARNESS_NAMES:
1133
+ cfg = registry.harness_config(task.harness)
1134
+ if cfg is None:
1135
+ print(
1136
+ f"单文件形态 publish 失败({args.name}):harness "
1137
+ f"'{task.harness}' 未在 registry 注册",
1138
+ file=sys.stderr,
1139
+ )
1140
+ return 1
1141
+ if not any(h.name == cfg.name for h in harnesses):
1142
+ harnesses.append(cfg)
1143
+ elif task.type == "script":
1144
+ fn = registry.get_body(task.script).__wrapped__ \
1145
+ if hasattr(registry.get_body(task.script), "__wrapped__") \
1146
+ else registry.get_body(task.script)
1147
+ scripts.setdefault(task.script, fn)
1148
+ elif task.type == "command":
1149
+ cc = registry.command_config(task.command)
1150
+ if cc is None:
1151
+ print(
1152
+ f"单文件形态 publish 失败({args.name}):command "
1153
+ f"'{task.command}' 未在 registry 注册",
1154
+ file=sys.stderr,
1155
+ )
1156
+ return 1
1157
+ if not any(c.name == cc.name for c in commands):
1158
+ commands.append(cc)
1159
+
1160
+ # 等价 SubModule → pack → install(校验失败诚实报错)
1161
+ sub = SubModule()
1162
+ sub.name = entry.name
1163
+ sub.version = "0.1.0"
1164
+ sub.description = entry.description
1165
+ sub.spec_schema = SpecSchema(
1166
+ input=dict(entry.spec_schema or {}),
1167
+ output={},
1168
+ )
1169
+ sub.tasklist = tasklist
1170
+ sub.harnesses = harnesses
1171
+ sub.commands = commands
1172
+ sub._scripts = scripts
1173
+ sub.guards = [] # guard 函数在闭包内不可静态导出——tasklist 引用 guard 的模块走目录形态
1174
+ sub.modules = entry.submodules
1175
+
1176
+ import tempfile
1177
+
1178
+ tmp = Path(tempfile.mkdtemp())
1179
+ try:
1180
+ out = sub.pack(tmp / "pack")
1181
+ try:
1182
+ dest = store.install_pack(out, source=args.from_dir, name=entry.name)
1183
+ except ValueError as e:
1184
+ print(f"发布失败: {e}", file=sys.stderr)
1185
+ return 1
1186
+ finally:
1187
+ import shutil
1188
+
1189
+ shutil.rmtree(tmp, ignore_errors=True)
1190
+ print(f"已发布(单文件转化): {dest.name} → {dest}")
1191
+ return 0
1192
+
1193
+
1194
+ def _cmd_update(args: argparse.Namespace) -> int:
1195
+ """更新模块:按 manifest 来源重取 → 哈希比对 → 无差异直接替换;
1196
+ 有差异列清单交互确认(--yes 覆盖 / --keep 保留本地)。"""
1197
+ manifest = store.load_manifest(args.name)
1198
+ if manifest is None:
1199
+ print(
1200
+ f"模块 '{args.name}' 未安装或无 manifest——用 specmodule install 安装",
1201
+ file=sys.stderr,
1202
+ )
1203
+ return 1
1204
+ source = manifest.get("source", "")
1205
+ if source.startswith(("http://", "https://")) or source.endswith(".git") or "git@" in source:
1206
+ try:
1207
+ src = _clone_to_cache(source)
1208
+ except ValueError as e:
1209
+ print(f"更新失败: {e}", file=sys.stderr)
1210
+ return 1
1211
+ else:
1212
+ src = Path(source)
1213
+ if not src.is_dir():
1214
+ print(f"更新来源不可用: {src}", file=sys.stderr)
1215
+ return 1
1216
+
1217
+ diff = store.check_updates(args.name, src)
1218
+ src_diff = any(diff[k] for k in ("changed", "added", "removed"))
1219
+ local_diff = any(diff[k] for k in ("local_modified", "untracked"))
1220
+ if not src_diff:
1221
+ # 来源无更新:本地改动保留(不动已装文件),仅提示
1222
+ if local_diff:
1223
+ print(f"来源无更新;本地改动保留({', '.join(diff['local_modified'] or diff['untracked'])})")
1224
+ else:
1225
+ store.apply_update(args.name, src) # 内容一致,刷新 installed_at
1226
+ print(f"已更新: {args.name}(无内容变化)")
1227
+ return 0
1228
+
1229
+ print(f"模块 '{args.name}' 来源检测到差异:")
1230
+ for label, key in (
1231
+ ("变化", "changed"), ("新增", "added"), ("移除", "removed"),
1232
+ ):
1233
+ if diff[key]:
1234
+ print(f" [{label}] " + ", ".join(diff[key]))
1235
+ if local_diff:
1236
+ print("警告:本地有改动(" + ", ".join(
1237
+ diff["local_modified"] + diff["untracked"]
1238
+ ) + "),覆盖将丢失——建议保留(--keep)或先备份。")
1239
+ if args.yes:
1240
+ store.apply_update(args.name, src)
1241
+ print(f"已更新: {args.name}(覆盖差异)")
1242
+ return 0
1243
+ if args.keep:
1244
+ print(f"已跳过: {args.name}(--keep 保留本地)")
1245
+ return 0
1246
+ answer = input("覆盖并更新?[y/N] ").strip().lower()
1247
+ if answer in ("y", "yes"):
1248
+ store.apply_update(args.name, src)
1249
+ print(f"已更新: {args.name}(覆盖差异)")
1250
+ return 0
1251
+ print("已取消(未写入任何文件)")
1252
+ return 0
1253
+
1254
+
1255
+ def _cmd_review(args: argparse.Namespace) -> int:
1256
+ run_id = args.run_id or _latest_run_id()
1257
+ if run_id is None:
1258
+ print("无运行记录(先执行 specmodule run)", file=sys.stderr)
1259
+ return 1
1260
+ timeline = build_timeline(run_id)
1261
+ if timeline is None:
1262
+ print(f"无运行记录: {run_id}(先执行 specmodule run)", file=sys.stderr)
1263
+ return 1
1264
+ if args.failed:
1265
+ timeline = filter_failed(timeline)
1266
+ if args.tick is not None:
1267
+ timeline = filter_tick(timeline, args.tick)
1268
+ if args.node:
1269
+ timeline = filter_node(timeline, args.node)
1270
+ if args.json:
1271
+ print(json.dumps(timeline_to_dict(timeline), ensure_ascii=False, indent=2))
1272
+ return 0
1273
+ _render_timeline(timeline, show_outputs=args.tick is not None or bool(args.node))
1274
+ return 0
1275
+
1276
+
1277
+ def _add_continue_args(p: argparse.ArgumentParser) -> None:
1278
+ """resume/rollback 共享参数:接线与 run 一致(spec/模板/verbose/mock),
1279
+
1280
+ 仅回退目标位置参数与默认语义不同(resume 缺省续最新,rollback 必填)。
1281
+ """
1282
+ p.add_argument(
1283
+ "--module", required=True, help="模块名(modules/ 中发现;须与先前 run 一致)"
1284
+ )
1285
+ p.add_argument(
1286
+ "--modules-dir", default="modules",
1287
+ help="模块目录(默认 modules/,cwd 相对)",
1288
+ )
1289
+ p.add_argument(
1290
+ "--spec", help="内联 JSON spec(重建未执行部分;缺省 entry.default_spec)"
1291
+ )
1292
+ p.add_argument("--spec-file", help="spec JSON 文件路径")
1293
+ p.add_argument("--template", help="模板名(默认 entry.default_template)")
1294
+ p.add_argument(
1295
+ "--tasklist", help="tasklist JSON 文件路径(跳过翻译,与 --template 互斥)"
1296
+ )
1297
+ p.add_argument(
1298
+ "--run-id", help="运行目录名(默认模块名;须与先前 run 的 run-id 一致)"
1299
+ )
1300
+ p.add_argument(
1301
+ "--verbose", type=int, choices=(1, 2, 3), default=1,
1302
+ help="实时显示级别:1=tick+节点+状态(默认),2=+产出预览,3=完整详情块",
1303
+ )
1304
+ p.add_argument(
1305
+ "--max-ticks", type=int, default=100,
1306
+ help="tick 上限(恢复后从回退 tick 起计的绝对上限,默认 100)",
1307
+ )
1308
+ p.add_argument("--mock", action="store_true", help="免 key 假 LLM 冒烟(测试/演示)")
1309
+
1310
+
1311
+ def main(argv: list[str] | None = None) -> int:
1312
+ parser = argparse.ArgumentParser(
1313
+ prog="specmodule",
1314
+ description="SpecModule CLI——选择模块、传入 spec/tasklist、观察与审阅运行",
1315
+ )
1316
+ sub = parser.add_subparsers(dest="command", required=True)
1317
+
1318
+ p_run = sub.add_parser("run", help="运行模块")
1319
+ p_run.add_argument("--module", required=True, help="模块名(modules/ 目录中发现)")
1320
+ p_run.add_argument(
1321
+ "--modules-dir", default="modules",
1322
+ help="模块目录(默认 modules/,cwd 相对;未来 init 实例布局即此目录)",
1323
+ )
1324
+ p_run.add_argument("--spec", help="内联 JSON spec")
1325
+ p_run.add_argument("--spec-file", help="spec JSON 文件路径")
1326
+ p_run.add_argument("--template", help="模板名(默认 entry.default_template)")
1327
+ p_run.add_argument(
1328
+ "--tasklist", help="tasklist JSON 文件路径(跳过翻译,与 --template 互斥)"
1329
+ )
1330
+ p_run.add_argument("--run-id", help="运行目录名(默认模块名)")
1331
+ p_run.add_argument(
1332
+ "--verbose", type=int, choices=(1, 2, 3), default=1,
1333
+ help="实时显示级别:1=tick+节点+状态(默认),2=+产出预览,3=完整详情块",
1334
+ )
1335
+ p_run.add_argument("--max-ticks", type=int, default=100, help="tick 上限(默认 100)")
1336
+ p_run.add_argument("--mock", action="store_true", help="免 key 假 LLM 冒烟(测试/演示)")
1337
+ p_run.set_defaults(func=_cmd_run)
1338
+
1339
+ p_resume = sub.add_parser(
1340
+ "resume", help="从中断处续跑模块(tick 截断 / Ctrl+C 后,缺省续最新)"
1341
+ )
1342
+ p_resume.add_argument(
1343
+ "rollback", nargs="?",
1344
+ help="回退目标:tick 号或 manual:<label>(缺省 = 最新 tick 快照)",
1345
+ )
1346
+ _add_continue_args(p_resume)
1347
+ p_resume.set_defaults(func=_cmd_resume)
1348
+
1349
+ p_rollback = sub.add_parser(
1350
+ "rollback", help="回退到指定 tick/manual 检查点并重跑(目标必填)"
1351
+ )
1352
+ p_rollback.add_argument(
1353
+ "rollback",
1354
+ help="回退目标:tick 号或 manual:<label>(必填;specmodule checkpoints 查看可用目标)",
1355
+ )
1356
+ _add_continue_args(p_rollback)
1357
+ p_rollback.set_defaults(func=_cmd_rollback)
1358
+
1359
+ p_status = sub.add_parser("status", help="查询运行状态")
1360
+ p_status.add_argument("--run-id", help="运行 id(默认最近运行)")
1361
+ p_status.add_argument("--json", action="store_true", help="JSON 输出")
1362
+ p_status.set_defaults(func=_cmd_status)
1363
+
1364
+ p_review = sub.add_parser("review", help="审阅历史时间线")
1365
+ p_review.add_argument("--run-id", help="运行 id(默认最近运行)")
1366
+ p_review.add_argument("--tick", type=int, help="只看指定 tick")
1367
+ p_review.add_argument("--node", help="只看指定节点")
1368
+ p_review.add_argument("--failed", action="store_true", help="只看失败节点")
1369
+ p_review.add_argument("--json", action="store_true", help="JSON 输出")
1370
+ p_review.set_defaults(func=_cmd_review)
1371
+
1372
+ p_checkpoints = sub.add_parser(
1373
+ "checkpoints", help="列出可用回退点(tick 快照 + manual 检查点)"
1374
+ )
1375
+ p_checkpoints.add_argument("--run-id", help="运行 id(默认最近运行)")
1376
+ p_checkpoints.add_argument("--json", action="store_true", help="JSON 输出")
1377
+ p_checkpoints.set_defaults(func=_cmd_checkpoints)
1378
+
1379
+ p_snapshot = sub.add_parser("snapshot", help="检视/导出指定 tick 的运行时快照")
1380
+ p_snapshot.add_argument(
1381
+ "tick", nargs="?", type=int, help="快照 tick(缺省 = 最新)"
1382
+ )
1383
+ p_snapshot.add_argument("--run-id", help="运行 id(默认最近运行)")
1384
+ p_snapshot.add_argument("--json", action="store_true", help="stdout 打印完整快照 JSON")
1385
+ p_snapshot.add_argument(
1386
+ "--out", help="写完整快照 JSON 到文件(自包含,可 restore 到新 runner)"
1387
+ )
1388
+ p_snapshot.set_defaults(func=_cmd_snapshot)
1389
+
1390
+ p_checkpoint = sub.add_parser(
1391
+ "checkpoint", help="给指定 tick 快照起命名检查点(manual: 永久保留)"
1392
+ )
1393
+ p_checkpoint.add_argument("label", help="检查点标签(自动补 manual: 前缀)")
1394
+ p_checkpoint.add_argument(
1395
+ "tick", nargs="?", type=int, help="快照 tick(缺省 = 最新)"
1396
+ )
1397
+ p_checkpoint.add_argument("--run-id", help="运行 id(默认最近运行)")
1398
+ p_checkpoint.set_defaults(func=_cmd_checkpoint)
1399
+
1400
+ p_visualize = sub.add_parser(
1401
+ "visualize", help="渲染 tasklist 对应图(mermaid 导出)"
1402
+ )
1403
+ p_visualize.add_argument(
1404
+ "--module", required=True,
1405
+ help="模块名(modules/ 中发现;构建 registry 校验 graph)",
1406
+ )
1407
+ p_visualize.add_argument(
1408
+ "--modules-dir", default="modules",
1409
+ help="模块目录(默认 modules/,cwd 相对)",
1410
+ )
1411
+ p_visualize.add_argument(
1412
+ "--tasklist", help="tasklist JSON 文件(直接渲染,不依赖运行记录)"
1413
+ )
1414
+ p_visualize.add_argument(
1415
+ "--template", help="模板名(默认 entry.default_template;仅影响 registry 构建)"
1416
+ )
1417
+ p_visualize.add_argument("--run-id", help="运行 id(缺省 = 模块同名运行目录;与 --tasklist 互斥)")
1418
+ p_visualize.add_argument(
1419
+ "--out", help="写 mermaid 文本到文件(缺省打印 stdout)"
1420
+ )
1421
+ p_visualize.set_defaults(func=_cmd_visualize)
1422
+
1423
+ p_init = sub.add_parser("init", help="生成模块开发脚手架(单文件模块 + 项目文件)")
1424
+ p_init.add_argument("name", help="模块名(合法 Python 标识符;同时是文件/--module/entry.name/run_id)")
1425
+ p_init.add_argument("--dir", default=".", help="生成位置(默认 cwd)")
1426
+ p_init.add_argument(
1427
+ "--as-dir", action="store_true",
1428
+ help="目录形态(modules/<name>/ pack 同构骨架;默认单文件 modules/<name>.py)",
1429
+ )
1430
+ p_init.add_argument("--force", action="store_true", help="覆盖已存在的模块文件(仅模块文件)")
1431
+ p_init.add_argument("--description", help="模块描述(展示用,不受标识符约束)")
1432
+ p_init.set_defaults(func=_cmd_init)
1433
+
1434
+ p_feed = sub.add_parser(
1435
+ "feed", help="启动零依赖运行 feed(http.server,浏览器轮询查看)"
1436
+ )
1437
+ p_feed.add_argument("--host", default="127.0.0.1", help="监听地址(默认 127.0.0.1)")
1438
+ p_feed.add_argument("--port", type=int, default=8000, help="监听端口(默认 8000)")
1439
+ p_feed.add_argument("--run-id", help="运行 id(缺省 = 最近运行)")
1440
+ p_feed.set_defaults(func=_cmd_feed)
1441
+
1442
+ p_list = sub.add_parser("list", help="列出全部可用模块(同名多来源全量展示)")
1443
+ p_list.add_argument("--json", action="store_true", help="JSON 输出")
1444
+ p_list.set_defaults(func=_cmd_list)
1445
+
1446
+ p_info = sub.add_parser("info", help="显示模块详情(元数据 + 来源 + 安装信息)")
1447
+ p_info.add_argument("name", help="模块名")
1448
+ p_info.set_defaults(func=_cmd_info)
1449
+
1450
+ p_install = sub.add_parser(
1451
+ "install", help="安装模块到 store(本地 pack 目录或 git URL,校验零落盘)"
1452
+ )
1453
+ p_install.add_argument("source", help="本地 pack 目录路径或 git URL")
1454
+ p_install.set_defaults(func=_cmd_install)
1455
+
1456
+ p_uninstall = sub.add_parser("uninstall", help="从 store 移除模块(目录 + manifest)")
1457
+ p_uninstall.add_argument("name", help="模块名")
1458
+ p_uninstall.set_defaults(func=_cmd_uninstall)
1459
+
1460
+ p_setup = sub.add_parser(
1461
+ "setup", help="一次性配置向导:provider/model/key → 写 store 级配置"
1462
+ )
1463
+ p_setup.set_defaults(func=_cmd_setup)
1464
+
1465
+ p_publish = sub.add_parser(
1466
+ "publish", help="发布模块到 store(目录形态校验复制;单文件形态暂不支持)"
1467
+ )
1468
+ p_publish.add_argument("name", help="模块名")
1469
+ p_publish.add_argument("--from", dest="from_dir", default=".", help="发布源目录(默认 cwd)")
1470
+ p_publish.set_defaults(func=_cmd_publish)
1471
+
1472
+ p_update = sub.add_parser(
1473
+ "update", help="更新模块(manifest 脏检测;本地改动列清单交互确认)"
1474
+ )
1475
+ p_update.add_argument("name", help="模块名")
1476
+ p_update.add_argument("--yes", action="store_true", help="非交互:有差异直接覆盖")
1477
+ p_update.add_argument("--keep", action="store_true", help="非交互:有差异保留本地不更新")
1478
+ p_update.set_defaults(func=_cmd_update)
1479
+
1480
+ args = parser.parse_args(argv)
1481
+ try:
1482
+ return int(args.func(args))
1483
+ except KeyboardInterrupt:
1484
+ print(
1485
+ "\n已中断——运行数据已落盘 .specmodule/runs/(specmodule status/review 可查)",
1486
+ file=sys.stderr,
1487
+ )
1488
+ return 2
1489
+
1490
+
1491
+ if __name__ == "__main__":
1492
+ sys.exit(main())