outcomeci-cli 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.
- outcomeci/__init__.py +7 -0
- outcomeci/cli.py +140 -0
- outcomeci/config.py +142 -0
- outcomeci/local.py +318 -0
- outcomeci/manifest.py +52 -0
- outcomeci/outcome.py +265 -0
- outcomeci/process.py +87 -0
- outcomeci/repository.py +51 -0
- outcomeci/templates.py +126 -0
- outcomeci/twin.py +40 -0
- outcomeci_cli-0.1.0.dist-info/METADATA +72 -0
- outcomeci_cli-0.1.0.dist-info/RECORD +14 -0
- outcomeci_cli-0.1.0.dist-info/WHEEL +4 -0
- outcomeci_cli-0.1.0.dist-info/entry_points.txt +2 -0
outcomeci/__init__.py
ADDED
outcomeci/cli.py
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
"""OutcomeCI command-line interface."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import argparse
|
|
5
|
+
import json
|
|
6
|
+
import sys
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Sequence
|
|
9
|
+
|
|
10
|
+
from . import __version__
|
|
11
|
+
from .config import ConfigError, compile_workflow
|
|
12
|
+
from .local import advance as advance_local_outcome
|
|
13
|
+
from .local import begin as begin_local_outcome
|
|
14
|
+
from .local import compile_context
|
|
15
|
+
from .local import continue_run as continue_local_outcome
|
|
16
|
+
from .local import start as start_local_outcome
|
|
17
|
+
from .local import status as local_outcome_status
|
|
18
|
+
from .local import validate_artifacts
|
|
19
|
+
from .outcome import run as run_outcome
|
|
20
|
+
from .process import ExecutionError
|
|
21
|
+
from .repository import RepositoryError, initialize, update, validate
|
|
22
|
+
from .twin import TwinError, search
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def parser() -> argparse.ArgumentParser:
|
|
26
|
+
root = argparse.ArgumentParser(prog="oci", description="Standup and outcome workflows for OutcomeCI")
|
|
27
|
+
root.add_argument("--version", action="version", version=f"oci {__version__}")
|
|
28
|
+
commands = root.add_subparsers(dest="command", required=True)
|
|
29
|
+
for name in ("init", "update", "validate", "status"):
|
|
30
|
+
item = commands.add_parser(name)
|
|
31
|
+
item.add_argument("--dir", type=Path, default=Path.cwd())
|
|
32
|
+
if name == "init":
|
|
33
|
+
item.add_argument("--backend", choices=("outcomeci", "filesystem"), default="outcomeci")
|
|
34
|
+
outcome = commands.add_parser("outcome")
|
|
35
|
+
outcome_commands = outcome.add_subparsers(dest="outcome_command", required=True)
|
|
36
|
+
for name in ("validate", "compile"):
|
|
37
|
+
item = outcome_commands.add_parser(name)
|
|
38
|
+
item.add_argument("config", nargs="?", type=Path, default=Path("outcome.yml"))
|
|
39
|
+
if name == "compile":
|
|
40
|
+
item.add_argument("--phase", choices=("intake", "plan", "tasks", "implementation", "pr"))
|
|
41
|
+
item.add_argument("--run")
|
|
42
|
+
item.add_argument("--workspace", type=Path, default=Path.cwd())
|
|
43
|
+
run = outcome_commands.add_parser("run")
|
|
44
|
+
run.add_argument("--claim", required=True, type=Path)
|
|
45
|
+
run.add_argument("--workspace", type=Path, default=Path("/workspace"))
|
|
46
|
+
start = outcome_commands.add_parser("start")
|
|
47
|
+
start.add_argument("intent")
|
|
48
|
+
start.add_argument("--workspace", type=Path, default=Path.cwd())
|
|
49
|
+
start.add_argument("--config", type=Path)
|
|
50
|
+
start.add_argument("--agent", choices=("codex", "claude"))
|
|
51
|
+
start.add_argument("--model")
|
|
52
|
+
continuation = outcome_commands.add_parser("continue")
|
|
53
|
+
continuation.add_argument("run_id")
|
|
54
|
+
continuation.add_argument("--approve", action="store_true")
|
|
55
|
+
continuation.add_argument("--workspace", type=Path, default=Path.cwd())
|
|
56
|
+
continuation.add_argument("--config", type=Path)
|
|
57
|
+
continuation.add_argument("--agent", choices=("codex", "claude"))
|
|
58
|
+
continuation.add_argument("--model")
|
|
59
|
+
outcome_status = outcome_commands.add_parser("status")
|
|
60
|
+
outcome_status.add_argument("run_id", nargs="?")
|
|
61
|
+
outcome_status.add_argument("--workspace", type=Path, default=Path.cwd())
|
|
62
|
+
begin_command = outcome_commands.add_parser("begin")
|
|
63
|
+
begin_command.add_argument("intent")
|
|
64
|
+
begin_command.add_argument("--workspace", type=Path, default=Path.cwd())
|
|
65
|
+
begin_command.add_argument("--config", type=Path)
|
|
66
|
+
validate_command = outcome_commands.add_parser("validate-artifacts")
|
|
67
|
+
validate_command.add_argument("--run")
|
|
68
|
+
validate_command.add_argument("--workspace", type=Path, default=Path.cwd())
|
|
69
|
+
validate_command.add_argument("--config", type=Path)
|
|
70
|
+
advance_command = outcome_commands.add_parser("advance")
|
|
71
|
+
advance_command.add_argument("--run")
|
|
72
|
+
advance_command.add_argument("--approve", action="store_true")
|
|
73
|
+
advance_command.add_argument("--workspace", type=Path, default=Path.cwd())
|
|
74
|
+
advance_command.add_argument("--config", type=Path)
|
|
75
|
+
twin = commands.add_parser("twin")
|
|
76
|
+
twin_commands = twin.add_subparsers(dest="twin_command", required=True)
|
|
77
|
+
twin_search = twin_commands.add_parser("search")
|
|
78
|
+
twin_search.add_argument("query")
|
|
79
|
+
twin_search.add_argument("--repository-id", action="append", default=[])
|
|
80
|
+
twin_search.add_argument("--limit", type=int, default=10)
|
|
81
|
+
twin_search.add_argument("--component-limit", type=int, default=20)
|
|
82
|
+
return root
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
86
|
+
args = parser().parse_args(argv)
|
|
87
|
+
try:
|
|
88
|
+
if args.command == "init":
|
|
89
|
+
print(json.dumps({"created": initialize(args.dir, args.backend)}, indent=2))
|
|
90
|
+
elif args.command == "update":
|
|
91
|
+
print(json.dumps({"created": update(args.dir)}, indent=2))
|
|
92
|
+
elif args.command == "validate":
|
|
93
|
+
result = validate(args.dir)
|
|
94
|
+
print(json.dumps({"valid": True, "workflow_revision": result["workflow_revision"]}, indent=2))
|
|
95
|
+
elif args.command == "status":
|
|
96
|
+
result = validate(args.dir)
|
|
97
|
+
print(json.dumps({"initialized": True, "workflow_revision": result["workflow_revision"], "path": str(args.dir.resolve())}, indent=2))
|
|
98
|
+
elif args.command == "outcome" and args.outcome_command == "validate":
|
|
99
|
+
result = compile_workflow(args.config)
|
|
100
|
+
print(json.dumps({"valid": True, "workflow_revision": result["workflow_revision"]}, indent=2, sort_keys=True))
|
|
101
|
+
elif args.command == "outcome" and args.outcome_command == "compile":
|
|
102
|
+
if args.run:
|
|
103
|
+
result = compile_context(args.workspace.resolve(), args.config.resolve(), args.run)
|
|
104
|
+
else:
|
|
105
|
+
result = compile_workflow(args.config)
|
|
106
|
+
if args.phase:
|
|
107
|
+
phase = result["instructions"]["phases"].get(args.phase)
|
|
108
|
+
if phase is None:
|
|
109
|
+
raise ExecutionError(f"workflow has no instructions for {args.phase}")
|
|
110
|
+
result = {**result, "instructions": {"standup": result["instructions"]["standup"], "phase": phase}}
|
|
111
|
+
print(json.dumps(result, indent=2, sort_keys=True))
|
|
112
|
+
elif args.command == "outcome" and args.outcome_command == "run":
|
|
113
|
+
print(json.dumps(run_outcome(args.claim, args.workspace), separators=(",", ":")))
|
|
114
|
+
elif args.command == "outcome" and args.outcome_command == "start":
|
|
115
|
+
config = args.config or args.workspace / "outcome.yml"
|
|
116
|
+
print(json.dumps(start_local_outcome(args.workspace.resolve(), config.resolve(), args.intent, agent=args.agent, model=args.model), indent=2, sort_keys=True))
|
|
117
|
+
elif args.command == "outcome" and args.outcome_command == "continue":
|
|
118
|
+
config = args.config or args.workspace / "outcome.yml"
|
|
119
|
+
print(json.dumps(continue_local_outcome(args.workspace.resolve(), config.resolve(), args.run_id, args.approve, agent=args.agent, model=args.model), indent=2, sort_keys=True))
|
|
120
|
+
elif args.command == "outcome" and args.outcome_command == "status":
|
|
121
|
+
print(json.dumps(local_outcome_status(args.workspace.resolve(), args.run_id), indent=2, sort_keys=True))
|
|
122
|
+
elif args.command == "outcome" and args.outcome_command == "begin":
|
|
123
|
+
config = args.config or args.workspace / "outcome.yml"
|
|
124
|
+
print(json.dumps(begin_local_outcome(args.workspace.resolve(), config.resolve(), args.intent), indent=2, sort_keys=True))
|
|
125
|
+
elif args.command == "outcome" and args.outcome_command == "validate-artifacts":
|
|
126
|
+
config = args.config or args.workspace / "outcome.yml"
|
|
127
|
+
print(json.dumps(validate_artifacts(args.workspace.resolve(), config.resolve(), args.run), indent=2, sort_keys=True))
|
|
128
|
+
elif args.command == "outcome" and args.outcome_command == "advance":
|
|
129
|
+
config = args.config or args.workspace / "outcome.yml"
|
|
130
|
+
print(json.dumps(advance_local_outcome(args.workspace.resolve(), config.resolve(), args.run, args.approve), indent=2, sort_keys=True))
|
|
131
|
+
elif args.command == "twin" and args.twin_command == "search":
|
|
132
|
+
print(json.dumps(search(args.query, args.repository_id, args.limit, args.component_limit), indent=2, sort_keys=True))
|
|
133
|
+
return 0
|
|
134
|
+
except (ConfigError, RepositoryError, TwinError, ExecutionError) as exc:
|
|
135
|
+
print(f"oci: {exc}", file=sys.stderr)
|
|
136
|
+
return 1 if isinstance(exc, ExecutionError) and exc.retryable else 2
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
if __name__ == "__main__":
|
|
140
|
+
raise SystemExit(main())
|
outcomeci/config.py
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"""Validation and deterministic compilation for outcome.yml."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import hashlib
|
|
5
|
+
import fnmatch
|
|
6
|
+
import json
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
import yaml
|
|
11
|
+
|
|
12
|
+
from . import __version__
|
|
13
|
+
|
|
14
|
+
PHASES = ("intake", "plan", "tasks", "implementation", "pr")
|
|
15
|
+
RUNNERS = {"codex", "claude"}
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class ConfigError(ValueError):
|
|
19
|
+
pass
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _mapping(value: Any, path: str) -> dict[str, Any]:
|
|
23
|
+
if not isinstance(value, dict):
|
|
24
|
+
raise ConfigError(f"{path} must be a mapping")
|
|
25
|
+
return value
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def load(path: Path) -> dict[str, Any]:
|
|
29
|
+
try:
|
|
30
|
+
root = _mapping(yaml.safe_load(path.read_text(encoding="utf-8")), "document")
|
|
31
|
+
except (OSError, yaml.YAMLError) as exc:
|
|
32
|
+
raise ConfigError(f"could not read {path}: {exc}") from exc
|
|
33
|
+
if root.get("apiVersion") != "outcomeci.dev/v1alpha1":
|
|
34
|
+
raise ConfigError("apiVersion must be outcomeci.dev/v1alpha1")
|
|
35
|
+
if root.get("kind") != "OutcomeWorkflow":
|
|
36
|
+
raise ConfigError("kind must be OutcomeWorkflow")
|
|
37
|
+
metadata = _mapping(root.get("metadata"), "metadata")
|
|
38
|
+
if not isinstance(metadata.get("name"), str) or not metadata["name"].strip():
|
|
39
|
+
raise ConfigError("metadata.name is required")
|
|
40
|
+
spec = _mapping(root.get("spec"), "spec")
|
|
41
|
+
for field, choices in (("backend", {"outcomeci", "filesystem"}), ("context", {"outcomeci", "http", "filesystem"})):
|
|
42
|
+
value = _mapping(spec.get(field, {"provider": "outcomeci"}), f"spec.{field}")
|
|
43
|
+
if value.get("provider", "outcomeci") not in choices:
|
|
44
|
+
raise ConfigError(f"unsupported spec.{field}.provider")
|
|
45
|
+
if field == "context":
|
|
46
|
+
for patterns_name in ("include", "exclude"):
|
|
47
|
+
patterns = value.get(patterns_name, [])
|
|
48
|
+
if not isinstance(patterns, list) or not all(isinstance(pattern, str) and pattern.strip() for pattern in patterns):
|
|
49
|
+
raise ConfigError(f"spec.context.{patterns_name} must be a list of non-empty glob strings")
|
|
50
|
+
instructions = _mapping(spec.get("instructions"), "spec.instructions")
|
|
51
|
+
if not isinstance(instructions.get("standup"), str):
|
|
52
|
+
raise ConfigError("spec.instructions.standup is required")
|
|
53
|
+
agents = _mapping(spec.get("agents", {}), "spec.agents")
|
|
54
|
+
default = _mapping(agents.get("default", {}), "spec.agents.default")
|
|
55
|
+
phases = _mapping(agents.get("phases", {}), "spec.agents.phases")
|
|
56
|
+
unknown = set(phases) - set(PHASES)
|
|
57
|
+
if unknown:
|
|
58
|
+
raise ConfigError(f"unknown outcome phases: {', '.join(sorted(unknown))}")
|
|
59
|
+
for name, policy in (("default", default), *phases.items()):
|
|
60
|
+
item = _mapping(policy, f"spec.agents.{name}")
|
|
61
|
+
if item.get("runner") is not None and item["runner"] not in RUNNERS:
|
|
62
|
+
raise ConfigError(f"spec.agents.{name}.runner must be codex or claude")
|
|
63
|
+
if item.get("model") is not None and not str(item["model"]).strip():
|
|
64
|
+
raise ConfigError(f"spec.agents.{name}.model must be non-empty")
|
|
65
|
+
if name != "default" and not isinstance(item.get("instructions"), str):
|
|
66
|
+
raise ConfigError(f"spec.agents.{name}.instructions is required")
|
|
67
|
+
connections = spec.get("connections", [])
|
|
68
|
+
if not isinstance(connections, list):
|
|
69
|
+
raise ConfigError("spec.connections must be a list")
|
|
70
|
+
refs: set[str] = set()
|
|
71
|
+
for index, value in enumerate(connections):
|
|
72
|
+
item = _mapping(value, f"spec.connections[{index}]")
|
|
73
|
+
ref = item.get("ref")
|
|
74
|
+
if not isinstance(ref, str) or not ref or ref in refs:
|
|
75
|
+
raise ConfigError("connection references must be unique non-empty strings")
|
|
76
|
+
refs.add(ref)
|
|
77
|
+
return root
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _instruction(root: Path, relative: str) -> dict[str, str]:
|
|
81
|
+
path = (root / relative).resolve()
|
|
82
|
+
try:
|
|
83
|
+
path.relative_to(root.resolve())
|
|
84
|
+
except ValueError as exc:
|
|
85
|
+
raise ConfigError("instruction path escapes repository") from exc
|
|
86
|
+
try:
|
|
87
|
+
content = path.read_text(encoding="utf-8")
|
|
88
|
+
except OSError as exc:
|
|
89
|
+
raise ConfigError(f"could not read instruction {relative}: {exc}") from exc
|
|
90
|
+
if not content.strip():
|
|
91
|
+
raise ConfigError(f"instruction {relative} is empty")
|
|
92
|
+
return {"path": relative, "sha256": hashlib.sha256(content.encode()).hexdigest(), "content": content}
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _excluded(relative: str, patterns: list[str]) -> bool:
|
|
96
|
+
return any(
|
|
97
|
+
fnmatch.fnmatch(relative, pattern)
|
|
98
|
+
or (pattern.endswith("/**") and (relative == pattern[:-3] or relative.startswith(pattern[:-2])))
|
|
99
|
+
for pattern in patterns
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _filesystem_context(root: Path, context: dict[str, Any]) -> list[dict[str, Any]]:
|
|
104
|
+
includes = context.get("include", [])
|
|
105
|
+
excludes = context.get("exclude", [])
|
|
106
|
+
paths: dict[str, Path] = {}
|
|
107
|
+
for pattern in includes:
|
|
108
|
+
if pattern.endswith("/**"):
|
|
109
|
+
base = root / pattern[:-3]
|
|
110
|
+
matches = base.rglob("*") if base.is_dir() else []
|
|
111
|
+
else:
|
|
112
|
+
matches = root.glob(pattern)
|
|
113
|
+
for path in matches:
|
|
114
|
+
if not path.is_file():
|
|
115
|
+
continue
|
|
116
|
+
resolved = path.resolve()
|
|
117
|
+
try:
|
|
118
|
+
relative = resolved.relative_to(root.resolve()).as_posix()
|
|
119
|
+
except ValueError as exc:
|
|
120
|
+
raise ConfigError(f"context path escapes repository: {path}") from exc
|
|
121
|
+
if not _excluded(relative, excludes):
|
|
122
|
+
paths[relative] = resolved
|
|
123
|
+
if len(paths) > 5000:
|
|
124
|
+
raise ConfigError("filesystem context exceeds 5000 files")
|
|
125
|
+
return [
|
|
126
|
+
{"path": relative, "byte_size": path.stat().st_size, "sha256": hashlib.sha256(path.read_bytes()).hexdigest()}
|
|
127
|
+
for relative, path in sorted(paths.items())
|
|
128
|
+
]
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def compile_workflow(path: Path) -> dict[str, Any]:
|
|
132
|
+
document = load(path)
|
|
133
|
+
spec = document["spec"]
|
|
134
|
+
resolved = {"standup": _instruction(path.parent, spec["instructions"]["standup"]), "phases": {}}
|
|
135
|
+
for phase, policy in spec.get("agents", {}).get("phases", {}).items():
|
|
136
|
+
resolved["phases"][phase] = _instruction(path.parent, policy["instructions"])
|
|
137
|
+
normalized = json.loads(json.dumps(document, sort_keys=True, separators=(",", ":")))
|
|
138
|
+
context = spec.get("context", {"provider": "outcomeci"})
|
|
139
|
+
context_files = _filesystem_context(path.parent, context) if context.get("provider") == "filesystem" else []
|
|
140
|
+
revision_input = {"workflow": normalized, "instructions": resolved, "context": {"provider": context.get("provider", "outcomeci"), "files": context_files}}
|
|
141
|
+
revision = hashlib.sha256(json.dumps(revision_input, sort_keys=True, separators=(",", ":")).encode()).hexdigest()
|
|
142
|
+
return {"schema_version": "outcomeci.workflow/v1alpha1", "engine_version": "1", "engine_package_version": __version__, "workflow_revision": revision, **revision_input}
|
outcomeci/local.py
ADDED
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
"""Filesystem-backed execution of an OutcomeWorkflow with a local agent."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import hashlib
|
|
5
|
+
import json
|
|
6
|
+
import re
|
|
7
|
+
import subprocess
|
|
8
|
+
import os
|
|
9
|
+
from datetime import datetime, timezone
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from .config import compile_workflow
|
|
14
|
+
from .manifest import build_manifest
|
|
15
|
+
from .outcome import _expected, _select_sessions, _session_details, _transcripts, _validate_trajectory
|
|
16
|
+
from .process import ExecutionError, invoke
|
|
17
|
+
|
|
18
|
+
PHASES = ("intake", "plan", "tasks")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _id(intent: str) -> str:
|
|
22
|
+
stamp = datetime.now(timezone.utc).strftime("%Y%m%d%H%M%S%f")
|
|
23
|
+
slug = re.sub(r"[^a-z0-9]+", "-", intent.casefold()).strip("-")[:36] or "outcome"
|
|
24
|
+
return f"{stamp}-{slug}"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _policy(compiled: dict[str, Any], phase: str, agent: str | None, model: str | None) -> tuple[str, str | None]:
|
|
28
|
+
agents = compiled["workflow"]["spec"].get("agents", {})
|
|
29
|
+
default = agents.get("default", {})
|
|
30
|
+
selected = agents.get("phases", {}).get(phase, {})
|
|
31
|
+
runner = agent or selected.get("runner") or default.get("runner")
|
|
32
|
+
chosen_model = model or selected.get("model") or default.get("model")
|
|
33
|
+
if runner not in {"codex", "claude"}:
|
|
34
|
+
raise ExecutionError(f"no supported agent configured for {phase}")
|
|
35
|
+
return runner, chosen_model
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _record(root: Path, run_id: str) -> Path:
|
|
39
|
+
return root / ".outcomeci" / "outcomes" / run_id / "run.json"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _read(root: Path, run_id: str) -> dict[str, Any]:
|
|
43
|
+
try:
|
|
44
|
+
value = json.loads(_record(root, run_id).read_text(encoding="utf-8"))
|
|
45
|
+
except (OSError, json.JSONDecodeError) as exc:
|
|
46
|
+
raise ExecutionError(f"local outcome {run_id!r} was not found") from exc
|
|
47
|
+
if not isinstance(value, dict):
|
|
48
|
+
raise ExecutionError("invalid local outcome state")
|
|
49
|
+
return value
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _write(root: Path, state: dict[str, Any]) -> None:
|
|
53
|
+
path = _record(root, state["run_id"])
|
|
54
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
55
|
+
state["updated_at"] = datetime.now(timezone.utc).isoformat()
|
|
56
|
+
path.write_text(json.dumps(state, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _local_revision(root: Path) -> str | None:
|
|
60
|
+
result = subprocess.run(
|
|
61
|
+
["git", "rev-parse", "HEAD"], cwd=root, text=True, capture_output=True, check=False
|
|
62
|
+
)
|
|
63
|
+
return result.stdout.strip() if result.returncode == 0 else None
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _interactive_session(root: Path, compiled: dict[str, Any]) -> dict[str, Any]:
|
|
67
|
+
codex_id = os.environ.get("CODEX_SESSION_ID") or os.environ.get("CODEX_THREAD_ID")
|
|
68
|
+
claude_id = os.environ.get("CLAUDE_CODE_SESSION_ID") or os.environ.get("CLAUDE_SESSION_ID")
|
|
69
|
+
if codex_id:
|
|
70
|
+
provider, session_id = "codex", codex_id
|
|
71
|
+
elif claude_id or os.environ.get("CLAUDECODE"):
|
|
72
|
+
provider, session_id = "claude", claude_id
|
|
73
|
+
else:
|
|
74
|
+
provider = compiled["workflow"]["spec"].get("agents", {}).get("default", {}).get("runner", "codex")
|
|
75
|
+
session_id = None
|
|
76
|
+
matches = _select_sessions(provider, session_id, root, None)
|
|
77
|
+
if matches and not session_id:
|
|
78
|
+
session_id = _session_details(matches[0], provider)[0]
|
|
79
|
+
return {
|
|
80
|
+
"provider": provider,
|
|
81
|
+
"session_id": session_id,
|
|
82
|
+
"byte_offset": matches[0].stat().st_size if matches else 0,
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _execute(root: Path, config: Path, state: dict[str, Any], *, agent: str | None = None, model: str | None = None) -> dict[str, Any]:
|
|
87
|
+
compiled = compile_workflow(config)
|
|
88
|
+
if compiled["workflow"]["spec"]["backend"].get("provider") != "filesystem":
|
|
89
|
+
raise ExecutionError("local execution requires spec.backend.provider: filesystem")
|
|
90
|
+
phase = state["phase"]
|
|
91
|
+
runner, chosen_model = _policy(compiled, phase, agent, model)
|
|
92
|
+
outcome_root = root / ".outcomeci" / "outcomes" / state["run_id"]
|
|
93
|
+
repository = root.name
|
|
94
|
+
shared = compiled["instructions"]["standup"]["content"]
|
|
95
|
+
instructions = compiled["instructions"]["phases"][phase]["content"]
|
|
96
|
+
local_revision = f"filesystem:{compiled['workflow_revision']}"
|
|
97
|
+
context = {
|
|
98
|
+
"run_id": state["run_id"], "phase": phase, "intent": state["intent"],
|
|
99
|
+
"repository": {"name": repository, "checkout": str(root)},
|
|
100
|
+
"prior_phases": state.get("completed_phases", []),
|
|
101
|
+
"workflow_revision": compiled["workflow_revision"],
|
|
102
|
+
"context_files": compiled["context"]["files"],
|
|
103
|
+
}
|
|
104
|
+
intake_contract = ""
|
|
105
|
+
if phase == "intake":
|
|
106
|
+
intake_contract = f"""
|
|
107
|
+
Write intake/trajectory.json with schema_version \"1\", ontology_revision_id
|
|
108
|
+
\"{local_revision}\", and at least one target. The local target must use
|
|
109
|
+
repository_id \"local:{repository}\", repository \"{repository}\", a non-empty
|
|
110
|
+
rationale, and a candidates array following the stable role and disposition
|
|
111
|
+
contract above. Use paths relative to this repository.
|
|
112
|
+
"""
|
|
113
|
+
prompt = f"{shared}\n\n{instructions}\n\nThis is a filesystem-backed local Standup. Work in {root}. Write durable artifacts beneath {outcome_root}. During intake, plan, and tasks, do not modify product source files. There is no OutcomeCI Cloud or Digital Twin; inspect the local repository directly.\n{intake_contract}\n{json.dumps(context, separators=(',', ':'))}"
|
|
114
|
+
state.update({"status": "running", "agent": runner, "model": chosen_model, "workflow_revision": compiled["workflow_revision"]})
|
|
115
|
+
_write(root, state)
|
|
116
|
+
try:
|
|
117
|
+
summary = invoke(runner, chosen_model, prompt, root, 7200)
|
|
118
|
+
expected = _expected(outcome_root, [repository], phase)
|
|
119
|
+
if any(not path.is_file() or not path.read_text(encoding="utf-8").strip() for path in expected):
|
|
120
|
+
raise ExecutionError("agent did not produce the complete local outcome artifact set")
|
|
121
|
+
if phase == "intake":
|
|
122
|
+
trajectory = json.loads((outcome_root / "intake" / "trajectory.json").read_text(encoding="utf-8"))
|
|
123
|
+
_validate_trajectory(trajectory, {"intent_context": {"ontology_revision_id": local_revision}})
|
|
124
|
+
transcripts = _transcripts(runner, outcome_root, phase)
|
|
125
|
+
except (ExecutionError, OSError, json.JSONDecodeError) as exc:
|
|
126
|
+
state.update({"status": "error", "error": str(exc)})
|
|
127
|
+
_write(root, state)
|
|
128
|
+
if isinstance(exc, ExecutionError):
|
|
129
|
+
raise
|
|
130
|
+
raise ExecutionError(f"invalid local outcome artifacts: {exc}") from exc
|
|
131
|
+
state["completed_phases"] = [*state.get("completed_phases", []), phase]
|
|
132
|
+
state["status"] = "awaiting_confirmation" if phase != "tasks" else "ready_for_implementation"
|
|
133
|
+
state["summary"] = summary[-1000:]
|
|
134
|
+
state["usage_records"] = transcripts["usage_records"]
|
|
135
|
+
state.pop("error", None)
|
|
136
|
+
constitution = root / ".outcomeci" / "constitution.md"
|
|
137
|
+
manifest = build_manifest(
|
|
138
|
+
outcome_root=outcome_root,
|
|
139
|
+
artifact_base=root,
|
|
140
|
+
run_id=state["run_id"],
|
|
141
|
+
workflow_run_id=None,
|
|
142
|
+
trajectory_version=None,
|
|
143
|
+
phase=phase,
|
|
144
|
+
workflow_revision=compiled["workflow_revision"],
|
|
145
|
+
backend_provider="filesystem",
|
|
146
|
+
state_repository=None,
|
|
147
|
+
context_provider=compiled["workflow"]["spec"]["context"].get("provider", "filesystem"),
|
|
148
|
+
context_revision_id=local_revision,
|
|
149
|
+
constitution_sha256=hashlib.sha256(constitution.read_bytes()).hexdigest(),
|
|
150
|
+
repository_base_commits={repository: _local_revision(root)},
|
|
151
|
+
runner=runner,
|
|
152
|
+
model=chosen_model,
|
|
153
|
+
transcript=transcripts,
|
|
154
|
+
)
|
|
155
|
+
(outcome_root / "manifest.json").write_text(json.dumps(manifest, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
|
156
|
+
_write(root, state)
|
|
157
|
+
return state
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def start(root: Path, config: Path, intent: str, *, agent: str | None = None, model: str | None = None) -> dict[str, Any]:
|
|
161
|
+
if not intent.strip():
|
|
162
|
+
raise ExecutionError("intent is required")
|
|
163
|
+
state = {"schema_version": 1, "run_id": _id(intent), "intent": intent.strip(), "phase": "intake", "status": "queued", "completed_phases": [], "created_at": datetime.now(timezone.utc).isoformat()}
|
|
164
|
+
return _execute(root, config, state, agent=agent, model=model)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def begin(root: Path, config: Path, intent: str) -> dict[str, Any]:
|
|
168
|
+
"""Create an interactive run without launching a child agent."""
|
|
169
|
+
if not intent.strip():
|
|
170
|
+
raise ExecutionError("intent is required")
|
|
171
|
+
compiled = compile_workflow(config)
|
|
172
|
+
if compiled["workflow"]["spec"]["backend"].get("provider") != "filesystem":
|
|
173
|
+
raise ExecutionError("interactive local execution requires spec.backend.provider: filesystem")
|
|
174
|
+
session = _interactive_session(root, compiled)
|
|
175
|
+
state = {
|
|
176
|
+
"schema_version": 1,
|
|
177
|
+
"run_id": _id(intent),
|
|
178
|
+
"intent": intent.strip(),
|
|
179
|
+
"phase": "intake",
|
|
180
|
+
"status": "awaiting_agent",
|
|
181
|
+
"completed_phases": [],
|
|
182
|
+
"workflow_revision": compiled["workflow_revision"],
|
|
183
|
+
"interactive_session": session,
|
|
184
|
+
"created_at": datetime.now(timezone.utc).isoformat(),
|
|
185
|
+
}
|
|
186
|
+
_write(root, state)
|
|
187
|
+
return {**state, "outcome_root": str(_record(root, state["run_id"]).parent)}
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def compile_context(root: Path, config: Path, run_id: str | None) -> dict[str, Any]:
|
|
191
|
+
state = _read(root, run_id) if run_id else status(root, None)
|
|
192
|
+
if state.get("status") == "no_runs":
|
|
193
|
+
raise ExecutionError("no local outcome exists; run `oci outcome begin` first")
|
|
194
|
+
compiled = compile_workflow(config)
|
|
195
|
+
phase = state["phase"]
|
|
196
|
+
if phase not in compiled["instructions"]["phases"]:
|
|
197
|
+
raise ExecutionError(f"workflow has no instructions for {phase}")
|
|
198
|
+
runner, model = _policy(compiled, phase, state.get("interactive_session", {}).get("provider"), None)
|
|
199
|
+
context_revision = f"filesystem:{compiled['workflow_revision']}"
|
|
200
|
+
phase_contract: dict[str, Any] | None = None
|
|
201
|
+
if phase == "intake":
|
|
202
|
+
phase_contract = {
|
|
203
|
+
"artifact": "intake/trajectory.json",
|
|
204
|
+
"schema_version": "1",
|
|
205
|
+
"ontology_revision_id": context_revision,
|
|
206
|
+
"target": {
|
|
207
|
+
"repository_id": f"local:{root.name}",
|
|
208
|
+
"repository": root.name,
|
|
209
|
+
"required": ["rationale", "candidates"],
|
|
210
|
+
},
|
|
211
|
+
}
|
|
212
|
+
return {
|
|
213
|
+
"schema_version": "outcomeci.interactive-context/v1alpha1",
|
|
214
|
+
"run": state,
|
|
215
|
+
"outcome_root": str(_record(root, state["run_id"]).parent),
|
|
216
|
+
"runner": {"provider": runner, "model": model or "provider-default"},
|
|
217
|
+
"context": compiled["context"],
|
|
218
|
+
"phase_contract": phase_contract,
|
|
219
|
+
"instructions": {
|
|
220
|
+
"standup": compiled["instructions"]["standup"],
|
|
221
|
+
"phase": compiled["instructions"]["phases"][phase],
|
|
222
|
+
},
|
|
223
|
+
"workflow_revision": compiled["workflow_revision"],
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def validate_artifacts(root: Path, config: Path, run_id: str | None) -> dict[str, Any]:
|
|
228
|
+
state = _read(root, run_id) if run_id else status(root, None)
|
|
229
|
+
if state.get("status") == "no_runs":
|
|
230
|
+
raise ExecutionError("no local outcome exists")
|
|
231
|
+
compiled = compile_workflow(config)
|
|
232
|
+
phase = state["phase"]
|
|
233
|
+
repository = root.name
|
|
234
|
+
outcome_root = _record(root, state["run_id"]).parent
|
|
235
|
+
expected = _expected(outcome_root, [repository], phase)
|
|
236
|
+
missing = [str(path.relative_to(root)) for path in expected if not path.is_file() or not path.read_text(encoding="utf-8").strip()]
|
|
237
|
+
if missing:
|
|
238
|
+
raise ExecutionError(f"incomplete {phase} artifacts: {', '.join(missing)}")
|
|
239
|
+
standup = (outcome_root / "standup.md").read_text(encoding="utf-8")
|
|
240
|
+
if "# Standup:" not in standup or "**Status**: active" not in standup:
|
|
241
|
+
raise ExecutionError("agent did not produce a valid active Standup")
|
|
242
|
+
context_revision = f"filesystem:{compiled['workflow_revision']}"
|
|
243
|
+
if phase == "intake":
|
|
244
|
+
try:
|
|
245
|
+
trajectory = json.loads((outcome_root / "intake" / "trajectory.json").read_text(encoding="utf-8"))
|
|
246
|
+
except json.JSONDecodeError as exc:
|
|
247
|
+
raise ExecutionError("intake trajectory is not valid JSON") from exc
|
|
248
|
+
_validate_trajectory(trajectory, {"intent_context": {"ontology_revision_id": context_revision}})
|
|
249
|
+
session = state.get("interactive_session", {})
|
|
250
|
+
runner, model = _policy(compiled, phase, session.get("provider"), None)
|
|
251
|
+
transcript = _transcripts(
|
|
252
|
+
runner,
|
|
253
|
+
outcome_root,
|
|
254
|
+
phase,
|
|
255
|
+
session_id=session.get("session_id"),
|
|
256
|
+
byte_offset=int(session.get("byte_offset") or 0),
|
|
257
|
+
workspace=root,
|
|
258
|
+
since=state.get("created_at"),
|
|
259
|
+
)
|
|
260
|
+
matches = _select_sessions(runner, session.get("session_id"), root, state.get("created_at"))
|
|
261
|
+
if matches:
|
|
262
|
+
session["session_id"] = session.get("session_id") or _session_details(matches[0], runner)[0]
|
|
263
|
+
session["byte_offset"] = matches[0].stat().st_size
|
|
264
|
+
state["interactive_session"] = session
|
|
265
|
+
constitution = root / ".outcomeci" / "constitution.md"
|
|
266
|
+
manifest = build_manifest(
|
|
267
|
+
outcome_root=outcome_root, artifact_base=root, run_id=state["run_id"],
|
|
268
|
+
workflow_run_id=None, trajectory_version=None, phase=phase,
|
|
269
|
+
workflow_revision=compiled["workflow_revision"], backend_provider="filesystem",
|
|
270
|
+
state_repository=None, context_provider="filesystem", context_revision_id=context_revision,
|
|
271
|
+
constitution_sha256=hashlib.sha256(constitution.read_bytes()).hexdigest(),
|
|
272
|
+
repository_base_commits={repository: _local_revision(root)}, runner=runner,
|
|
273
|
+
model=model, transcript=transcript,
|
|
274
|
+
)
|
|
275
|
+
(outcome_root / "manifest.json").write_text(json.dumps(manifest, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
|
276
|
+
completed = list(state.get("completed_phases", []))
|
|
277
|
+
if phase not in completed:
|
|
278
|
+
completed.append(phase)
|
|
279
|
+
state.update({
|
|
280
|
+
"completed_phases": completed,
|
|
281
|
+
"status": "ready_for_implementation" if phase == "tasks" else "awaiting_confirmation",
|
|
282
|
+
"workflow_revision": compiled["workflow_revision"],
|
|
283
|
+
})
|
|
284
|
+
_write(root, state)
|
|
285
|
+
return {"valid": True, "run_id": state["run_id"], "phase": phase, "status": state["status"], "manifest": manifest}
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def advance(root: Path, config: Path, run_id: str | None, approve: bool) -> dict[str, Any]:
|
|
289
|
+
state = _read(root, run_id) if run_id else status(root, None)
|
|
290
|
+
if not approve:
|
|
291
|
+
raise ExecutionError("advancing requires explicit --approve")
|
|
292
|
+
if state.get("status") != "awaiting_confirmation":
|
|
293
|
+
raise ExecutionError(f"outcome cannot advance from {state.get('status')}")
|
|
294
|
+
state["phase"] = PHASES[PHASES.index(state["phase"]) + 1]
|
|
295
|
+
state["status"] = "awaiting_agent"
|
|
296
|
+
state["workflow_revision"] = compile_workflow(config)["workflow_revision"]
|
|
297
|
+
_write(root, state)
|
|
298
|
+
return {**state, "outcome_root": str(_record(root, state["run_id"]).parent)}
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def continue_run(root: Path, config: Path, run_id: str, approve: bool, *, agent: str | None = None, model: str | None = None) -> dict[str, Any]:
|
|
302
|
+
state = _read(root, run_id)
|
|
303
|
+
if state.get("status") != "awaiting_confirmation":
|
|
304
|
+
raise ExecutionError(f"outcome cannot continue from {state.get('status')}")
|
|
305
|
+
if not approve:
|
|
306
|
+
raise ExecutionError("continuation requires explicit --approve")
|
|
307
|
+
current = state["phase"]
|
|
308
|
+
state["phase"] = PHASES[PHASES.index(current) + 1]
|
|
309
|
+
state["status"] = "queued"
|
|
310
|
+
_write(root, state)
|
|
311
|
+
return _execute(root, config, state, agent=agent, model=model)
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def status(root: Path, run_id: str | None) -> dict[str, Any]:
|
|
315
|
+
if run_id:
|
|
316
|
+
return _read(root, run_id)
|
|
317
|
+
records = sorted((root / ".outcomeci" / "outcomes").glob("*/run.json"), reverse=True)
|
|
318
|
+
return _read(root, records[0].parent.name) if records else {"status": "no_runs"}
|
outcomeci/manifest.py
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""Shared artifact manifest contract for local and managed outcome runs."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
SCHEMA_VERSION = "outcomeci.outcome-manifest/v1alpha1"
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def build_manifest(
|
|
12
|
+
*,
|
|
13
|
+
outcome_root: Path,
|
|
14
|
+
artifact_base: Path,
|
|
15
|
+
run_id: str,
|
|
16
|
+
workflow_run_id: str | None,
|
|
17
|
+
trajectory_version: int | None,
|
|
18
|
+
phase: str,
|
|
19
|
+
workflow_revision: str,
|
|
20
|
+
backend_provider: str,
|
|
21
|
+
state_repository: str | None,
|
|
22
|
+
context_provider: str,
|
|
23
|
+
context_revision_id: str | None,
|
|
24
|
+
constitution_sha256: str,
|
|
25
|
+
repository_base_commits: dict[str, str | None],
|
|
26
|
+
runner: str,
|
|
27
|
+
model: str | None,
|
|
28
|
+
transcript: dict[str, Any],
|
|
29
|
+
) -> dict[str, Any]:
|
|
30
|
+
"""Build the canonical envelope without backend-specific omissions."""
|
|
31
|
+
artifacts = sorted(
|
|
32
|
+
str(path.relative_to(artifact_base))
|
|
33
|
+
for path in outcome_root.rglob("*")
|
|
34
|
+
if path.is_file() and path.name not in {"manifest.json", "run.json"}
|
|
35
|
+
)
|
|
36
|
+
return {
|
|
37
|
+
"schema_version": SCHEMA_VERSION,
|
|
38
|
+
"run_id": run_id,
|
|
39
|
+
"odl_run_id": run_id,
|
|
40
|
+
"workflow_run_id": workflow_run_id,
|
|
41
|
+
"trajectory_version": trajectory_version,
|
|
42
|
+
"phase": phase,
|
|
43
|
+
"workflow_revision": workflow_revision,
|
|
44
|
+
"backend": {"provider": backend_provider, "state_repository": state_repository},
|
|
45
|
+
"context": {"provider": context_provider, "revision_id": context_revision_id},
|
|
46
|
+
"standup": str((outcome_root / "standup.md").relative_to(artifact_base)),
|
|
47
|
+
"constitution_sha256": constitution_sha256,
|
|
48
|
+
"repository_base_commits": repository_base_commits,
|
|
49
|
+
"runner": {"provider": runner, "model": model or "provider-default"},
|
|
50
|
+
"transcript": transcript,
|
|
51
|
+
"artifacts": artifacts,
|
|
52
|
+
}
|