norn-cli 0.7.1__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.
@@ -0,0 +1,6 @@
1
+ """Norn package implementation."""
2
+
3
+ from .config import HarnessConfig, load_config
4
+
5
+ __all__ = ["HarnessConfig", "load_config"]
6
+ __version__ = "0.7.1"
norf_harness/cli.py ADDED
@@ -0,0 +1,303 @@
1
+ """Command-line interface for Norn."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import asyncio
7
+ import json
8
+ import shutil
9
+ import sys
10
+ from pathlib import Path
11
+
12
+ import yaml
13
+
14
+ from . import __version__
15
+ from .config import ConfigError, default_config_path, load_config, project_config_path
16
+ from .doctor import checks_as_dict, doctor_exit_code, run_doctor
17
+ from .models import ExitCode, RunMode, RunStatus, StopReason
18
+ from .pipeline import execute_pipeline
19
+ from .providers import default_registry
20
+ from .reporting import read_report, write_reports
21
+ from .run_store import RunStore, RunStoreError
22
+ from .skill_install import SkillInstallError, install_skills
23
+
24
+
25
+ def build_parser() -> argparse.ArgumentParser:
26
+ parser = argparse.ArgumentParser(prog="norn")
27
+ parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
28
+ subparsers = parser.add_subparsers(dest="command", required=True)
29
+
30
+ init_parser = subparsers.add_parser("init", help="create project harness configuration")
31
+ _project_option(init_parser)
32
+ init_parser.add_argument("--force", action="store_true")
33
+
34
+ skill_parser = subparsers.add_parser(
35
+ "install-skill", help="install the bundled Codex and/or Claude skill"
36
+ )
37
+ _project_option(skill_parser)
38
+ skill_parser.add_argument("--target", choices=("codex", "claude", "all"), default="all")
39
+ skill_parser.add_argument("--scope", choices=("project", "user"), default="project")
40
+ skill_parser.add_argument("--force", action="store_true")
41
+
42
+ doctor_parser = subparsers.add_parser("doctor", help="check local harness prerequisites")
43
+ _common_options(doctor_parser)
44
+ doctor_parser.add_argument("--json", action="store_true", dest="json_output")
45
+
46
+ validate_parser = subparsers.add_parser("validate-config", help="validate merged config")
47
+ _common_options(validate_parser)
48
+ validate_parser.add_argument("--json", action="store_true", dest="json_output")
49
+
50
+ providers_parser = subparsers.add_parser(
51
+ "providers", help="show provider profiles without invoking models"
52
+ )
53
+ _common_options(providers_parser)
54
+ providers_parser.add_argument("--json", action="store_true", dest="json_output")
55
+
56
+ for name in ("plan", "run"):
57
+ execution = subparsers.add_parser(name, help=f"start a {name} lifecycle")
58
+ execution.add_argument("request")
59
+ _common_options(execution)
60
+ _limit_options(execution)
61
+
62
+ for name in ("status", "recover", "report"):
63
+ command = subparsers.add_parser(name)
64
+ command.add_argument("run_id")
65
+ _common_options(command)
66
+ if name in {"status", "report"}:
67
+ command.add_argument("--json", action="store_true", dest="json_output")
68
+ if name == "report":
69
+ command.add_argument("--escalation", action="store_true")
70
+
71
+ return parser
72
+
73
+
74
+ def _project_option(parser: argparse.ArgumentParser) -> None:
75
+ parser.add_argument("--project", type=Path, default=Path.cwd())
76
+
77
+
78
+ def _common_options(parser: argparse.ArgumentParser) -> None:
79
+ _project_option(parser)
80
+ parser.add_argument("--config", type=Path)
81
+
82
+
83
+ def _limit_options(parser: argparse.ArgumentParser) -> None:
84
+ parser.add_argument("--max-rounds", type=int)
85
+ parser.add_argument("--max-total-rounds", type=int)
86
+ parser.add_argument("--max-planner-retries", type=int)
87
+ parser.add_argument("--max-final-replans", type=int)
88
+
89
+
90
+ def main(argv: list[str] | None = None) -> int:
91
+ args = build_parser().parse_args(argv)
92
+ try:
93
+ if args.command == "init":
94
+ return _init(args)
95
+ if args.command == "install-skill":
96
+ return _install_skill(args)
97
+ config = load_config(
98
+ args.project,
99
+ config_path=args.config,
100
+ overrides=_overrides(args),
101
+ )
102
+ if args.command == "doctor":
103
+ return _doctor(config, args.json_output)
104
+ if args.command == "validate-config":
105
+ return _validate(config, args.json_output)
106
+ if args.command == "providers":
107
+ return _providers(config, args.json_output)
108
+ if args.command in {"plan", "run"}:
109
+ return _execution(config, args.request, RunMode(args.command))
110
+ if args.command == "status":
111
+ return _status(config, args.run_id, args.json_output)
112
+ if args.command == "recover":
113
+ return _recover(config, args.run_id)
114
+ if args.command == "report":
115
+ return _report(config, args.run_id, args.escalation, args.json_output)
116
+ except ConfigError as exc:
117
+ print(f"CONFIG ERROR: {exc}", file=sys.stderr)
118
+ return int(ExitCode.CONFIG_ERROR)
119
+ except SkillInstallError as exc:
120
+ print(f"SKILL ERROR: {exc}", file=sys.stderr)
121
+ return int(ExitCode.CONFIG_ERROR)
122
+ except (RunStoreError, FileNotFoundError) as exc:
123
+ print(f"RUN ERROR: {exc}", file=sys.stderr)
124
+ return int(ExitCode.UNEXPECTED_FAILURE)
125
+ return int(ExitCode.UNEXPECTED_FAILURE)
126
+
127
+
128
+ def _init(args: argparse.Namespace) -> int:
129
+ root = args.project.expanduser().resolve()
130
+ target = project_config_path(root)
131
+ if target.exists() and not args.force:
132
+ print(f"Config already exists: {target}", file=sys.stderr)
133
+ return int(ExitCode.CONFIG_ERROR)
134
+ target.parent.mkdir(parents=True, exist_ok=True)
135
+ defaults = yaml.safe_load(default_config_path().read_text(encoding="utf-8"))
136
+ defaults["paths"]["runs_dir"] = ".norf/runs"
137
+ target.write_text(
138
+ yaml.safe_dump(defaults, sort_keys=False, allow_unicode=True), encoding="utf-8"
139
+ )
140
+ _ensure_gitignore(root)
141
+ print(f"Created {target}")
142
+ return int(ExitCode.SUCCESS)
143
+
144
+
145
+ def _install_skill(args: argparse.Namespace) -> int:
146
+ installed = install_skills(
147
+ args.project,
148
+ target=args.target,
149
+ scope=args.scope,
150
+ force=args.force,
151
+ )
152
+ for item in installed:
153
+ print(f"Installed {item.target} skill: {item.path}")
154
+ return int(ExitCode.SUCCESS)
155
+
156
+
157
+ def _ensure_gitignore(root: Path) -> None:
158
+ path = root / ".gitignore"
159
+ markers = (".norf/runs/", ".norf/worktrees/", ".norf/e2e-evidence/")
160
+ existing = path.read_text(encoding="utf-8") if path.exists() else ""
161
+ current = set(existing.splitlines())
162
+ missing = [marker for marker in markers if marker not in current]
163
+ if missing:
164
+ prefix = existing
165
+ if prefix and not prefix.endswith("\n"):
166
+ prefix += "\n"
167
+ path.write_text(prefix + "\n".join(missing) + "\n", encoding="utf-8")
168
+
169
+
170
+ def _doctor(config, json_output: bool) -> int:
171
+ checks = run_doctor(config)
172
+ if json_output:
173
+ print(json.dumps(checks_as_dict(checks), ensure_ascii=False, indent=2))
174
+ else:
175
+ for check in checks:
176
+ print(f"[{check.status:<4}] {check.name}: {check.detail}")
177
+ return doctor_exit_code(checks)
178
+
179
+
180
+ def _validate(config, json_output: bool) -> int:
181
+ if json_output:
182
+ print(json.dumps(config.to_dict(), ensure_ascii=False, indent=2))
183
+ else:
184
+ print("Config valid")
185
+ print(f"Project: {config.project_root}")
186
+ print(f"Runs: {config.runs_dir}")
187
+ print("Sources:")
188
+ for source in config.sources:
189
+ print(f" - {source}")
190
+ return int(ExitCode.SUCCESS)
191
+
192
+
193
+ def _providers(config, json_output: bool) -> int:
194
+ registered = default_registry()
195
+ rows: list[dict[str, object]] = []
196
+ failed = False
197
+ for profile_name, profile in config.profiles.items():
198
+ for index, candidate in enumerate(profile.candidates):
199
+ provider = config.providers[candidate.provider]
200
+ command = shutil.which(provider.command)
201
+ adapter_registered = provider.adapter in registered
202
+ available = command is not None and adapter_registered
203
+ failed = failed or (index == 0 and not available and len(profile.candidates) == 1)
204
+ rows.append(
205
+ {
206
+ "profile": profile_name,
207
+ "priority": index + 1,
208
+ "provider": provider.name,
209
+ "adapter": provider.adapter,
210
+ "model": candidate.model,
211
+ "model_id": candidate.resolved_model,
212
+ "effort": candidate.effort,
213
+ "command": command or provider.command,
214
+ "available": available,
215
+ }
216
+ )
217
+ if json_output:
218
+ print(json.dumps(rows, ensure_ascii=False, indent=2))
219
+ else:
220
+ for row in rows:
221
+ marker = "PASS" if row["available"] else "WARN"
222
+ print(
223
+ f"[{marker}] {row['profile']}#{row['priority']} "
224
+ f"{row['provider']}/{row['model']}->{row['model_id']}/{row['effort']} "
225
+ f"adapter={row['adapter']}"
226
+ )
227
+ return 1 if failed else 0
228
+
229
+
230
+ def _execution(config, request: str, mode: RunMode) -> int:
231
+ outcome = asyncio.run(execute_pipeline(config, request, mode))
232
+ manifest = outcome.manifest
233
+ if manifest.exit_code == 0:
234
+ print(f"Harness succeeded: {outcome.paths.final_report}")
235
+ else:
236
+ print(f"Harness stopped: {manifest.stop_reason}", file=sys.stderr)
237
+ print(f"Exit code: {manifest.exit_code}", file=sys.stderr)
238
+ print(f"Escalation report: {outcome.paths.escalation_report}", file=sys.stderr)
239
+ print(f"Run ID: {manifest.run_id}", file=sys.stderr)
240
+ return outcome.exit_code
241
+
242
+
243
+ def _status(config, run_id: str, json_output: bool) -> int:
244
+ manifest = RunStore(config).load_manifest(run_id)
245
+ if json_output:
246
+ print(json.dumps(manifest.to_dict(), ensure_ascii=False, indent=2))
247
+ else:
248
+ print(f"Run: {manifest.run_id}")
249
+ print(f"Status: {manifest.status}")
250
+ print(f"Phase: {manifest.phase}")
251
+ print(f"Exit code: {manifest.exit_code}")
252
+ print(f"Stop reason: {manifest.stop_reason or ''}")
253
+ return int(manifest.exit_code or 0)
254
+
255
+
256
+ def _recover(config, run_id: str) -> int:
257
+ store = RunStore(config)
258
+ manifest = store.load_manifest(run_id)
259
+ if manifest.status in {RunStatus.CREATED, RunStatus.RUNNING}:
260
+ store.escalate(
261
+ manifest,
262
+ reason=StopReason.UNEXPECTED_FAILURE,
263
+ exit_code=ExitCode.UNEXPECTED_FAILURE,
264
+ detail="前回プロセスが完了記録を残さず停止しました。イベントから判断文書を再構築しました。",
265
+ decisions=["直近イベントとworktreeを確認し、再実行または手動継続を判断する"],
266
+ )
267
+ paths = write_reports(store, manifest)
268
+ target = (
269
+ paths.escalation_report if manifest.status != RunStatus.SUCCEEDED else paths.final_report
270
+ )
271
+ print(f"Recovered report: {target}")
272
+ return int(manifest.exit_code or 0)
273
+
274
+
275
+ def _report(config, run_id: str, escalation: bool, json_output: bool) -> int:
276
+ store = RunStore(config)
277
+ manifest = store.load_manifest(run_id)
278
+ paths = write_reports(store, manifest)
279
+ if json_output:
280
+ path = paths.escalation_json if escalation else paths.manifest
281
+ else:
282
+ path = paths.escalation_report if escalation else paths.final_report
283
+ print(read_report(path), end="")
284
+ return int(manifest.exit_code or 0)
285
+
286
+
287
+ def _overrides(args: argparse.Namespace) -> dict[str, int]:
288
+ mapping = {
289
+ "max_rounds": "limits.implementation.max_rounds",
290
+ "max_total_rounds": "limits.pipeline.max_total_rounds",
291
+ "max_planner_retries": "limits.planning.max_retries",
292
+ "max_final_replans": "limits.final_gate.max_replans",
293
+ }
294
+ result: dict[str, int] = {}
295
+ for attribute, dotted in mapping.items():
296
+ value = getattr(args, attribute, None)
297
+ if value is not None:
298
+ result[dotted] = value
299
+ return result
300
+
301
+
302
+ if __name__ == "__main__":
303
+ raise SystemExit(main())