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.
Files changed (46) hide show
  1. {norn_cli-0.7.1 → norn_cli-0.7.2}/PKG-INFO +13 -2
  2. {norn_cli-0.7.1 → norn_cli-0.7.2}/README.md +12 -1
  3. {norn_cli-0.7.1 → norn_cli-0.7.2}/pyproject.toml +1 -1
  4. {norn_cli-0.7.1 → norn_cli-0.7.2}/src/norf_harness/__init__.py +1 -1
  5. {norn_cli-0.7.1 → norn_cli-0.7.2}/src/norf_harness/cli.py +23 -8
  6. {norn_cli-0.7.1 → norn_cli-0.7.2}/src/norf_harness/pipeline.py +125 -4
  7. {norn_cli-0.7.1 → norn_cli-0.7.2}/src/norf_harness/resources/skills/norn/SKILL.md +6 -0
  8. {norn_cli-0.7.1 → norn_cli-0.7.2}/src/norf_harness/resources/skills/norn/references/cli.md +7 -0
  9. {norn_cli-0.7.1 → norn_cli-0.7.2}/src/norn_cli.egg-info/PKG-INFO +13 -2
  10. {norn_cli-0.7.1 → norn_cli-0.7.2}/tests/test_cli.py +40 -3
  11. {norn_cli-0.7.1 → norn_cli-0.7.2}/tests/test_pipeline.py +110 -0
  12. {norn_cli-0.7.1 → norn_cli-0.7.2}/tests/test_skill_install.py +1 -0
  13. {norn_cli-0.7.1 → norn_cli-0.7.2}/LICENSE +0 -0
  14. {norn_cli-0.7.1 → norn_cli-0.7.2}/setup.cfg +0 -0
  15. {norn_cli-0.7.1 → norn_cli-0.7.2}/src/norf_harness/config.py +0 -0
  16. {norn_cli-0.7.1 → norn_cli-0.7.2}/src/norf_harness/doctor.py +0 -0
  17. {norn_cli-0.7.1 → norn_cli-0.7.2}/src/norf_harness/e2e_evidence.py +0 -0
  18. {norn_cli-0.7.1 → norn_cli-0.7.2}/src/norf_harness/gate.py +0 -0
  19. {norn_cli-0.7.1 → norn_cli-0.7.2}/src/norf_harness/models.py +0 -0
  20. {norn_cli-0.7.1 → norn_cli-0.7.2}/src/norf_harness/project_gates.py +0 -0
  21. {norn_cli-0.7.1 → norn_cli-0.7.2}/src/norf_harness/providers/__init__.py +0 -0
  22. {norn_cli-0.7.1 → norn_cli-0.7.2}/src/norf_harness/providers/base.py +0 -0
  23. {norn_cli-0.7.1 → norn_cli-0.7.2}/src/norf_harness/providers/claude.py +0 -0
  24. {norn_cli-0.7.1 → norn_cli-0.7.2}/src/norf_harness/providers/codex.py +0 -0
  25. {norn_cli-0.7.1 → norn_cli-0.7.2}/src/norf_harness/providers/command.py +0 -0
  26. {norn_cli-0.7.1 → norn_cli-0.7.2}/src/norf_harness/providers/grok.py +0 -0
  27. {norn_cli-0.7.1 → norn_cli-0.7.2}/src/norf_harness/providers/registry.py +0 -0
  28. {norn_cli-0.7.1 → norn_cli-0.7.2}/src/norf_harness/providers/router.py +0 -0
  29. {norn_cli-0.7.1 → norn_cli-0.7.2}/src/norf_harness/reporting.py +0 -0
  30. {norn_cli-0.7.1 → norn_cli-0.7.2}/src/norf_harness/resources/default.yaml +0 -0
  31. {norn_cli-0.7.1 → norn_cli-0.7.2}/src/norf_harness/resources/skills/norn/agents/openai.yaml +0 -0
  32. {norn_cli-0.7.1 → norn_cli-0.7.2}/src/norf_harness/run_store.py +0 -0
  33. {norn_cli-0.7.1 → norn_cli-0.7.2}/src/norf_harness/skill_install.py +0 -0
  34. {norn_cli-0.7.1 → norn_cli-0.7.2}/src/norf_harness/worktree.py +0 -0
  35. {norn_cli-0.7.1 → norn_cli-0.7.2}/src/norn_cli.egg-info/SOURCES.txt +0 -0
  36. {norn_cli-0.7.1 → norn_cli-0.7.2}/src/norn_cli.egg-info/dependency_links.txt +0 -0
  37. {norn_cli-0.7.1 → norn_cli-0.7.2}/src/norn_cli.egg-info/entry_points.txt +0 -0
  38. {norn_cli-0.7.1 → norn_cli-0.7.2}/src/norn_cli.egg-info/requires.txt +0 -0
  39. {norn_cli-0.7.1 → norn_cli-0.7.2}/src/norn_cli.egg-info/top_level.txt +0 -0
  40. {norn_cli-0.7.1 → norn_cli-0.7.2}/tests/test_config.py +0 -0
  41. {norn_cli-0.7.1 → norn_cli-0.7.2}/tests/test_e2e_evidence.py +0 -0
  42. {norn_cli-0.7.1 → norn_cli-0.7.2}/tests/test_gate.py +0 -0
  43. {norn_cli-0.7.1 → norn_cli-0.7.2}/tests/test_project_gates.py +0 -0
  44. {norn_cli-0.7.1 → norn_cli-0.7.2}/tests/test_providers.py +0 -0
  45. {norn_cli-0.7.1 → norn_cli-0.7.2}/tests/test_run_store.py +0 -0
  46. {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.1
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.1-py3-none-any.whl
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.1-py3-none-any.whl
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
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "norn-cli"
7
- version = "0.7.1"
7
+ version = "0.7.2"
8
8
  description = "Norn: a provider-neutral multi-agent development harness"
9
9
  readme = "README.md"
10
10
  license = "Apache-2.0"
@@ -3,4 +3,4 @@
3
3
  from .config import HarnessConfig, load_config
4
4
 
5
5
  __all__ = ["HarnessConfig", "load_config"]
6
- __version__ = "0.7.1"
6
+ __version__ = "0.7.2"
@@ -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
- 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)
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(config, args.request, RunMode(args.command))
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(manifest, phase="orchestration")
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(self, mode: RunMode) -> None:
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.1
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.1-py3-none-any.whl
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.1")
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.assert_awaited_once()
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