okstra 0.102.3 → 0.103.0

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 (37) hide show
  1. package/README.kr.md +3 -2
  2. package/README.md +3 -2
  3. package/docs/for-ai/skills/okstra-brief.md +18 -2
  4. package/docs/for-ai/skills/okstra-run.md +9 -1
  5. package/docs/kr/architecture/storage-model.md +7 -2
  6. package/docs/kr/architecture.md +5 -2
  7. package/docs/kr/cli.md +1 -0
  8. package/docs/project-structure-overview.md +9 -4
  9. package/package.json +1 -1
  10. package/runtime/BUILD.json +2 -2
  11. package/runtime/bin/lib/okstra/interactive.sh +12 -0
  12. package/runtime/prompts/profiles/_common-contract.md +4 -0
  13. package/runtime/prompts/profiles/error-analysis.md +2 -0
  14. package/runtime/prompts/profiles/improvement-discovery.md +2 -0
  15. package/runtime/prompts/profiles/requirements-discovery.md +4 -0
  16. package/runtime/prompts/wizard/prompts.ko.json +1 -1
  17. package/runtime/python/okstra_ctl/codex_dispatch.py +2 -1
  18. package/runtime/python/okstra_ctl/dispatch_core.py +4 -1
  19. package/runtime/python/okstra_ctl/implementation_outcome.py +271 -0
  20. package/runtime/python/okstra_ctl/manager_cli.py +231 -0
  21. package/runtime/python/okstra_ctl/manager_launch.py +198 -0
  22. package/runtime/python/okstra_ctl/manager_paths.py +98 -0
  23. package/runtime/python/okstra_ctl/manager_store.py +318 -0
  24. package/runtime/python/okstra_ctl/manager_sync.py +144 -0
  25. package/runtime/python/okstra_ctl/path_hints.py +665 -0
  26. package/runtime/python/okstra_ctl/render.py +4 -3
  27. package/runtime/python/okstra_ctl/run_context.py +4 -2
  28. package/runtime/python/okstra_ctl/wizard.py +118 -16
  29. package/runtime/python/okstra_project/state.py +40 -2
  30. package/runtime/skills/okstra-brief/SKILL.md +32 -7
  31. package/runtime/skills/okstra-manager/SKILL.md +44 -0
  32. package/runtime/skills/okstra-run/SKILL.md +2 -2
  33. package/runtime/templates/reports/brief.template.md +25 -0
  34. package/runtime/validators/validate-brief.py +128 -0
  35. package/src/cli-registry.mjs +7 -0
  36. package/src/commands/manager.mjs +57 -0
  37. package/src/lib/skill-catalog.mjs +1 -0
@@ -0,0 +1,271 @@
1
+ """Artifact-derived implementation phase outcome reconciliation."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ from dataclasses import dataclass, field
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ from .consumers import read_stage_consumer_state
10
+ from .workflow import DEFAULT_NEXT_PHASE, PHASE_SEQUENCE
11
+
12
+
13
+ @dataclass(frozen=True)
14
+ class ImplementationOutcome:
15
+ completed: bool
16
+ stages: list[int] = field(default_factory=list)
17
+ head_commit: str = ""
18
+ report_path: str = ""
19
+ next_recommended_phase: str = ""
20
+ reason: str = ""
21
+
22
+
23
+ def derive_implementation_outcome(task_root: Path) -> ImplementationOutcome:
24
+ task_root = Path(task_root)
25
+ manifest = _load_json(task_root / "task-manifest.json")
26
+ workflow = manifest.get("workflow") if isinstance(manifest.get("workflow"), dict) else {}
27
+ if workflow.get("currentPhase") != "implementation":
28
+ return ImplementationOutcome(completed=False, reason="current phase is not implementation")
29
+
30
+ plan_run_root = task_root / "runs" / "implementation-planning"
31
+ if not plan_run_root.is_dir():
32
+ return ImplementationOutcome(completed=False, reason="implementation-planning run root missing")
33
+
34
+ stage_map = _load_stage_map(task_root, manifest)
35
+ if not stage_map:
36
+ return ImplementationOutcome(completed=False, reason="stage map unavailable")
37
+
38
+ consumers = read_stage_consumer_state(plan_run_root, recover_from_carry=True)
39
+ required_stages = {stage["stage_number"] for stage in stage_map}
40
+ if not required_stages or not required_stages.issubset(consumers.done_stages):
41
+ return ImplementationOutcome(completed=False, reason="not all stages are done")
42
+
43
+ carries = [_load_carry(task_root, stage) for stage in sorted(required_stages)]
44
+ if any(carry is None for carry in carries):
45
+ return ImplementationOutcome(completed=False, reason="carry sidecar missing")
46
+ if any(not _carry_passed(carry) for carry in carries if carry is not None):
47
+ return ImplementationOutcome(completed=False, reason="carry sidecar is not pass-grade")
48
+
49
+ latest_stage = max(required_stages)
50
+ latest_done = consumers.done_by_stage.get(latest_stage) or {}
51
+ latest_carry = carries[-1] or {}
52
+ return ImplementationOutcome(
53
+ completed=True,
54
+ stages=sorted(required_stages),
55
+ head_commit=str(latest_done.get("head_commit") or "") or _carry_head_commit(latest_carry),
56
+ report_path=_latest_implementation_report(task_root),
57
+ next_recommended_phase=DEFAULT_NEXT_PHASE["implementation"],
58
+ reason="all implementation stages have pass-grade carry and done rows",
59
+ )
60
+
61
+
62
+ def reconcile_implementation_outcome(project_root: Path, task_root: Path) -> bool:
63
+ project_root = Path(project_root)
64
+ task_root = Path(task_root)
65
+ outcome = derive_implementation_outcome(task_root)
66
+ if not outcome.completed:
67
+ return False
68
+
69
+ manifest_path = task_root / "task-manifest.json"
70
+ manifest = _load_json(manifest_path)
71
+ workflow = manifest.get("workflow")
72
+ if not isinstance(workflow, dict):
73
+ workflow = {}
74
+ manifest["workflow"] = workflow
75
+
76
+ changed = _promote_workflow(workflow, outcome)
77
+ phase_outcome = manifest.get("phaseOutcome")
78
+ if not isinstance(phase_outcome, dict):
79
+ phase_outcome = {}
80
+ manifest["phaseOutcome"] = phase_outcome
81
+
82
+ contract = manifest.get("contractValidation")
83
+ contract_status = contract.get("status", "") if isinstance(contract, dict) else ""
84
+ implementation = {
85
+ "state": "completed",
86
+ "source": "implementation-carry",
87
+ "stages": outcome.stages,
88
+ "headCommit": outcome.head_commit,
89
+ "reportPath": outcome.report_path,
90
+ "contractValidationStatus": contract_status,
91
+ "reason": outcome.reason,
92
+ }
93
+ if phase_outcome.get("implementation") != implementation:
94
+ phase_outcome["implementation"] = implementation
95
+ changed = True
96
+
97
+ if changed:
98
+ _write_json(manifest_path, manifest)
99
+ return changed or _update_task_catalog(project_root, manifest)
100
+
101
+
102
+ def _load_json(path: Path) -> dict[str, Any]:
103
+ if not path.is_file():
104
+ return {}
105
+ try:
106
+ data = json.loads(path.read_text(encoding="utf-8"))
107
+ except json.JSONDecodeError:
108
+ return {}
109
+ return data if isinstance(data, dict) else {}
110
+
111
+
112
+ def _load_stage_map(task_root: Path, manifest: dict[str, Any]) -> list[dict[str, Any]]:
113
+ plan_path = _source_plan_path(task_root, manifest)
114
+ if not plan_path.is_file():
115
+ return []
116
+ from .run import PrepareError, _load_parsed_stage_map, _stage_map_reject_detail
117
+
118
+ try:
119
+ stages, errs = _load_parsed_stage_map(plan_path.read_text(encoding="utf-8"))
120
+ except PrepareError:
121
+ return []
122
+ if _stage_map_reject_detail(stages, errs) is not None:
123
+ return []
124
+ return [
125
+ {
126
+ "stage_number": stage.stage_number,
127
+ "title": stage.title,
128
+ "depends_on": list(stage.depends_on),
129
+ "step_count": stage.step_count,
130
+ }
131
+ for stage in stages
132
+ ]
133
+
134
+
135
+ def _source_plan_path(task_root: Path, manifest: dict[str, Any]) -> Path:
136
+ carry_dir = task_root / "runs" / "implementation" / "carry"
137
+ for carry_path in sorted(carry_dir.glob("stage-*.json")):
138
+ value = _load_json(carry_path).get("sourcePlanPath")
139
+ if isinstance(value, str) and value:
140
+ return _resolve_relative(task_root, value)
141
+
142
+ latest = manifest.get("latestReportPath")
143
+ if isinstance(latest, str) and "implementation-planning" in latest:
144
+ return _resolve_relative(task_root, latest)
145
+
146
+ reports = task_root / "runs" / "implementation-planning" / "reports"
147
+ candidates = sorted(reports.glob("final-report-implementation-planning-*.md"))
148
+ return candidates[-1] if candidates else Path()
149
+
150
+
151
+ def _resolve_relative(task_root: Path, value: str) -> Path:
152
+ path = Path(value)
153
+ if path.is_absolute():
154
+ return path
155
+ project_root = _project_root_from_task_root(task_root)
156
+ project_relative = project_root / path
157
+ if project_relative.exists():
158
+ return project_relative
159
+ task_relative = task_root / path
160
+ if task_relative.exists():
161
+ return task_relative
162
+ return project_relative
163
+
164
+
165
+ def _project_root_from_task_root(task_root: Path) -> Path:
166
+ parts = task_root.resolve().parts
167
+ if ".okstra" not in parts:
168
+ return task_root.resolve()
169
+ return Path(*parts[: parts.index(".okstra")])
170
+
171
+
172
+ def _load_carry(task_root: Path, stage: int) -> dict[str, Any] | None:
173
+ data = _load_json(task_root / "runs" / "implementation" / "carry" / f"stage-{stage}.json")
174
+ return data or None
175
+
176
+
177
+ def _carry_passed(carry: dict[str, Any]) -> bool:
178
+ status = str(carry.get("status") or "").lower()
179
+ if status in {"fail", "failed", "blocked", "error", "aborted"}:
180
+ return False
181
+ conformance = carry.get("conformance")
182
+ if isinstance(conformance, dict):
183
+ overall = str(conformance.get("overall") or "").upper()
184
+ if overall and overall != "PASS":
185
+ return False
186
+ unverified = carry.get("unverified")
187
+ return not (isinstance(unverified, list) and unverified)
188
+
189
+
190
+ def _carry_head_commit(carry: dict[str, Any]) -> str:
191
+ stage_range = carry.get("stageCommitRange")
192
+ if isinstance(stage_range, dict) and stage_range.get("head"):
193
+ return str(stage_range["head"])
194
+ for key in ("head_sha", "head_commit", "head"):
195
+ value = carry.get(key)
196
+ if value:
197
+ return str(value)
198
+ commits = carry.get("commits")
199
+ if isinstance(commits, list) and commits:
200
+ last = commits[-1]
201
+ if isinstance(last, dict) and last.get("sha"):
202
+ return str(last["sha"])
203
+ return ""
204
+
205
+
206
+ def _latest_implementation_report(task_root: Path) -> str:
207
+ reports = sorted(
208
+ (task_root / "runs" / "implementation").glob(
209
+ "stage-*/reports/final-report-implementation-*.md"
210
+ )
211
+ )
212
+ if not reports:
213
+ return ""
214
+ project_root = _project_root_from_task_root(task_root)
215
+ try:
216
+ return str(reports[-1].relative_to(project_root))
217
+ except ValueError:
218
+ return str(reports[-1])
219
+
220
+
221
+ def _promote_workflow(workflow: dict[str, Any], outcome: ImplementationOutcome) -> bool:
222
+ before = json.dumps(workflow, sort_keys=True, ensure_ascii=False)
223
+ phase_states = workflow.get("phaseStates")
224
+ if not isinstance(phase_states, dict):
225
+ phase_states = {}
226
+ workflow["phaseStates"] = phase_states
227
+ for phase in PHASE_SEQUENCE:
228
+ phase_states.setdefault(phase, "not-started")
229
+ workflow["currentPhase"] = "implementation"
230
+ workflow["currentPhaseState"] = "completed"
231
+ phase_states["implementation"] = "completed"
232
+ workflow["lastCompletedPhase"] = "implementation"
233
+ workflow["nextRecommendedPhase"] = outcome.next_recommended_phase
234
+ after = json.dumps(workflow, sort_keys=True, ensure_ascii=False)
235
+ return before != after
236
+
237
+
238
+ def _update_task_catalog(project_root: Path, manifest: dict[str, Any]) -> bool:
239
+ catalog_path = project_root / ".okstra" / "discovery" / "task-catalog.json"
240
+ catalog = _load_json(catalog_path)
241
+ tasks = catalog.get("tasks")
242
+ if not isinstance(tasks, list):
243
+ return False
244
+ task_key = manifest.get("taskKey")
245
+ workflow = manifest.get("workflow") if isinstance(manifest.get("workflow"), dict) else {}
246
+ outcome = manifest.get("phaseOutcome") if isinstance(manifest.get("phaseOutcome"), dict) else {}
247
+ for entry in tasks:
248
+ if not isinstance(entry, dict) or entry.get("taskKey") != task_key:
249
+ continue
250
+ before = json.dumps(entry, sort_keys=True, ensure_ascii=False)
251
+ entry["currentPhase"] = workflow.get("currentPhase", "")
252
+ entry["currentPhaseState"] = workflow.get("currentPhaseState", "")
253
+ entry["lastCompletedPhase"] = workflow.get("lastCompletedPhase", "")
254
+ entry["nextRecommendedPhase"] = workflow.get("nextRecommendedPhase", "")
255
+ entry["latestReportPath"] = manifest.get("latestReportPath", "")
256
+ entry["latestRunStatus"] = manifest.get("latestRunStatus", entry.get("latestRunStatus", ""))
257
+ entry["currentStatus"] = manifest.get("currentStatus", entry.get("currentStatus", ""))
258
+ entry["phaseOutcome"] = outcome
259
+ if before == json.dumps(entry, sort_keys=True, ensure_ascii=False):
260
+ return False
261
+ _write_json(catalog_path, catalog)
262
+ return True
263
+ return False
264
+
265
+
266
+ def _write_json(path: Path, payload: dict[str, Any]) -> None:
267
+ path.parent.mkdir(parents=True, exist_ok=True)
268
+ path.write_text(
269
+ json.dumps(payload, ensure_ascii=False, indent=2) + "\n",
270
+ encoding="utf-8",
271
+ )
@@ -0,0 +1,231 @@
1
+ """CLI entrypoint for okstra manager."""
2
+ from __future__ import annotations
3
+
4
+ import argparse
5
+ import json
6
+ import os
7
+ import sys
8
+ from pathlib import Path
9
+
10
+ from okstra_project.dirs import okstra_home
11
+
12
+ from .manager_launch import build_launch_packet
13
+ from .manager_store import (
14
+ ManagerError,
15
+ append_directive,
16
+ assign_child,
17
+ create_task,
18
+ create_task_group,
19
+ discover_projects,
20
+ init_manager,
21
+ link_project,
22
+ )
23
+ from .manager_sync import status_task, sync_task
24
+
25
+
26
+ def _emit(payload: dict | list[dict], as_json: bool) -> None:
27
+ if as_json:
28
+ print(json.dumps(payload, ensure_ascii=False, indent=2))
29
+ return
30
+ print(json.dumps(payload, ensure_ascii=False))
31
+
32
+
33
+ def _normalize_global_flags(argv: list[str] | None) -> list[str]:
34
+ values = list(argv or [])
35
+ out: list[str] = []
36
+ json_seen = False
37
+ for value in values:
38
+ if value == "--json":
39
+ json_seen = True
40
+ continue
41
+ out.append(value)
42
+ return (["--json"] if json_seen else []) + out
43
+
44
+
45
+ def _parse_child_specs(values: list[str], task_group: str, default_task_id: str) -> list[dict]:
46
+ specs: list[dict] = []
47
+ for value in values:
48
+ parts = value.split(":")
49
+ if len(parts) == 1:
50
+ specs.append({"projectId": parts[0], "taskId": default_task_id})
51
+ continue
52
+ if len(parts) == 2:
53
+ specs.append({"projectId": parts[0], "taskId": parts[1]})
54
+ continue
55
+ if len(parts) == 3:
56
+ project_id, embedded_task_group, child_task_id = parts
57
+ if embedded_task_group != task_group:
58
+ raise ManagerError(
59
+ f"task-group mismatch for --task {value!r}: expected {task_group!r}, got {embedded_task_group!r}"
60
+ )
61
+ specs.append({"projectId": project_id, "taskId": child_task_id})
62
+ continue
63
+ raise ManagerError(
64
+ f"invalid --task {value!r}: expected project-id, project-id:task-id, or project-id:task-group:task-id"
65
+ )
66
+ return specs
67
+
68
+
69
+ def _parser() -> argparse.ArgumentParser:
70
+ parser = argparse.ArgumentParser(prog="okstra manager")
71
+ parser.add_argument("--json", action="store_true")
72
+ parser.add_argument("--workspace-root", default="")
73
+ sub = parser.add_subparsers(dest="command", required=True)
74
+
75
+ init = sub.add_parser("init")
76
+ init.add_argument("--manager-id", required=True)
77
+
78
+ sub.add_parser("discover-projects")
79
+
80
+ new = sub.add_parser("new")
81
+ new_sub = new.add_subparsers(dest="new_command", required=True)
82
+
83
+ project = new_sub.add_parser("project")
84
+ project.add_argument("--manager-id", required=True)
85
+ project.add_argument("--project-id", required=True)
86
+ project.add_argument("--project-root", required=True)
87
+ project.add_argument("--role", default="")
88
+ project.add_argument("--tag", action="append", default=[])
89
+
90
+ group = new_sub.add_parser("task-group")
91
+ group.add_argument("--manager-id", required=True)
92
+ group.add_argument("--task-group", required=True)
93
+
94
+ task_new = new_sub.add_parser("task")
95
+ task_new.add_argument("--manager-id", required=True)
96
+ task_new.add_argument("--task-group", required=True)
97
+ task_new.add_argument("--task-id", required=True)
98
+ task_new.add_argument("--task", action="append", default=[])
99
+ task_new.add_argument("--objective", default="")
100
+ task_new.add_argument("--common-brief", default="")
101
+ task_new.add_argument("--progress-mode", choices=["manual", "auto"], default="manual")
102
+
103
+ task = sub.add_parser("task")
104
+ task_sub = task.add_subparsers(dest="task_command", required=True)
105
+
106
+ for name in ("status", "sync"):
107
+ command = task_sub.add_parser(name)
108
+ command.add_argument("--manager-id", required=True)
109
+ command.add_argument("--task-group", required=True)
110
+ command.add_argument("--task-id", required=True)
111
+
112
+ assign = task_sub.add_parser("assign")
113
+ assign.add_argument("--manager-id", required=True)
114
+ assign.add_argument("--task-group", required=True)
115
+ assign.add_argument("--task-id", required=True)
116
+ assign.add_argument("--project-id", required=True)
117
+ assign.add_argument("--child-task-id", default=None)
118
+ assign.add_argument("--role", default="")
119
+ assign.add_argument("--tag", action="append", default=[])
120
+ assign.add_argument("--assignment", default="")
121
+
122
+ note = task_sub.add_parser("note")
123
+ note.add_argument("--manager-id", required=True)
124
+ note.add_argument("--task-group", required=True)
125
+ note.add_argument("--task-id", required=True)
126
+ note.add_argument("--scope", choices=["shared", "project"], required=True)
127
+ note.add_argument("--project-id", default="")
128
+ note.add_argument("--body", required=True)
129
+
130
+ run = task_sub.add_parser("run")
131
+ run.add_argument("--manager-id", required=True)
132
+ run.add_argument("--project-id", required=True)
133
+ run.add_argument("--task-group", required=True)
134
+ run.add_argument("--task-id", required=True)
135
+ run.add_argument("--child-task-id", default=None)
136
+ return parser
137
+
138
+
139
+ def main(argv: list[str] | None = None) -> int:
140
+ parser = _parser()
141
+ args = parser.parse_args(_normalize_global_flags(argv if argv is not None else sys.argv[1:]))
142
+ home = okstra_home()
143
+ try:
144
+ if args.command == "init":
145
+ _emit(init_manager(home, args.manager_id), args.json)
146
+ elif args.command == "discover-projects":
147
+ _emit(discover_projects(home), args.json)
148
+ elif args.command == "new" and args.new_command == "project":
149
+ _emit(
150
+ link_project(
151
+ home,
152
+ args.manager_id,
153
+ args.project_id,
154
+ Path(args.project_root),
155
+ role=args.role,
156
+ tags=args.tag,
157
+ ),
158
+ args.json,
159
+ )
160
+ elif args.command == "new" and args.new_command == "task-group":
161
+ _emit(create_task_group(home, args.manager_id, args.task_group), args.json)
162
+ elif args.command == "new" and args.new_command == "task":
163
+ _emit(
164
+ create_task(
165
+ home,
166
+ args.manager_id,
167
+ args.task_group,
168
+ args.task_id,
169
+ _parse_child_specs(args.task, args.task_group, args.task_id),
170
+ objective=args.objective,
171
+ common_brief_path=args.common_brief,
172
+ progress_mode=args.progress_mode,
173
+ ),
174
+ args.json,
175
+ )
176
+ elif args.command == "task" and args.task_command == "assign":
177
+ _emit(
178
+ assign_child(
179
+ home,
180
+ args.manager_id,
181
+ args.task_group,
182
+ args.task_id,
183
+ args.project_id,
184
+ role=args.role,
185
+ tags=args.tag,
186
+ assignment=args.assignment,
187
+ child_task_id=args.child_task_id,
188
+ ),
189
+ args.json,
190
+ )
191
+ elif args.command == "task" and args.task_command == "note":
192
+ _emit(
193
+ append_directive(
194
+ home,
195
+ args.manager_id,
196
+ args.task_group,
197
+ args.task_id,
198
+ scope=args.scope,
199
+ project_id=args.project_id,
200
+ body=args.body,
201
+ ),
202
+ args.json,
203
+ )
204
+ elif args.command == "task" and args.task_command == "sync":
205
+ _emit(sync_task(home, args.manager_id, args.task_group, args.task_id), args.json)
206
+ elif args.command == "task" and args.task_command == "status":
207
+ _emit(status_task(home, args.manager_id, args.task_group, args.task_id), args.json)
208
+ elif args.command == "task" and args.task_command == "run":
209
+ _emit(
210
+ build_launch_packet(
211
+ home,
212
+ args.manager_id,
213
+ args.task_group,
214
+ args.task_id,
215
+ args.project_id,
216
+ child_task_id=args.child_task_id,
217
+ env=os.environ,
218
+ ),
219
+ args.json,
220
+ )
221
+ else:
222
+ parser.print_help()
223
+ return 2
224
+ return 0
225
+ except ManagerError as exc:
226
+ print(f"okstra manager: {exc}", file=sys.stderr)
227
+ return 2
228
+
229
+
230
+ if __name__ == "__main__":
231
+ raise SystemExit(main(sys.argv[1:]))