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 ADDED
@@ -0,0 +1,7 @@
1
+ """OutcomeCI portable workflow runtime."""
2
+ from importlib.metadata import PackageNotFoundError, version
3
+
4
+ try:
5
+ __version__ = version("outcomeci-cli")
6
+ except PackageNotFoundError:
7
+ __version__ = "0.0.0"
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
+ }