harnessops 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.
Files changed (77) hide show
  1. harnessops/__init__.py +4 -0
  2. harnessops/__main__.py +5 -0
  3. harnessops/adapters/__init__.py +2 -0
  4. harnessops/adapters/base.py +39 -0
  5. harnessops/adapters/generic_code.py +6 -0
  6. harnessops/adapters/harnessops_core.py +17 -0
  7. harnessops/adapters/paper_harness_project.py +21 -0
  8. harnessops/adapters/paper_harness_upstream.py +15 -0
  9. harnessops/adapters/python_package.py +14 -0
  10. harnessops/adapters/runops_project.py +21 -0
  11. harnessops/adapters/runops_upstream.py +17 -0
  12. harnessops/cli/__init__.py +1 -0
  13. harnessops/cli/add_failure.py +85 -0
  14. harnessops/cli/add_feedback.py +4 -0
  15. harnessops/cli/agent.py +81 -0
  16. harnessops/cli/decide.py +50 -0
  17. harnessops/cli/detect.py +28 -0
  18. harnessops/cli/doctor.py +43 -0
  19. harnessops/cli/eval.py +58 -0
  20. harnessops/cli/feedback.py +116 -0
  21. harnessops/cli/init.py +80 -0
  22. harnessops/cli/lab.py +49 -0
  23. harnessops/cli/link.py +4 -0
  24. harnessops/cli/main.py +34 -0
  25. harnessops/cli/migrate.py +36 -0
  26. harnessops/cli/profiles.py +37 -0
  27. harnessops/cli/propose.py +47 -0
  28. harnessops/cli/report.py +17 -0
  29. harnessops/cli/route.py +42 -0
  30. harnessops/core/__init__.py +2 -0
  31. harnessops/core/agent_bridge.py +45 -0
  32. harnessops/core/detect.py +67 -0
  33. harnessops/core/evaluation.py +75 -0
  34. harnessops/core/lock.py +57 -0
  35. harnessops/core/manifest.py +60 -0
  36. harnessops/core/migration.py +40 -0
  37. harnessops/core/overlay.py +207 -0
  38. harnessops/core/paths.py +19 -0
  39. harnessops/core/project.py +51 -0
  40. harnessops/core/records.py +422 -0
  41. harnessops/core/render.py +33 -0
  42. harnessops/core/routing.py +38 -0
  43. harnessops/core/sanitize.py +41 -0
  44. harnessops/core/validation.py +123 -0
  45. harnessops/core/yamlio.py +42 -0
  46. harnessops/migrations/__init__.py +2 -0
  47. harnessops/migrations/registry.py +2 -0
  48. harnessops/migrations/v0_1_to_v0_2.py +3 -0
  49. harnessops/profiles/__init__.py +2 -0
  50. harnessops/profiles/builtins/__init__.py +2 -0
  51. harnessops/profiles/builtins/generic-code.yml +35 -0
  52. harnessops/profiles/builtins/harnessops-core.yml +49 -0
  53. harnessops/profiles/builtins/paper-harness-project.yml +67 -0
  54. harnessops/profiles/builtins/paper-harness-upstream.yml +53 -0
  55. harnessops/profiles/builtins/python-package.yml +40 -0
  56. harnessops/profiles/builtins/runops-project.yml +79 -0
  57. harnessops/profiles/builtins/runops-upstream.yml +49 -0
  58. harnessops/profiles/builtins/target-harness.yml +37 -0
  59. harnessops/profiles/loader.py +4 -0
  60. harnessops/profiles/registry.py +53 -0
  61. harnessops/schemas/__init__.py +2 -0
  62. harnessops/schemas/json/__init__.py +2 -0
  63. harnessops/schemas/json/decision.schema.json +19 -0
  64. harnessops/schemas/json/eval-case.schema.json +13 -0
  65. harnessops/schemas/json/experiment.schema.json +10 -0
  66. harnessops/schemas/json/failure-record.schema.json +13 -0
  67. harnessops/schemas/json/feedback-record.schema.json +17 -0
  68. harnessops/schemas/json/harness-manifest.schema.json +20 -0
  69. harnessops/schemas/json/harnessops-project.schema.json +15 -0
  70. harnessops/schemas/json/hypothesis.schema.json +23 -0
  71. harnessops/schemas/json/profile.schema.json +23 -0
  72. harnessops/schemas/loader.py +11 -0
  73. harnessops-0.1.0.dist-info/METADATA +75 -0
  74. harnessops-0.1.0.dist-info/RECORD +77 -0
  75. harnessops-0.1.0.dist-info/WHEEL +4 -0
  76. harnessops-0.1.0.dist-info/entry_points.txt +3 -0
  77. harnessops-0.1.0.dist-info/licenses/LICENSE +2 -0
harnessops/__init__.py ADDED
@@ -0,0 +1,4 @@
1
+ """HarnessOps package."""
2
+
3
+ __version__ = "0.1.0"
4
+
harnessops/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ from harnessops.cli.main import app
2
+
3
+ if __name__ == "__main__":
4
+ app()
5
+
@@ -0,0 +1,2 @@
1
+ """Built-in adapters."""
2
+
@@ -0,0 +1,39 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from pathlib import Path
5
+
6
+
7
+ @dataclass(frozen=True)
8
+ class DetectionResult:
9
+ profile_id: str | None
10
+ repository_kind: str
11
+ confidence: float
12
+ markers: list[str]
13
+
14
+
15
+ @dataclass(frozen=True)
16
+ class CheckResult:
17
+ name: str
18
+ ok: bool
19
+ message: str
20
+
21
+
22
+ class Adapter:
23
+ id = "base"
24
+
25
+ def detect(self, root: Path) -> DetectionResult:
26
+ return DetectionResult(None, "unknown", 0.0, [])
27
+
28
+ def default_profile_id(self, root: Path) -> str | None:
29
+ return self.detect(root).profile_id
30
+
31
+ def doctor_checks(self, root: Path) -> list[CheckResult]:
32
+ return []
33
+
34
+ def routing_hints(self, text: str) -> list[str]:
35
+ return []
36
+
37
+ def eval_case_templates(self) -> list[str]:
38
+ return []
39
+
@@ -0,0 +1,6 @@
1
+ from harnessops.adapters.base import Adapter
2
+
3
+
4
+ class GenericCodeAdapter(Adapter):
5
+ id = "generic_code"
6
+
@@ -0,0 +1,17 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+ from harnessops.adapters.base import Adapter, DetectionResult
6
+
7
+
8
+ class HarnessOpsCoreAdapter(Adapter):
9
+ id = "harnessops_core"
10
+
11
+ def detect(self, root: Path) -> DetectionResult:
12
+ markers = [m for m in ["pyproject.toml", "src/harnessops", "src/harnessops/profiles", "src/harnessops/schemas"] if (root / m).exists()]
13
+ pyproject = (root / "pyproject.toml").read_text(encoding="utf-8") if (root / "pyproject.toml").exists() else ""
14
+ if 'name = "harnessops"' in pyproject and len(markers) >= 2:
15
+ return DetectionResult("harnessops-core", "harnessops-repository", 0.95, markers)
16
+ return DetectionResult(None, "unknown", 0.0, markers)
17
+
@@ -0,0 +1,21 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+ from harnessops.adapters.base import Adapter, CheckResult, DetectionResult
6
+
7
+
8
+ class PaperHarnessProjectAdapter(Adapter):
9
+ id = "paper_harness_project"
10
+
11
+ def detect(self, root: Path) -> DetectionResult:
12
+ markers = [m for m in ["manuscript", "notes/claim-evidence-map.md", "refs", "submission"] if (root / m).exists()]
13
+ profile = "paper-harness-project" if len(markers) >= 2 else None
14
+ return DetectionResult(profile, "project-repository", len(markers) / 4.0, markers)
15
+
16
+ def doctor_checks(self, root: Path) -> list[CheckResult]:
17
+ return [
18
+ CheckResult("manuscript", (root / "manuscript").exists(), "manuscript exists"),
19
+ CheckResult("notes", (root / "notes").exists(), "notes exists"),
20
+ ]
21
+
@@ -0,0 +1,15 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+ from harnessops.adapters.base import Adapter, DetectionResult
6
+
7
+
8
+ class PaperHarnessUpstreamAdapter(Adapter):
9
+ id = "paper_harness_upstream"
10
+
11
+ def detect(self, root: Path) -> DetectionResult:
12
+ markers = [m for m in ["template", "scripts/publish-scaffold.sh", "template/manuscript"] if (root / m).exists()]
13
+ profile = "paper-harness-upstream" if len(markers) >= 2 else None
14
+ return DetectionResult(profile, "target-repository", len(markers) / 3.0, markers)
15
+
@@ -0,0 +1,14 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+ from harnessops.adapters.base import Adapter, DetectionResult
6
+
7
+
8
+ class PythonPackageAdapter(Adapter):
9
+ id = "python_package"
10
+
11
+ def detect(self, root: Path) -> DetectionResult:
12
+ markers = ["pyproject.toml"] if (root / "pyproject.toml").exists() else []
13
+ return DetectionResult("python-package" if markers else None, "python-package", 0.4, markers)
14
+
@@ -0,0 +1,21 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+ from harnessops.adapters.base import Adapter, CheckResult, DetectionResult
6
+
7
+
8
+ class RunopsProjectAdapter(Adapter):
9
+ id = "runops_project"
10
+
11
+ def detect(self, root: Path) -> DetectionResult:
12
+ markers = [m for m in [".runops/harness.lock", "campaign.toml", "cases", "runs"] if (root / m).exists()]
13
+ profile = "runops-project" if len(markers) >= 2 else None
14
+ return DetectionResult(profile, "project-repository", len(markers) / 4.0, markers)
15
+
16
+ def doctor_checks(self, root: Path) -> list[CheckResult]:
17
+ checks = []
18
+ for marker in ["campaign.toml", "cases", "runs"]:
19
+ checks.append(CheckResult(marker, (root / marker).exists(), f"{marker} exists"))
20
+ return checks
21
+
@@ -0,0 +1,17 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+ from harnessops.adapters.base import Adapter, DetectionResult
6
+
7
+
8
+ class RunopsUpstreamAdapter(Adapter):
9
+ id = "runops_upstream"
10
+
11
+ def detect(self, root: Path) -> DetectionResult:
12
+ markers = [m for m in ["pyproject.toml", "src/runops", "src/runops/templates"] if (root / m).exists()]
13
+ pyproject = (root / "pyproject.toml").read_text(encoding="utf-8") if (root / "pyproject.toml").exists() else ""
14
+ if 'name = "runops"' in pyproject and len(markers) >= 2:
15
+ return DetectionResult("runops-upstream", "target-repository", 0.95, markers)
16
+ return DetectionResult(None, "unknown", 0.0, markers)
17
+
@@ -0,0 +1 @@
1
+ """HarnessOps CLI パッケージ。"""
@@ -0,0 +1,85 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Optional
4
+
5
+ import typer
6
+
7
+ from harnessops.core.paths import find_root
8
+ from harnessops.core.project import load_project
9
+ from harnessops.core.records import create_failure, create_feedback_from_failure, find_record, read_record
10
+ from harnessops.core.render import refresh_views
11
+ from harnessops.core.routing import classify_text
12
+ from harnessops.core.routing import DISPOSITIONS
13
+
14
+
15
+ def add_failure_command(
16
+ title: str = typer.Option(..., "--title"),
17
+ target: Optional[str] = typer.Option(None, "--target"),
18
+ context: str = typer.Option("", "--context"),
19
+ what_happened: str = typer.Option("", "--what-happened"),
20
+ why_matters: str = typer.Option("", "--why-matters"),
21
+ desired_behavior: str = typer.Option("", "--desired-behavior"),
22
+ local_workaround: str = typer.Option("", "--local-workaround"),
23
+ disposition: Optional[str] = typer.Option(None, "--disposition"),
24
+ from_file: Optional[str] = typer.Option(None, "--from-file"),
25
+ interactive: bool = typer.Option(False, "--interactive"),
26
+ ) -> None:
27
+ """プロジェクト側の失敗レコードを作成します。"""
28
+ del interactive
29
+ root = find_root()
30
+ project = load_project(root)
31
+ if project.overlay_mode not in {"feedback-source", "local-and-feedback"}:
32
+ typer.echo("add-failure には feedback-source または local-and-feedback mode が必要です")
33
+ raise typer.Exit(1)
34
+ file_text = ""
35
+ if from_file:
36
+ file_text = (root / from_file).read_text(encoding="utf-8")
37
+ routing = classify_text(" ".join([title, context, what_happened, file_text]), target=target)
38
+ if disposition is not None and disposition not in DISPOSITIONS:
39
+ typer.echo(f"disposition が不正です: {disposition}")
40
+ raise typer.Exit(1)
41
+ path = create_failure(
42
+ project,
43
+ title=title,
44
+ target=target,
45
+ context=context or file_text,
46
+ what_happened=what_happened or file_text,
47
+ why_matters=why_matters,
48
+ desired_behavior=desired_behavior,
49
+ local_workaround=local_workaround,
50
+ disposition_type=disposition or routing["type"],
51
+ )
52
+ refresh_views(root, project.overlay_path)
53
+ typer.echo(path.relative_to(root).as_posix())
54
+
55
+
56
+ def add_feedback_command(
57
+ from_id: str = typer.Option(..., "--from"),
58
+ target: Optional[str] = typer.Option(None, "--target"),
59
+ feedback_type: Optional[str] = typer.Option(None, "--type"),
60
+ title: Optional[str] = typer.Option(None, "--title"),
61
+ summary: str = typer.Option("", "--summary"),
62
+ ) -> None:
63
+ """既存の失敗からフィードバック下書きを作成します。
64
+
65
+ 下書きは `hops feedback export --sanitize` でエクスポートされるまで非公開です。
66
+ """
67
+ root = find_root()
68
+ project = load_project(root)
69
+ failure_frontmatter, _ = read_record(find_record(project, from_id))
70
+ resolved_target = target or failure_frontmatter.get("disposition", {}).get("target") or "harnessops"
71
+ path = create_feedback_from_failure(
72
+ project,
73
+ failure_ref=from_id,
74
+ target=resolved_target,
75
+ feedback_type=feedback_type,
76
+ title=title,
77
+ summary=summary,
78
+ )
79
+ refresh_views(root, project.overlay_path)
80
+ typer.echo(path.relative_to(root).as_posix())
81
+
82
+
83
+ def register(app: typer.Typer) -> None:
84
+ app.command("add-failure")(add_failure_command)
85
+ app.command("add-feedback")(add_feedback_command)
@@ -0,0 +1,4 @@
1
+ from harnessops.cli.add_failure import add_feedback_command
2
+
3
+ __all__ = ["add_feedback_command"]
4
+
@@ -0,0 +1,81 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import shutil
5
+ from pathlib import Path
6
+
7
+ import typer
8
+
9
+ from harnessops.core.agent_bridge import write_bridge
10
+ from harnessops.core.paths import find_root
11
+
12
+ agent_app = typer.Typer(help="エージェントブリッジ/プラグイン成果物をインストールまたは検証します。")
13
+
14
+
15
+ @agent_app.command("bridge")
16
+ def bridge(codex: bool = typer.Option(False, "--codex"), claude: bool = typer.Option(False, "--claude"), force: bool = typer.Option(False, "--force")) -> None:
17
+ """薄いリポジトリローカルブリッジスキルを生成します。"""
18
+ if not codex and not claude:
19
+ codex = True
20
+ root = find_root()
21
+ paths = write_bridge(root, codex=codex, claude=claude, force=force)
22
+ typer.echo(json.dumps([path.relative_to(root).as_posix() for path in paths], indent=2))
23
+
24
+
25
+ @agent_app.command("install")
26
+ def install(
27
+ codex: bool = typer.Option(False, "--codex"),
28
+ claude: bool = typer.Option(False, "--claude"),
29
+ scope: str = typer.Option("repo", "--scope"),
30
+ force: bool = typer.Option(False, "--force"),
31
+ ) -> None:
32
+ """リポジトリローカルブリッジをインストールするか、同梱プラグインをユーザープラグインディレクトリへコピーします。"""
33
+ if not codex and not claude:
34
+ codex = True
35
+ root = find_root()
36
+ if scope == "repo":
37
+ bridge(codex=codex, claude=claude, force=force)
38
+ return
39
+ if scope != "user":
40
+ typer.echo("scope は repo または user で指定してください")
41
+ raise typer.Exit(1)
42
+ home = Path.home()
43
+ installed = []
44
+ for enabled, host, plugin_dir in [
45
+ (codex, "codex", home / ".codex" / "plugins" / "harnessops"),
46
+ (claude, "claude", home / ".claude" / "plugins" / "harnessops"),
47
+ ]:
48
+ if not enabled:
49
+ continue
50
+ source = root / "plugins" / host / "harnessops"
51
+ if not source.exists():
52
+ source = Path(__file__).resolve().parents[3] / "plugins" / host / "harnessops"
53
+ if not source.exists():
54
+ typer.echo(f"同梱 {host} プラグインソースが見つかりません")
55
+ raise typer.Exit(1)
56
+ if plugin_dir.exists():
57
+ if not force:
58
+ typer.echo(f"プラグインは既に存在します: {plugin_dir}")
59
+ raise typer.Exit(2)
60
+ shutil.rmtree(plugin_dir)
61
+ plugin_dir.parent.mkdir(parents=True, exist_ok=True)
62
+ shutil.copytree(source, plugin_dir)
63
+ installed.append(plugin_dir.as_posix())
64
+ typer.echo(json.dumps(installed, indent=2))
65
+
66
+
67
+ @agent_app.command("verify")
68
+ def verify() -> None:
69
+ """リポジトリローカルブリッジまたは同梱プラグイン成果物を検証します。"""
70
+ root = find_root()
71
+ expected = root / ".agents" / "skills" / "harnessops-bridge" / "SKILL.md"
72
+ packaged = root / "plugins" / "codex" / "harnessops" / ".codex-plugin" / "plugin.json"
73
+ if expected.exists() or packaged.exists():
74
+ typer.echo("ok")
75
+ else:
76
+ typer.echo("ブリッジまたは同梱プラグインが見つかりません")
77
+ raise typer.Exit(1)
78
+
79
+
80
+ def register(app: typer.Typer) -> None:
81
+ app.add_typer(agent_app, name="agent")
@@ -0,0 +1,50 @@
1
+ from __future__ import annotations
2
+
3
+ import typer
4
+
5
+ from harnessops.core.paths import find_root
6
+ from harnessops.core.project import load_project
7
+ from harnessops.core.records import create_decision
8
+
9
+ STATUSES = {"adopted", "rejected", "parked", "needs-more-evidence", "merged-into-other", "not-upstreamable"}
10
+
11
+
12
+ def decide_command(
13
+ experiment: str | None = typer.Option(None, "--experiment"),
14
+ from_id: str | None = typer.Option(None, "--from"),
15
+ status: str = typer.Option(..., "--status"),
16
+ reason: str = typer.Option("", "--reason"),
17
+ evidence: str = typer.Option("", "--evidence"),
18
+ regression_risk: str = typer.Option("", "--regression-risk"),
19
+ follow_up: str = typer.Option("", "--follow-up"),
20
+ guard_path: str | None = typer.Option(None, "--guard-path"),
21
+ ) -> None:
22
+ """判断レコードを作成します。"""
23
+ if status not in STATUSES:
24
+ typer.echo(f"status が不正です: {status}")
25
+ raise typer.Exit(1)
26
+ source = experiment or from_id
27
+ if not source:
28
+ typer.echo("--experiment または --from を指定してください")
29
+ raise typer.Exit(1)
30
+ if status == "adopted" and (not evidence or not regression_risk or not guard_path):
31
+ typer.echo("adopted の判断には --evidence、--regression-risk、--guard-path が必要です")
32
+ raise typer.Exit(1)
33
+ root = find_root()
34
+ project = load_project(root)
35
+ path = create_decision(
36
+ project,
37
+ source=source,
38
+ status=status,
39
+ title=f"{status} {source}",
40
+ reason=reason or f"`{source}` に対して `{status}` の判断を記録しました。",
41
+ evidence=evidence or "証拠は指定されていません。この判断は採用可能ではありません。",
42
+ regression_risk=regression_risk or "回帰リスクは評価されていません。",
43
+ follow_up=follow_up or "変更を昇格する前にこの判断をレビューしてください。",
44
+ guard_path=guard_path,
45
+ )
46
+ typer.echo(path.relative_to(root).as_posix())
47
+
48
+
49
+ def register(app: typer.Typer) -> None:
50
+ app.command("decide")(decide_command)
@@ -0,0 +1,28 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from pathlib import Path
5
+
6
+ import typer
7
+
8
+ from harnessops.core.detect import detect_repository
9
+
10
+
11
+ def detect(json_output: bool = typer.Option(False, "--json")) -> None:
12
+ """リポジトリ種別と推奨プロファイルを推定します。"""
13
+ root = Path.cwd().resolve()
14
+ result = detect_repository(root)
15
+ result["root"] = str(root)
16
+ if json_output:
17
+ typer.echo(json.dumps(result, indent=2, sort_keys=True))
18
+ return
19
+ typer.echo(f"ルート: {root}")
20
+ typer.echo(f"プロファイル: {result.get('profile')}")
21
+ typer.echo(f"リポジトリ種別: {result.get('repository_kind')}")
22
+ typer.echo(f"検出元: {result.get('source')}")
23
+ if result.get("markers"):
24
+ typer.echo("マーカー: " + ", ".join(result["markers"]))
25
+
26
+
27
+ def register(app: typer.Typer) -> None:
28
+ app.command("detect")(detect)
@@ -0,0 +1,43 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+
5
+ import typer
6
+
7
+ from harnessops.core.paths import find_root
8
+ from harnessops.core.project import load_project
9
+ from harnessops.core.validation import doctor as doctor_project
10
+ from harnessops.core.migration import check_migrations
11
+
12
+
13
+ def doctor_command(
14
+ json_output: bool = typer.Option(False, "--json"),
15
+ provider: bool = typer.Option(False, "--provider"),
16
+ check_overlay: bool = typer.Option(False, "--check-overlay"),
17
+ check_records: bool = typer.Option(False, "--check-records"),
18
+ allow_pending: bool = typer.Option(False, "--allow-pending"),
19
+ ) -> None:
20
+ """HarnessOps リンク、オーバーレイ、プロファイル、レコードを検証します。"""
21
+ del check_overlay
22
+ project = load_project(find_root())
23
+ result = doctor_project(project, check_records=check_records)
24
+ migration = check_migrations(project)
25
+ if not migration["ok"] and not allow_pending:
26
+ result["ok"] = False
27
+ result["errors"].extend(f"未適用マイグレーション: {item}" for item in migration["pending"])
28
+ if provider:
29
+ result["provider"] = {"checked": False, "message": "MVP doctor ではプロバイダコマンド実行は有効化されていません"}
30
+ if json_output:
31
+ typer.echo(json.dumps(result, indent=2, sort_keys=True))
32
+ else:
33
+ typer.echo("ok" if result["ok"] else "失敗")
34
+ for warning in result["warnings"]:
35
+ typer.echo(f"警告: {warning}")
36
+ for error in result["errors"]:
37
+ typer.echo(f"エラー: {error}")
38
+ if not result["ok"]:
39
+ raise typer.Exit(1)
40
+
41
+
42
+ def register(app: typer.Typer) -> None:
43
+ app.command("doctor")(doctor_command)
harnessops/cli/eval.py ADDED
@@ -0,0 +1,58 @@
1
+ from __future__ import annotations
2
+
3
+ import typer
4
+
5
+ from harnessops.core.evaluation import parse_scores, write_manual_eval
6
+ from harnessops.core.paths import find_root
7
+ from harnessops.core.project import load_project
8
+ from harnessops.core.records import find_record, read_record
9
+
10
+
11
+ def eval_command(
12
+ case: str | None = typer.Option(None, "--case"),
13
+ manual: bool = typer.Option(False, "--manual"),
14
+ all_cases: bool = typer.Option(False, "--all"),
15
+ experiment: str | None = typer.Option(None, "--experiment"),
16
+ score: list[str] = typer.Option(None, "--score"),
17
+ notes: str = typer.Option("", "--notes"),
18
+ ) -> None:
19
+ """評価ケースの多軸手動スコアカードを保存します。"""
20
+ root = find_root()
21
+ project = load_project(root)
22
+ cases = []
23
+ if all_cases:
24
+ cases = [path.stem.split("-", 1)[0] for path in sorted((project.overlay_dir / "records/eval-cases").glob("E*.md"))]
25
+ elif experiment:
26
+ try:
27
+ experiment_path = find_record(project, experiment)
28
+ frontmatter, _ = read_record(experiment_path)
29
+ cases = [str(item) for item in frontmatter.get("eval_cases", [])]
30
+ except FileNotFoundError:
31
+ typer.echo(f"experiment が見つかりません: {experiment}")
32
+ raise typer.Exit(1)
33
+ elif case:
34
+ cases = [case]
35
+ else:
36
+ typer.echo("--case または --all を指定してください")
37
+ raise typer.Exit(1)
38
+ if not cases:
39
+ typer.echo("評価ケースが見つかりません")
40
+ raise typer.Exit(1)
41
+ if not manual:
42
+ typer.echo("このコマンドには手動採点が必要です。--manual を指定してください")
43
+ raise typer.Exit(1)
44
+ try:
45
+ scores = parse_scores(score or [])
46
+ except ValueError as exc:
47
+ typer.echo(str(exc))
48
+ raise typer.Exit(1) from exc
49
+ outputs = []
50
+ for case_id in cases:
51
+ yml_path, md_path = write_manual_eval(project, case_id=case_id, scores=scores, notes=notes, experiment=experiment)
52
+ outputs.append(yml_path.relative_to(root).as_posix())
53
+ outputs.append(md_path.relative_to(root).as_posix())
54
+ typer.echo("\n".join(outputs))
55
+
56
+
57
+ def register(app: typer.Typer) -> None:
58
+ app.command("eval")(eval_command)
@@ -0,0 +1,116 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from pathlib import Path
5
+ from typing import Optional
6
+
7
+ import typer
8
+
9
+ from harnessops.core.paths import find_root
10
+ from harnessops.core.project import load_project
11
+ from harnessops.core.records import create_imported_feedback, next_id, read_record
12
+ from harnessops.core.render import refresh_views
13
+ from harnessops.core.sanitize import sanitize_text
14
+ from harnessops.profiles.registry import load_profile
15
+
16
+ feedback_app = typer.Typer(help="フィードバックバンドルをエクスポート/インポートします。")
17
+
18
+
19
+ @feedback_app.command("export")
20
+ def export_feedback(
21
+ target: Optional[str] = typer.Option(None, "--target"),
22
+ sanitize: bool = typer.Option(False, "--sanitize"),
23
+ format: str = typer.Option("markdown", "--format"), # noqa: A002
24
+ allow_private: bool = typer.Option(False, "--allow-private"),
25
+ ) -> None:
26
+ """プロジェクト側レコードからサニタイズ済み上流/メタフィードバックバンドルを生成します。"""
27
+ root = find_root()
28
+ project = load_project(root)
29
+ if not sanitize and not allow_private:
30
+ typer.echo("--allow-private なしの未サニタイズエクスポートは拒否します")
31
+ raise typer.Exit(1)
32
+ profile = load_profile(project.profile_id)
33
+ records = []
34
+ target_filter = target or "all"
35
+ for path in sorted((project.overlay_dir / "records/failures").glob("*.md")):
36
+ frontmatter, body = read_record(path)
37
+ disposition = frontmatter.get("disposition", {})
38
+ if disposition.get("type") not in {"target-upstream-candidate", "meta-harness-candidate", "protocol-candidate"}:
39
+ continue
40
+ if disposition.get("target") == target_filter or target_filter == "all":
41
+ records.append((path, frontmatter, body))
42
+ for rel in ["records/upstream-feedback", "records/meta-feedback"]:
43
+ for path in sorted((project.overlay_dir / rel).glob("*.md")):
44
+ frontmatter, body = read_record(path)
45
+ if frontmatter.get("target") == target_filter or target_filter == "all":
46
+ records.append((path, frontmatter, body))
47
+ if not records:
48
+ typer.echo("一致するフィードバックレコードがありません")
49
+ raise typer.Exit(1)
50
+ resolved_targets = sorted({str(record[1].get("target") or record[1].get("disposition", {}).get("target") or "unknown") for record in records})
51
+ export_target = target or ("mixed" if len(resolved_targets) != 1 else resolved_targets[0])
52
+ prefix = "MF" if export_target == "harnessops" else "UF"
53
+ out_dir = project.overlay_dir / "views" / "exported-feedback"
54
+ out_dir.mkdir(parents=True, exist_ok=True)
55
+ export_id = next_id(out_dir, prefix)
56
+ title = f"{export_target} へのフィードバック"
57
+ sections = []
58
+ for path, frontmatter, body in records:
59
+ sections.append(f"## 送信元 {frontmatter.get('id')}: {path.name}\n\n{body.strip()}\n")
60
+ bundle_body = "\n".join(sections)
61
+ if sanitize:
62
+ bundle_body = sanitize_text(bundle_body, root=root, profile=profile, allow_private=allow_private)
63
+ if format in {"issue", "github-issue"}:
64
+ bundle_body = "## Issue下書き\n\n" + bundle_body + "\n\n## 確認\n\nこれは下書きのみです。HarnessOps はリモートIssueを自動作成しません。\n"
65
+ frontmatter = {
66
+ "id": export_id,
67
+ "record_type": "meta_feedback" if prefix == "MF" else "upstream_feedback",
68
+ "created_at": records[0][1].get("created_at"),
69
+ "status": "draft",
70
+ "target": export_target,
71
+ "source_failure": records[0][1].get("id"),
72
+ "sanitized": bool(sanitize),
73
+ "visibility": "sanitized" if sanitize else "private-until-sanitized",
74
+ "format": format,
75
+ "included_targets": resolved_targets,
76
+ }
77
+ text = "---\n" + json.dumps(frontmatter, indent=2) + "\n---\n\n# " + title + "\n\n" + bundle_body
78
+ out_path = out_dir / f"{export_id}-{export_target}-feedback.md"
79
+ out_path.write_text(text, encoding="utf-8")
80
+ refresh_views(root, project.overlay_path)
81
+ typer.echo(out_path.relative_to(root).as_posix())
82
+
83
+
84
+ @feedback_app.command("import")
85
+ def import_feedback(
86
+ path: Optional[Path] = typer.Argument(None),
87
+ issue: Optional[int] = typer.Option(None, "--issue"),
88
+ repo: Optional[str] = typer.Option(None, "--repo"),
89
+ ) -> None:
90
+ """フィードバックバンドルをターゲット側 harness-lab にインポートします。"""
91
+ root = find_root()
92
+ project = load_project(root)
93
+ if project.overlay_mode not in {"upstream-lab", "meta-lab"}:
94
+ typer.echo("feedback import には upstream-lab または meta-lab mode が必要です")
95
+ raise typer.Exit(1)
96
+ if issue is not None:
97
+ source = {"id": f"ISSUE-{issue}", "record_type": "upstream_feedback", "issue": {"url": f"https://github.com/{repo or 'unknown'}/issues/{issue}"}}
98
+ body = f"{repo or 'unknown'} から GitHub issue {issue} をインポートしました。"
99
+ title = f"GitHub issue {issue}"
100
+ elif path is not None:
101
+ source_path = path if path.is_absolute() else root / path
102
+ source, body = read_record(source_path)
103
+ if source.get("record_type") not in {"upstream_feedback", "meta_feedback"} or not source.get("sanitized", False):
104
+ typer.echo("import にはサニタイズ済み upstream_feedback または meta_feedback バンドルが必要です")
105
+ raise typer.Exit(1)
106
+ title = source_path.stem
107
+ else:
108
+ typer.echo("バンドルパスまたは --issue を指定してください")
109
+ raise typer.Exit(1)
110
+ out_path = create_imported_feedback(project, source_record=source, body=body, title=title)
111
+ refresh_views(root, project.overlay_path)
112
+ typer.echo(out_path.relative_to(root).as_posix())
113
+
114
+
115
+ def register(app: typer.Typer) -> None:
116
+ app.add_typer(feedback_app, name="feedback")