norn-cli 0.7.1__tar.gz → 0.7.2__tar.gz
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.
- {norn_cli-0.7.1 → norn_cli-0.7.2}/PKG-INFO +13 -2
- {norn_cli-0.7.1 → norn_cli-0.7.2}/README.md +12 -1
- {norn_cli-0.7.1 → norn_cli-0.7.2}/pyproject.toml +1 -1
- {norn_cli-0.7.1 → norn_cli-0.7.2}/src/norf_harness/__init__.py +1 -1
- {norn_cli-0.7.1 → norn_cli-0.7.2}/src/norf_harness/cli.py +23 -8
- {norn_cli-0.7.1 → norn_cli-0.7.2}/src/norf_harness/pipeline.py +125 -4
- {norn_cli-0.7.1 → norn_cli-0.7.2}/src/norf_harness/resources/skills/norn/SKILL.md +6 -0
- {norn_cli-0.7.1 → norn_cli-0.7.2}/src/norf_harness/resources/skills/norn/references/cli.md +7 -0
- {norn_cli-0.7.1 → norn_cli-0.7.2}/src/norn_cli.egg-info/PKG-INFO +13 -2
- {norn_cli-0.7.1 → norn_cli-0.7.2}/tests/test_cli.py +40 -3
- {norn_cli-0.7.1 → norn_cli-0.7.2}/tests/test_pipeline.py +110 -0
- {norn_cli-0.7.1 → norn_cli-0.7.2}/tests/test_skill_install.py +1 -0
- {norn_cli-0.7.1 → norn_cli-0.7.2}/LICENSE +0 -0
- {norn_cli-0.7.1 → norn_cli-0.7.2}/setup.cfg +0 -0
- {norn_cli-0.7.1 → norn_cli-0.7.2}/src/norf_harness/config.py +0 -0
- {norn_cli-0.7.1 → norn_cli-0.7.2}/src/norf_harness/doctor.py +0 -0
- {norn_cli-0.7.1 → norn_cli-0.7.2}/src/norf_harness/e2e_evidence.py +0 -0
- {norn_cli-0.7.1 → norn_cli-0.7.2}/src/norf_harness/gate.py +0 -0
- {norn_cli-0.7.1 → norn_cli-0.7.2}/src/norf_harness/models.py +0 -0
- {norn_cli-0.7.1 → norn_cli-0.7.2}/src/norf_harness/project_gates.py +0 -0
- {norn_cli-0.7.1 → norn_cli-0.7.2}/src/norf_harness/providers/__init__.py +0 -0
- {norn_cli-0.7.1 → norn_cli-0.7.2}/src/norf_harness/providers/base.py +0 -0
- {norn_cli-0.7.1 → norn_cli-0.7.2}/src/norf_harness/providers/claude.py +0 -0
- {norn_cli-0.7.1 → norn_cli-0.7.2}/src/norf_harness/providers/codex.py +0 -0
- {norn_cli-0.7.1 → norn_cli-0.7.2}/src/norf_harness/providers/command.py +0 -0
- {norn_cli-0.7.1 → norn_cli-0.7.2}/src/norf_harness/providers/grok.py +0 -0
- {norn_cli-0.7.1 → norn_cli-0.7.2}/src/norf_harness/providers/registry.py +0 -0
- {norn_cli-0.7.1 → norn_cli-0.7.2}/src/norf_harness/providers/router.py +0 -0
- {norn_cli-0.7.1 → norn_cli-0.7.2}/src/norf_harness/reporting.py +0 -0
- {norn_cli-0.7.1 → norn_cli-0.7.2}/src/norf_harness/resources/default.yaml +0 -0
- {norn_cli-0.7.1 → norn_cli-0.7.2}/src/norf_harness/resources/skills/norn/agents/openai.yaml +0 -0
- {norn_cli-0.7.1 → norn_cli-0.7.2}/src/norf_harness/run_store.py +0 -0
- {norn_cli-0.7.1 → norn_cli-0.7.2}/src/norf_harness/skill_install.py +0 -0
- {norn_cli-0.7.1 → norn_cli-0.7.2}/src/norf_harness/worktree.py +0 -0
- {norn_cli-0.7.1 → norn_cli-0.7.2}/src/norn_cli.egg-info/SOURCES.txt +0 -0
- {norn_cli-0.7.1 → norn_cli-0.7.2}/src/norn_cli.egg-info/dependency_links.txt +0 -0
- {norn_cli-0.7.1 → norn_cli-0.7.2}/src/norn_cli.egg-info/entry_points.txt +0 -0
- {norn_cli-0.7.1 → norn_cli-0.7.2}/src/norn_cli.egg-info/requires.txt +0 -0
- {norn_cli-0.7.1 → norn_cli-0.7.2}/src/norn_cli.egg-info/top_level.txt +0 -0
- {norn_cli-0.7.1 → norn_cli-0.7.2}/tests/test_config.py +0 -0
- {norn_cli-0.7.1 → norn_cli-0.7.2}/tests/test_e2e_evidence.py +0 -0
- {norn_cli-0.7.1 → norn_cli-0.7.2}/tests/test_gate.py +0 -0
- {norn_cli-0.7.1 → norn_cli-0.7.2}/tests/test_project_gates.py +0 -0
- {norn_cli-0.7.1 → norn_cli-0.7.2}/tests/test_providers.py +0 -0
- {norn_cli-0.7.1 → norn_cli-0.7.2}/tests/test_run_store.py +0 -0
- {norn_cli-0.7.1 → norn_cli-0.7.2}/tests/test_worktree.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: norn-cli
|
|
3
|
-
Version: 0.7.
|
|
3
|
+
Version: 0.7.2
|
|
4
4
|
Summary: Norn: a provider-neutral multi-agent development harness
|
|
5
5
|
License-Expression: Apache-2.0
|
|
6
6
|
Project-URL: Repository, https://github.com/uruca-kk/norn
|
|
@@ -70,6 +70,17 @@ assigned evidence directory. The harness rejects missing, stale, symlinked, or
|
|
|
70
70
|
out-of-directory evidence and archives accepted files under
|
|
71
71
|
`.norf/runs/<run-id>/artifacts/e2e/`.
|
|
72
72
|
|
|
73
|
+
To implement a previously approved plan without invoking orchestration, planning,
|
|
74
|
+
or plan review again, pass the successful `plan` run ID with the identical request:
|
|
75
|
+
|
|
76
|
+
```bash
|
|
77
|
+
norn plan "add an audit log"
|
|
78
|
+
norn run "add an audit log" --from-plan <plan-run-id>
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
Norn verifies the source run mode and status, project root, request text, and saved
|
|
82
|
+
artifact schemas before creating the implementation worktree.
|
|
83
|
+
|
|
73
84
|
For Codex tester roles, the adapter keeps `workspace-write` filesystem isolation
|
|
74
85
|
and enables command networking through a loopback-only proxy allowlist
|
|
75
86
|
(`localhost` and `127.0.0.1`). Planning and implementation roles retain their
|
|
@@ -124,7 +135,7 @@ ruff format --check .
|
|
|
124
135
|
ruff check .
|
|
125
136
|
python3 -m unittest discover -v
|
|
126
137
|
python3 -m build
|
|
127
|
-
python3 scripts/package_smoke.py dist/norn_cli-0.7.
|
|
138
|
+
python3 scripts/package_smoke.py dist/norn_cli-0.7.2-py3-none-any.whl
|
|
128
139
|
```
|
|
129
140
|
|
|
130
141
|
CI runs the quality suite on Python 3.12, 3.13, and 3.14, then installs the
|
|
@@ -45,6 +45,17 @@ assigned evidence directory. The harness rejects missing, stale, symlinked, or
|
|
|
45
45
|
out-of-directory evidence and archives accepted files under
|
|
46
46
|
`.norf/runs/<run-id>/artifacts/e2e/`.
|
|
47
47
|
|
|
48
|
+
To implement a previously approved plan without invoking orchestration, planning,
|
|
49
|
+
or plan review again, pass the successful `plan` run ID with the identical request:
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
norn plan "add an audit log"
|
|
53
|
+
norn run "add an audit log" --from-plan <plan-run-id>
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Norn verifies the source run mode and status, project root, request text, and saved
|
|
57
|
+
artifact schemas before creating the implementation worktree.
|
|
58
|
+
|
|
48
59
|
For Codex tester roles, the adapter keeps `workspace-write` filesystem isolation
|
|
49
60
|
and enables command networking through a loopback-only proxy allowlist
|
|
50
61
|
(`localhost` and `127.0.0.1`). Planning and implementation roles retain their
|
|
@@ -99,7 +110,7 @@ ruff format --check .
|
|
|
99
110
|
ruff check .
|
|
100
111
|
python3 -m unittest discover -v
|
|
101
112
|
python3 -m build
|
|
102
|
-
python3 scripts/package_smoke.py dist/norn_cli-0.7.
|
|
113
|
+
python3 scripts/package_smoke.py dist/norn_cli-0.7.2-py3-none-any.whl
|
|
103
114
|
```
|
|
104
115
|
|
|
105
116
|
CI runs the quality suite on Python 3.12, 3.13, and 3.14, then installs the
|
|
@@ -53,11 +53,21 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
53
53
|
_common_options(providers_parser)
|
|
54
54
|
providers_parser.add_argument("--json", action="store_true", dest="json_output")
|
|
55
55
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
56
|
+
plan_parser = subparsers.add_parser("plan", help="start a plan lifecycle")
|
|
57
|
+
plan_parser.add_argument("request")
|
|
58
|
+
_common_options(plan_parser)
|
|
59
|
+
_limit_options(plan_parser)
|
|
60
|
+
|
|
61
|
+
run_parser = subparsers.add_parser("run", help="start a run lifecycle")
|
|
62
|
+
run_parser.add_argument("request")
|
|
63
|
+
run_parser.add_argument(
|
|
64
|
+
"--from-plan",
|
|
65
|
+
dest="plan_run_id",
|
|
66
|
+
metavar="RUN_ID",
|
|
67
|
+
help="reuse the approved plan from a successful plan run",
|
|
68
|
+
)
|
|
69
|
+
_common_options(run_parser)
|
|
70
|
+
_limit_options(run_parser)
|
|
61
71
|
|
|
62
72
|
for name in ("status", "recover", "report"):
|
|
63
73
|
command = subparsers.add_parser(name)
|
|
@@ -106,7 +116,12 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
106
116
|
if args.command == "providers":
|
|
107
117
|
return _providers(config, args.json_output)
|
|
108
118
|
if args.command in {"plan", "run"}:
|
|
109
|
-
return _execution(
|
|
119
|
+
return _execution(
|
|
120
|
+
config,
|
|
121
|
+
args.request,
|
|
122
|
+
RunMode(args.command),
|
|
123
|
+
plan_run_id=getattr(args, "plan_run_id", None),
|
|
124
|
+
)
|
|
110
125
|
if args.command == "status":
|
|
111
126
|
return _status(config, args.run_id, args.json_output)
|
|
112
127
|
if args.command == "recover":
|
|
@@ -227,8 +242,8 @@ def _providers(config, json_output: bool) -> int:
|
|
|
227
242
|
return 1 if failed else 0
|
|
228
243
|
|
|
229
244
|
|
|
230
|
-
def _execution(config, request: str, mode: RunMode) -> int:
|
|
231
|
-
outcome = asyncio.run(execute_pipeline(config, request, mode))
|
|
245
|
+
def _execution(config, request: str, mode: RunMode, *, plan_run_id: str | None = None) -> int:
|
|
246
|
+
outcome = asyncio.run(execute_pipeline(config, request, mode, plan_run_id=plan_run_id))
|
|
232
247
|
manifest = outcome.manifest
|
|
233
248
|
if manifest.exit_code == 0:
|
|
234
249
|
print(f"Harness succeeded: {outcome.paths.final_report}")
|
|
@@ -8,6 +8,8 @@ from dataclasses import dataclass
|
|
|
8
8
|
from pathlib import Path
|
|
9
9
|
from typing import Any, Protocol
|
|
10
10
|
|
|
11
|
+
import jsonschema
|
|
12
|
+
|
|
11
13
|
from .config import HarnessConfig
|
|
12
14
|
from .e2e_evidence import EvidenceError, prepare_evidence_directory, validate_and_archive_e2e
|
|
13
15
|
from .gate import Finding, GateResult, Severity, evaluate_gate
|
|
@@ -17,7 +19,7 @@ from .providers import default_registry
|
|
|
17
19
|
from .providers.base import ErrorKind, ProviderError, ProviderRequest, SandboxPolicy
|
|
18
20
|
from .providers.router import ProviderRouter, ProviderRoutingError, RoutedResult
|
|
19
21
|
from .reporting import write_reports
|
|
20
|
-
from .run_store import RunPaths, RunStore
|
|
22
|
+
from .run_store import RunPaths, RunStore, RunStoreError
|
|
21
23
|
from .worktree import WorktreeError, prepare_worktree
|
|
22
24
|
|
|
23
25
|
|
|
@@ -186,11 +188,18 @@ async def execute_pipeline(
|
|
|
186
188
|
mode: RunMode,
|
|
187
189
|
*,
|
|
188
190
|
router: Router | None = None,
|
|
191
|
+
plan_run_id: str | None = None,
|
|
189
192
|
) -> PipelineOutcome:
|
|
190
193
|
store = RunStore(config)
|
|
191
194
|
manifest, paths = store.create(request, mode)
|
|
192
|
-
store.start(
|
|
195
|
+
store.start(
|
|
196
|
+
manifest,
|
|
197
|
+
phase="plan_reuse_validation" if plan_run_id is not None else "orchestration",
|
|
198
|
+
)
|
|
193
199
|
try:
|
|
200
|
+
reused_plan = (
|
|
201
|
+
_load_approved_plan(store, manifest, plan_run_id) if plan_run_id is not None else None
|
|
202
|
+
)
|
|
194
203
|
execution_config = (
|
|
195
204
|
prepare_worktree(config, manifest, store) if mode == RunMode.RUN else config
|
|
196
205
|
)
|
|
@@ -200,7 +209,7 @@ async def execute_pipeline(
|
|
|
200
209
|
manifest,
|
|
201
210
|
router or ProviderRouter(execution_config, default_registry()),
|
|
202
211
|
)
|
|
203
|
-
await runner.run(mode)
|
|
212
|
+
await runner.run(mode, reused_plan=reused_plan, plan_run_id=plan_run_id)
|
|
204
213
|
except PipelineStop as stop:
|
|
205
214
|
store.escalate(
|
|
206
215
|
manifest,
|
|
@@ -263,6 +272,98 @@ async def execute_pipeline(
|
|
|
263
272
|
return PipelineOutcome(manifest, paths)
|
|
264
273
|
|
|
265
274
|
|
|
275
|
+
def _load_approved_plan(
|
|
276
|
+
store: RunStore,
|
|
277
|
+
manifest: RunManifest,
|
|
278
|
+
plan_run_id: str,
|
|
279
|
+
) -> tuple[dict[str, Any], dict[str, Any]]:
|
|
280
|
+
if manifest.mode != RunMode.RUN:
|
|
281
|
+
raise PipelineStop(
|
|
282
|
+
StopReason.UNSAFE_OPERATION_BLOCKED,
|
|
283
|
+
ExitCode.MANUAL_DECISION_REQUIRED,
|
|
284
|
+
"承認済みplanの再利用はrun lifecycleでのみ指定できます。",
|
|
285
|
+
("--from-planはnorn runで指定する",),
|
|
286
|
+
)
|
|
287
|
+
try:
|
|
288
|
+
source_paths = store.paths(plan_run_id)
|
|
289
|
+
_reject_symlinked_plan_source(store, source_paths)
|
|
290
|
+
source = store.load_manifest(plan_run_id)
|
|
291
|
+
plan = _read_plan_artifact(store, plan_run_id, "approved-plan.json")
|
|
292
|
+
orchestration = _read_plan_artifact(
|
|
293
|
+
store,
|
|
294
|
+
plan_run_id,
|
|
295
|
+
"approved-orchestration.json",
|
|
296
|
+
)
|
|
297
|
+
if orchestration is None:
|
|
298
|
+
legacy = _read_plan_artifact(store, plan_run_id, "001-orchestrator.json")
|
|
299
|
+
if isinstance(legacy, dict):
|
|
300
|
+
orchestration = legacy.get("result")
|
|
301
|
+
except RunStoreError as exc:
|
|
302
|
+
raise PipelineStop(
|
|
303
|
+
StopReason.MANUAL_DECISION_REQUIRED,
|
|
304
|
+
ExitCode.MANUAL_DECISION_REQUIRED,
|
|
305
|
+
f"再利用するplan runを読み込めません: {exc}",
|
|
306
|
+
("正しいplan run IDを確認する",),
|
|
307
|
+
) from exc
|
|
308
|
+
|
|
309
|
+
problems: list[str] = []
|
|
310
|
+
if source.mode != RunMode.PLAN:
|
|
311
|
+
problems.append("source runはplan modeではありません")
|
|
312
|
+
if source.status != "succeeded" or source.exit_code != 0:
|
|
313
|
+
problems.append("source plan runは正常完了していません")
|
|
314
|
+
if Path(source.project_root).resolve() != store.config.project_root.resolve():
|
|
315
|
+
problems.append("source plan runのproject rootが現在のprojectと一致しません")
|
|
316
|
+
if source.request != manifest.request:
|
|
317
|
+
problems.append("source plan runの依頼文が現在の依頼文と一致しません")
|
|
318
|
+
if plan is None:
|
|
319
|
+
problems.append("approved-plan.jsonがありません")
|
|
320
|
+
if orchestration is None:
|
|
321
|
+
problems.append("承認済みplanに対応するorchestration成果物がありません")
|
|
322
|
+
if problems:
|
|
323
|
+
raise PipelineStop(
|
|
324
|
+
StopReason.MANUAL_DECISION_REQUIRED,
|
|
325
|
+
ExitCode.MANUAL_DECISION_REQUIRED,
|
|
326
|
+
"承認済みplanを安全に再利用できません: " + "; ".join(problems),
|
|
327
|
+
("同じprojectと依頼文で正常完了したplan runを指定する",),
|
|
328
|
+
)
|
|
329
|
+
|
|
330
|
+
try:
|
|
331
|
+
jsonschema.validate(plan, _PLAN_SCHEMA)
|
|
332
|
+
jsonschema.validate(orchestration, _ORCHESTRATION_SCHEMA)
|
|
333
|
+
except jsonschema.ValidationError as exc:
|
|
334
|
+
raise PipelineStop(
|
|
335
|
+
StopReason.MANUAL_DECISION_REQUIRED,
|
|
336
|
+
ExitCode.MANUAL_DECISION_REQUIRED,
|
|
337
|
+
f"保存済みplan成果物が期待する形式ではありません: {exc.message}",
|
|
338
|
+
("source plan runの成果物を確認し、新しいplanを作成する",),
|
|
339
|
+
) from exc
|
|
340
|
+
|
|
341
|
+
store.write_json(
|
|
342
|
+
manifest.run_id,
|
|
343
|
+
"artifacts/reused-plan.json",
|
|
344
|
+
{
|
|
345
|
+
"source_run_id": plan_run_id,
|
|
346
|
+
"orchestration": orchestration,
|
|
347
|
+
"plan": plan,
|
|
348
|
+
},
|
|
349
|
+
)
|
|
350
|
+
return orchestration, plan
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def _reject_symlinked_plan_source(store: RunStore, paths: RunPaths) -> None:
|
|
354
|
+
candidates = (store.config.runs_dir, paths.root, paths.manifest, paths.artifacts)
|
|
355
|
+
symlink = next((path for path in candidates if path.is_symlink()), None)
|
|
356
|
+
if symlink is not None:
|
|
357
|
+
raise RunStoreError(f"Plan source path must not be a symlink: {symlink}")
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def _read_plan_artifact(store: RunStore, run_id: str, name: str) -> Any:
|
|
361
|
+
path = store.paths(run_id).artifacts / name
|
|
362
|
+
if path.is_symlink():
|
|
363
|
+
raise RunStoreError(f"Plan source artifact must not be a symlink: {path}")
|
|
364
|
+
return store.read_json(run_id, f"artifacts/{name}")
|
|
365
|
+
|
|
366
|
+
|
|
266
367
|
class _PipelineRunner:
|
|
267
368
|
def __init__(
|
|
268
369
|
self,
|
|
@@ -281,7 +382,22 @@ class _PipelineRunner:
|
|
|
281
382
|
self.latest_tests: list[dict[str, Any]] = []
|
|
282
383
|
self.latest_triage: dict[str, Any] = {}
|
|
283
384
|
|
|
284
|
-
async def run(
|
|
385
|
+
async def run(
|
|
386
|
+
self,
|
|
387
|
+
mode: RunMode,
|
|
388
|
+
*,
|
|
389
|
+
reused_plan: tuple[dict[str, Any], dict[str, Any]] | None = None,
|
|
390
|
+
plan_run_id: str | None = None,
|
|
391
|
+
) -> None:
|
|
392
|
+
if reused_plan is not None:
|
|
393
|
+
orchestration, plan = reused_plan
|
|
394
|
+
self.store.append_event(
|
|
395
|
+
self.manifest.run_id,
|
|
396
|
+
"planning.reused",
|
|
397
|
+
source_run_id=plan_run_id,
|
|
398
|
+
)
|
|
399
|
+
await self._implement_until_approved(orchestration, plan)
|
|
400
|
+
return
|
|
285
401
|
orchestration = await self._invoke(
|
|
286
402
|
"orchestrator",
|
|
287
403
|
{"request": self.manifest.request, "mode": mode.value},
|
|
@@ -290,6 +406,11 @@ class _PipelineRunner:
|
|
|
290
406
|
)
|
|
291
407
|
plan = await self._plan(orchestration)
|
|
292
408
|
if mode == RunMode.PLAN:
|
|
409
|
+
self.store.write_json(
|
|
410
|
+
self.manifest.run_id,
|
|
411
|
+
"artifacts/approved-orchestration.json",
|
|
412
|
+
orchestration,
|
|
413
|
+
)
|
|
293
414
|
self.store.write_json(self.manifest.run_id, "artifacts/approved-plan.json", plan)
|
|
294
415
|
self.store.succeed(self.manifest)
|
|
295
416
|
return
|
|
@@ -41,8 +41,14 @@ criteria; do not reduce the request to a vague summary.
|
|
|
41
41
|
```bash
|
|
42
42
|
norn plan "<request>" --project <project-root>
|
|
43
43
|
norn run "<request>" --project <project-root>
|
|
44
|
+
norn run "<same-request>" --from-plan <plan-run-id> --project <project-root>
|
|
44
45
|
```
|
|
45
46
|
|
|
47
|
+
When the user asks to implement a plan already approved by Norn, use `--from-plan` with that
|
|
48
|
+
successful `plan` run ID and preserve the original request exactly. This starts a new run and
|
|
49
|
+
worktree but reuses the saved orchestration and approved plan instead of invoking those roles
|
|
50
|
+
again. Do not use a plan from another project, a different request, or a non-successful run.
|
|
51
|
+
|
|
46
52
|
Forward user-specified round limits with `--max-rounds`, `--max-total-rounds`,
|
|
47
53
|
`--max-planner-retries`, or `--max-final-replans`. Never increase a limit after failure without
|
|
48
54
|
the user's approval.
|
|
@@ -29,8 +29,14 @@ provider-specific model IDs without consuming model allowance.
|
|
|
29
29
|
```bash
|
|
30
30
|
norn plan "<request>" --project <path>
|
|
31
31
|
norn run "<request>" --project <path>
|
|
32
|
+
norn run "<same-request>" --from-plan <plan-run-id> --project <path>
|
|
32
33
|
```
|
|
33
34
|
|
|
35
|
+
`--from-plan` skips orchestration, planning, and plan review by reusing the artifacts from a
|
|
36
|
+
successful `plan` run. The project root and request must match the source run exactly. Norn
|
|
37
|
+
validates the source manifest and artifact schemas, records the source run ID, and starts a new
|
|
38
|
+
implementation run in its normal isolated worktree.
|
|
39
|
+
|
|
34
40
|
Optional per-run limits:
|
|
35
41
|
|
|
36
42
|
```text
|
|
@@ -94,6 +100,7 @@ Runs are stored under `.norf/runs/<run-id>/` by default:
|
|
|
94
100
|
manifest.json authoritative state and selected models
|
|
95
101
|
events.jsonl append-only lifecycle journal
|
|
96
102
|
artifacts/*.json role responses, provider attempts, and usage
|
|
103
|
+
artifacts/reused-plan.json copied plan context and source run ID when --from-plan is used
|
|
97
104
|
rounds.json implementation round history
|
|
98
105
|
findings.json deduplicated current findings
|
|
99
106
|
test-results.json local, agent, and E2E results
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: norn-cli
|
|
3
|
-
Version: 0.7.
|
|
3
|
+
Version: 0.7.2
|
|
4
4
|
Summary: Norn: a provider-neutral multi-agent development harness
|
|
5
5
|
License-Expression: Apache-2.0
|
|
6
6
|
Project-URL: Repository, https://github.com/uruca-kk/norn
|
|
@@ -70,6 +70,17 @@ assigned evidence directory. The harness rejects missing, stale, symlinked, or
|
|
|
70
70
|
out-of-directory evidence and archives accepted files under
|
|
71
71
|
`.norf/runs/<run-id>/artifacts/e2e/`.
|
|
72
72
|
|
|
73
|
+
To implement a previously approved plan without invoking orchestration, planning,
|
|
74
|
+
or plan review again, pass the successful `plan` run ID with the identical request:
|
|
75
|
+
|
|
76
|
+
```bash
|
|
77
|
+
norn plan "add an audit log"
|
|
78
|
+
norn run "add an audit log" --from-plan <plan-run-id>
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
Norn verifies the source run mode and status, project root, request text, and saved
|
|
82
|
+
artifact schemas before creating the implementation worktree.
|
|
83
|
+
|
|
73
84
|
For Codex tester roles, the adapter keeps `workspace-write` filesystem isolation
|
|
74
85
|
and enables command networking through a loopback-only proxy allowlist
|
|
75
86
|
(`localhost` and `127.0.0.1`). Planning and implementation roles retain their
|
|
@@ -124,7 +135,7 @@ ruff format --check .
|
|
|
124
135
|
ruff check .
|
|
125
136
|
python3 -m unittest discover -v
|
|
126
137
|
python3 -m build
|
|
127
|
-
python3 scripts/package_smoke.py dist/norn_cli-0.7.
|
|
138
|
+
python3 scripts/package_smoke.py dist/norn_cli-0.7.2-py3-none-any.whl
|
|
128
139
|
```
|
|
129
140
|
|
|
130
141
|
CI runs the quality suite on Python 3.12, 3.13, and 3.14, then installs the
|
|
@@ -8,7 +8,7 @@ import tempfile
|
|
|
8
8
|
import unittest
|
|
9
9
|
from pathlib import Path
|
|
10
10
|
from types import SimpleNamespace
|
|
11
|
-
from unittest.mock import patch
|
|
11
|
+
from unittest.mock import ANY, patch
|
|
12
12
|
|
|
13
13
|
from norf_harness.cli import main
|
|
14
14
|
from norf_harness.config import load_config
|
|
@@ -23,7 +23,7 @@ class CliTests(unittest.TestCase):
|
|
|
23
23
|
main(["--version"])
|
|
24
24
|
|
|
25
25
|
self.assertEqual(raised.exception.code, 0)
|
|
26
|
-
self.assertEqual(output.getvalue().strip(), "norn 0.7.
|
|
26
|
+
self.assertEqual(output.getvalue().strip(), "norn 0.7.2")
|
|
27
27
|
|
|
28
28
|
def test_init_and_validate(self) -> None:
|
|
29
29
|
with tempfile.TemporaryDirectory() as tmp:
|
|
@@ -58,7 +58,44 @@ class CliTests(unittest.TestCase):
|
|
|
58
58
|
result = main(["run", "build a feature", "--project", str(root)])
|
|
59
59
|
|
|
60
60
|
self.assertEqual(result, 0)
|
|
61
|
-
execute.
|
|
61
|
+
execute.assert_awaited_once_with(
|
|
62
|
+
ANY,
|
|
63
|
+
"build a feature",
|
|
64
|
+
RunMode.RUN,
|
|
65
|
+
plan_run_id=None,
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
def test_run_forwards_approved_plan_run_id(self) -> None:
|
|
69
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
70
|
+
root = Path(tmp)
|
|
71
|
+
subprocess.run(["git", "init", "-q", str(root)], check=True)
|
|
72
|
+
outcome = SimpleNamespace(
|
|
73
|
+
manifest=SimpleNamespace(exit_code=0, run_id="run-2", stop_reason=None),
|
|
74
|
+
paths=SimpleNamespace(final_report=root / "final-report.md"),
|
|
75
|
+
exit_code=0,
|
|
76
|
+
)
|
|
77
|
+
with (
|
|
78
|
+
patch.dict(os.environ, {"NORF_HARNESS_USER_CONFIG": str(root / "none")}),
|
|
79
|
+
patch("norf_harness.cli.execute_pipeline", return_value=outcome) as execute,
|
|
80
|
+
):
|
|
81
|
+
result = main(
|
|
82
|
+
[
|
|
83
|
+
"run",
|
|
84
|
+
"build a feature",
|
|
85
|
+
"--from-plan",
|
|
86
|
+
"plan-run-1",
|
|
87
|
+
"--project",
|
|
88
|
+
str(root),
|
|
89
|
+
]
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
self.assertEqual(result, 0)
|
|
93
|
+
execute.assert_awaited_once_with(
|
|
94
|
+
ANY,
|
|
95
|
+
"build a feature",
|
|
96
|
+
RunMode.RUN,
|
|
97
|
+
plan_run_id="plan-run-1",
|
|
98
|
+
)
|
|
62
99
|
|
|
63
100
|
def test_invalid_round_override_returns_config_error(self) -> None:
|
|
64
101
|
with tempfile.TemporaryDirectory() as tmp:
|
|
@@ -222,6 +222,116 @@ class PipelineTests(unittest.TestCase):
|
|
|
222
222
|
self.assertIn("人間に判断してほしいこと", report)
|
|
223
223
|
self.assertIn("revise", report)
|
|
224
224
|
|
|
225
|
+
def test_run_reuses_approved_plan_without_planning_roles(self) -> None:
|
|
226
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
227
|
+
root = Path(tmp)
|
|
228
|
+
config = self.config(root)
|
|
229
|
+
planning_router = ScriptedRouter(
|
|
230
|
+
orchestrator=[orchestration()],
|
|
231
|
+
planner=[plan()],
|
|
232
|
+
plan_reviewer=[review()],
|
|
233
|
+
)
|
|
234
|
+
planned = asyncio.run(
|
|
235
|
+
execute_pipeline(config, "build a feature", RunMode.PLAN, router=planning_router)
|
|
236
|
+
)
|
|
237
|
+
run_router = self.base_router()
|
|
238
|
+
run_router.responses["orchestrator"].clear()
|
|
239
|
+
run_router.responses["planner"].clear()
|
|
240
|
+
run_router.responses["plan_reviewer"].clear()
|
|
241
|
+
|
|
242
|
+
outcome = asyncio.run(
|
|
243
|
+
execute_pipeline(
|
|
244
|
+
config,
|
|
245
|
+
"build a feature",
|
|
246
|
+
RunMode.RUN,
|
|
247
|
+
router=run_router,
|
|
248
|
+
plan_run_id=planned.manifest.run_id,
|
|
249
|
+
)
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
self.assertEqual(outcome.exit_code, 0)
|
|
253
|
+
roles = [call.role for call in run_router.calls]
|
|
254
|
+
self.assertNotIn("orchestrator", roles)
|
|
255
|
+
self.assertNotIn("planner", roles)
|
|
256
|
+
self.assertNotIn("plan_reviewer", roles)
|
|
257
|
+
self.assertEqual(roles[0], "implementer")
|
|
258
|
+
self.assertEqual(outcome.manifest.rounds.total, 1)
|
|
259
|
+
reused = json.loads(
|
|
260
|
+
(outcome.paths.artifacts / "reused-plan.json").read_text(encoding="utf-8")
|
|
261
|
+
)
|
|
262
|
+
self.assertEqual(reused["source_run_id"], planned.manifest.run_id)
|
|
263
|
+
events = outcome.paths.events.read_text(encoding="utf-8")
|
|
264
|
+
self.assertIn('"event": "planning.reused"', events)
|
|
265
|
+
|
|
266
|
+
def test_run_rejects_reused_plan_for_a_different_request(self) -> None:
|
|
267
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
268
|
+
root = Path(tmp)
|
|
269
|
+
config = self.config(root)
|
|
270
|
+
planned = asyncio.run(
|
|
271
|
+
execute_pipeline(
|
|
272
|
+
config,
|
|
273
|
+
"first request",
|
|
274
|
+
RunMode.PLAN,
|
|
275
|
+
router=ScriptedRouter(
|
|
276
|
+
orchestrator=[orchestration()],
|
|
277
|
+
planner=[plan()],
|
|
278
|
+
plan_reviewer=[review()],
|
|
279
|
+
),
|
|
280
|
+
)
|
|
281
|
+
)
|
|
282
|
+
run_router = ScriptedRouter()
|
|
283
|
+
|
|
284
|
+
outcome = asyncio.run(
|
|
285
|
+
execute_pipeline(
|
|
286
|
+
config,
|
|
287
|
+
"different request",
|
|
288
|
+
RunMode.RUN,
|
|
289
|
+
router=run_router,
|
|
290
|
+
plan_run_id=planned.manifest.run_id,
|
|
291
|
+
)
|
|
292
|
+
)
|
|
293
|
+
|
|
294
|
+
self.assertEqual(outcome.exit_code, 50)
|
|
295
|
+
self.assertEqual(outcome.manifest.stop_reason, "manual_decision_required")
|
|
296
|
+
self.assertEqual(run_router.calls, [])
|
|
297
|
+
self.assertIn("依頼文", outcome.manifest.stop_detail)
|
|
298
|
+
self.assertTrue(outcome.paths.escalation_report.is_file())
|
|
299
|
+
|
|
300
|
+
def test_run_rejects_symlinked_reused_plan_artifact(self) -> None:
|
|
301
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
302
|
+
root = Path(tmp)
|
|
303
|
+
config = self.config(root)
|
|
304
|
+
planned = asyncio.run(
|
|
305
|
+
execute_pipeline(
|
|
306
|
+
config,
|
|
307
|
+
"build a feature",
|
|
308
|
+
RunMode.PLAN,
|
|
309
|
+
router=ScriptedRouter(
|
|
310
|
+
orchestrator=[orchestration()],
|
|
311
|
+
planner=[plan()],
|
|
312
|
+
plan_reviewer=[review()],
|
|
313
|
+
),
|
|
314
|
+
)
|
|
315
|
+
)
|
|
316
|
+
approved_plan = planned.paths.artifacts / "approved-plan.json"
|
|
317
|
+
outside = root / "outside-plan.json"
|
|
318
|
+
approved_plan.replace(outside)
|
|
319
|
+
approved_plan.symlink_to(outside)
|
|
320
|
+
|
|
321
|
+
outcome = asyncio.run(
|
|
322
|
+
execute_pipeline(
|
|
323
|
+
config,
|
|
324
|
+
"build a feature",
|
|
325
|
+
RunMode.RUN,
|
|
326
|
+
router=ScriptedRouter(),
|
|
327
|
+
plan_run_id=planned.manifest.run_id,
|
|
328
|
+
)
|
|
329
|
+
)
|
|
330
|
+
|
|
331
|
+
self.assertEqual(outcome.exit_code, 50)
|
|
332
|
+
self.assertIn("symlink", outcome.manifest.stop_detail)
|
|
333
|
+
self.assertTrue(outcome.paths.escalation_report.is_file())
|
|
334
|
+
|
|
225
335
|
def test_missing_required_e2e_escalates_as_test_infrastructure(self) -> None:
|
|
226
336
|
with tempfile.TemporaryDirectory() as tmp:
|
|
227
337
|
root = Path(tmp)
|
|
@@ -44,6 +44,7 @@ class SkillInstallTests(unittest.TestCase):
|
|
|
44
44
|
self.assertTrue(
|
|
45
45
|
{"readiness-only", "reviewed-plan", "gated-implementation"}.issubset(case_ids)
|
|
46
46
|
)
|
|
47
|
+
self.assertIn("approved-plan-implementation", case_ids)
|
|
47
48
|
self.assertTrue({"escalated-run", "interrupted-run"}.issubset(case_ids))
|
|
48
49
|
|
|
49
50
|
def test_install_all_project_targets_are_explicit_only(self) -> None:
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|