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.
- norf_harness/__init__.py +6 -0
- norf_harness/cli.py +303 -0
- norf_harness/config.py +398 -0
- norf_harness/doctor.py +103 -0
- norf_harness/e2e_evidence.py +163 -0
- norf_harness/gate.py +140 -0
- norf_harness/models.py +93 -0
- norf_harness/pipeline.py +841 -0
- norf_harness/project_gates.py +225 -0
- norf_harness/providers/__init__.py +32 -0
- norf_harness/providers/base.py +395 -0
- norf_harness/providers/claude.py +127 -0
- norf_harness/providers/codex.py +145 -0
- norf_harness/providers/command.py +118 -0
- norf_harness/providers/grok.py +140 -0
- norf_harness/providers/registry.py +21 -0
- norf_harness/providers/router.py +137 -0
- norf_harness/reporting.py +285 -0
- norf_harness/resources/default.yaml +124 -0
- norf_harness/resources/skills/norn/SKILL.md +74 -0
- norf_harness/resources/skills/norn/agents/openai.yaml +7 -0
- norf_harness/resources/skills/norn/references/cli.md +123 -0
- norf_harness/run_store.py +205 -0
- norf_harness/skill_install.py +116 -0
- norf_harness/worktree.py +109 -0
- norn_cli-0.7.1.dist-info/METADATA +149 -0
- norn_cli-0.7.1.dist-info/RECORD +31 -0
- norn_cli-0.7.1.dist-info/WHEEL +5 -0
- norn_cli-0.7.1.dist-info/entry_points.txt +3 -0
- norn_cli-0.7.1.dist-info/licenses/LICENSE +201 -0
- norn_cli-0.7.1.dist-info/top_level.txt +1 -0
norf_harness/__init__.py
ADDED
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())
|