open-research-protocol 0.4.33 → 0.4.35
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.
- package/CHANGELOG.md +50 -0
- package/cli/__pycache__/orp.cpython-311.pyc +0 -0
- package/cli/orp.py +430 -11
- package/docs/ORP_HOSTED_WORKSPACE_CONTRACT.md +39 -0
- package/package.json +1 -1
- package/packages/orp-workspace-launcher/src/core-plan.js +62 -0
- package/packages/orp-workspace-launcher/src/hosted-state.js +328 -4
- package/packages/orp-workspace-launcher/src/index.js +11 -1
- package/packages/orp-workspace-launcher/src/local-inventory.js +681 -0
- package/packages/orp-workspace-launcher/src/orp.js +23 -0
- package/packages/orp-workspace-launcher/src/registry.js +24 -11
- package/packages/orp-workspace-launcher/src/sync.js +241 -14
- package/packages/orp-workspace-launcher/test/core-plan.test.js +48 -0
- package/packages/orp-workspace-launcher/test/hosted-state.test.js +187 -0
- package/packages/orp-workspace-launcher/test/local-inventory.test.js +126 -0
- package/packages/orp-workspace-launcher/test/orp.test.js +19 -0
- package/scripts/__pycache__/orp-kernel-agent-pilot.cpython-311.pyc +0 -0
- package/scripts/__pycache__/orp-kernel-agent-replication.cpython-311.pyc +0 -0
- package/scripts/__pycache__/orp-kernel-benchmark.cpython-311.pyc +0 -0
- package/scripts/__pycache__/orp-kernel-canonical-continuation.cpython-311.pyc +0 -0
- package/scripts/__pycache__/orp-kernel-continuation-pilot.cpython-311.pyc +0 -0
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,56 @@ There was no prior in-repo changelog file, so the first formal entry starts
|
|
|
6
6
|
with the currently shipped `v0.4.4` release and summarizes the full release
|
|
7
7
|
delta reflected in this repo.
|
|
8
8
|
|
|
9
|
+
## v0.4.35 - 2026-04-30
|
|
10
|
+
|
|
11
|
+
This release makes the hosted workspace sync contract canonical from the ORP
|
|
12
|
+
CLI side, so hosted ORP can render the same projects, resume sessions, plans,
|
|
13
|
+
and tasks that local workspace tooling sees.
|
|
14
|
+
|
|
15
|
+
### Added
|
|
16
|
+
|
|
17
|
+
- Added local workspace inventory reconciliation for `orp workspace sync`,
|
|
18
|
+
starting from the selected workspace ledger and refreshing known projects
|
|
19
|
+
from ORP startup state, Clawdad state, and recent Codex session metadata.
|
|
20
|
+
- Added hosted workspace state push support that carries per-project sync
|
|
21
|
+
metadata, linked ORP idea/feature ids, plan summaries, and task lists.
|
|
22
|
+
- Added a hosted workspace sync contract document covering the canonical
|
|
23
|
+
source order and required hosted payload fields.
|
|
24
|
+
|
|
25
|
+
### Changed
|
|
26
|
+
|
|
27
|
+
- `orp workspace sync main` now bridges a local managed workspace file to the
|
|
28
|
+
matching hosted workspace by durable `workspaceId` before pushing state.
|
|
29
|
+
- Hosted workspace sync skips the idea-note compatibility mirror when the
|
|
30
|
+
hosted workspace state can be written directly, avoiding stale or truncated
|
|
31
|
+
plan/task payloads.
|
|
32
|
+
- Workspace manifests now round-trip activity timestamps, sync timestamps,
|
|
33
|
+
sync source labels, plan data, tasks, and linked ORP project references.
|
|
34
|
+
|
|
35
|
+
## v0.4.34 - 2026-04-30
|
|
36
|
+
|
|
37
|
+
This release connects ORP frontier plans to hosted ORP ideas/features and lets
|
|
38
|
+
workspace snapshots carry canonical project references instead of stale copied
|
|
39
|
+
task text.
|
|
40
|
+
|
|
41
|
+
### Added
|
|
42
|
+
|
|
43
|
+
- Added `orp frontier sync-idea` to create or update a hosted ORP idea from a
|
|
44
|
+
repo's frontier TAS/current phase, then sync milestone phases as hosted
|
|
45
|
+
feature tasks with completion state.
|
|
46
|
+
- Added project-link metadata for active frontier feature ids so
|
|
47
|
+
`.git/orp/link/project.json` can map local frontier phases back to hosted
|
|
48
|
+
ORP feature records.
|
|
49
|
+
- Added workspace enrichment for linked ORP projects, including compact
|
|
50
|
+
`linkedIdeaId` / `linkedFeatureId` references and local frontier plan/task
|
|
51
|
+
summaries for workspace sync previews.
|
|
52
|
+
|
|
53
|
+
### Changed
|
|
54
|
+
|
|
55
|
+
- Workspace sync now preserves linked idea/feature references in structured
|
|
56
|
+
workspace manifests so the hosted web app can prefer canonical ORP
|
|
57
|
+
idea/features for plan and task rendering.
|
|
58
|
+
|
|
9
59
|
## v0.4.33 - 2026-04-30
|
|
10
60
|
|
|
11
61
|
This release teaches ORP to manage Codex's global instruction and startup hook
|
|
Binary file
|
package/cli/orp.py
CHANGED
|
@@ -4941,9 +4941,31 @@ def _build_remote_feature_body(
|
|
|
4941
4941
|
body["superStarred"] = True
|
|
4942
4942
|
if getattr(args, "visibility", None) is not None:
|
|
4943
4943
|
body["visibility"] = str(getattr(args, "visibility", "")).strip()
|
|
4944
|
+
completed = bool(getattr(args, "completed", False))
|
|
4945
|
+
incomplete = bool(getattr(args, "incomplete", False))
|
|
4946
|
+
if completed and incomplete:
|
|
4947
|
+
raise RuntimeError("Use only one of --completed or --incomplete.")
|
|
4948
|
+
if completed or incomplete:
|
|
4949
|
+
body["completed"] = completed
|
|
4944
4950
|
return body
|
|
4945
4951
|
|
|
4946
4952
|
|
|
4953
|
+
def _feature_body_has_metadata_update(body: dict[str, Any]) -> bool:
|
|
4954
|
+
return any(
|
|
4955
|
+
key in body
|
|
4956
|
+
for key in (
|
|
4957
|
+
"title",
|
|
4958
|
+
"notes",
|
|
4959
|
+
"detail",
|
|
4960
|
+
"detailLabel",
|
|
4961
|
+
"details",
|
|
4962
|
+
"starred",
|
|
4963
|
+
"superStarred",
|
|
4964
|
+
"visibility",
|
|
4965
|
+
)
|
|
4966
|
+
)
|
|
4967
|
+
|
|
4968
|
+
|
|
4947
4969
|
def _resolve_codex_bin(args: argparse.Namespace) -> str:
|
|
4948
4970
|
explicit = str(getattr(args, "codex_bin", "")).strip()
|
|
4949
4971
|
if explicit:
|
|
@@ -7753,6 +7775,7 @@ def _normalize_link_project_payload(
|
|
|
7753
7775
|
"world_name": ["world_name", "worldName", "name"],
|
|
7754
7776
|
"github_url": ["github_url", "githubUrl"],
|
|
7755
7777
|
"linked_email": ["linked_email", "linkedEmail"],
|
|
7778
|
+
"active_feature_id": ["active_feature_id", "activeFeatureId", "linked_feature_id", "linkedFeatureId"],
|
|
7756
7779
|
"notes": ["notes"],
|
|
7757
7780
|
}.items():
|
|
7758
7781
|
value = ""
|
|
@@ -7762,6 +7785,15 @@ def _normalize_link_project_payload(
|
|
|
7762
7785
|
break
|
|
7763
7786
|
if value:
|
|
7764
7787
|
payload[key] = value
|
|
7788
|
+
frontier_feature_ids = raw.get("frontier_feature_ids", raw.get("frontierFeatureIds"))
|
|
7789
|
+
if isinstance(frontier_feature_ids, dict):
|
|
7790
|
+
normalized_feature_ids = {
|
|
7791
|
+
str(key).strip(): str(value).strip()
|
|
7792
|
+
for key, value in frontier_feature_ids.items()
|
|
7793
|
+
if str(key).strip() and str(value).strip()
|
|
7794
|
+
}
|
|
7795
|
+
if normalized_feature_ids:
|
|
7796
|
+
payload["frontier_feature_ids"] = normalized_feature_ids
|
|
7765
7797
|
payload["source"] = source
|
|
7766
7798
|
return payload
|
|
7767
7799
|
|
|
@@ -13276,6 +13308,7 @@ def _about_payload() -> dict[str, Any]:
|
|
|
13276
13308
|
["frontier", "add-phase"],
|
|
13277
13309
|
["frontier", "set-live"],
|
|
13278
13310
|
["frontier", "render"],
|
|
13311
|
+
["frontier", "sync-idea"],
|
|
13279
13312
|
["frontier", "doctor"],
|
|
13280
13313
|
],
|
|
13281
13314
|
},
|
|
@@ -13460,6 +13493,7 @@ def _about_payload() -> dict[str, Any]:
|
|
|
13460
13493
|
{"name": "frontier_add_phase", "path": ["frontier", "add-phase"], "json_output": True},
|
|
13461
13494
|
{"name": "frontier_set_live", "path": ["frontier", "set-live"], "json_output": True},
|
|
13462
13495
|
{"name": "frontier_render", "path": ["frontier", "render"], "json_output": True},
|
|
13496
|
+
{"name": "frontier_sync_idea", "path": ["frontier", "sync-idea"], "json_output": True},
|
|
13463
13497
|
{"name": "frontier_doctor", "path": ["frontier", "doctor"], "json_output": True},
|
|
13464
13498
|
{"name": "branch_start", "path": ["branch", "start"], "json_output": True},
|
|
13465
13499
|
{"name": "checkpoint_create", "path": ["checkpoint", "create"], "json_output": True},
|
|
@@ -16937,6 +16971,305 @@ def cmd_frontier_render(args: argparse.Namespace) -> int:
|
|
|
16937
16971
|
return 0
|
|
16938
16972
|
|
|
16939
16973
|
|
|
16974
|
+
FRONTIER_FEATURE_MARKER_PATTERN = re.compile(r"<!--\s*ORP:FRONTIER_PHASE:([^>\s]+)\s*-->")
|
|
16975
|
+
|
|
16976
|
+
|
|
16977
|
+
def _frontier_tas_text(repo_root: Path) -> str:
|
|
16978
|
+
path = _frontier_paths(repo_root)["root"] / "TAS.md"
|
|
16979
|
+
if not path.exists():
|
|
16980
|
+
return ""
|
|
16981
|
+
return _read_text(path).strip()
|
|
16982
|
+
|
|
16983
|
+
|
|
16984
|
+
def _frontier_tas_title(text: str) -> str:
|
|
16985
|
+
for line in str(text or "").splitlines():
|
|
16986
|
+
stripped = line.strip()
|
|
16987
|
+
if stripped.startswith("# "):
|
|
16988
|
+
return stripped[2:].strip()
|
|
16989
|
+
return ""
|
|
16990
|
+
|
|
16991
|
+
|
|
16992
|
+
def _frontier_plan_notes(repo_root: Path, stack: dict[str, Any], state: dict[str, Any]) -> tuple[str, str]:
|
|
16993
|
+
tas_text = _frontier_tas_text(repo_root)
|
|
16994
|
+
title = (
|
|
16995
|
+
_frontier_tas_title(tas_text)
|
|
16996
|
+
or str(stack.get("label", "")).strip()
|
|
16997
|
+
or str(stack.get("program_id", "")).strip()
|
|
16998
|
+
or repo_root.name
|
|
16999
|
+
)
|
|
17000
|
+
active_parts = [
|
|
17001
|
+
str(state.get("active_version", "")).strip(),
|
|
17002
|
+
str(state.get("active_milestone", "")).strip(),
|
|
17003
|
+
str(state.get("active_phase", "")).strip(),
|
|
17004
|
+
]
|
|
17005
|
+
body_parts = [
|
|
17006
|
+
f"Current next action: {str(state.get('next_action', '')).strip()}" if str(state.get("next_action", "")).strip() else "",
|
|
17007
|
+
f"Active frontier: {' / '.join([part for part in active_parts if part])}" if any(active_parts) else "",
|
|
17008
|
+
tas_text,
|
|
17009
|
+
]
|
|
17010
|
+
return title, "\n\n".join(part for part in body_parts if part).strip()
|
|
17011
|
+
|
|
17012
|
+
|
|
17013
|
+
def _frontier_phase_completed(status: str) -> bool:
|
|
17014
|
+
return _frontier_status(status, default="planned") in {"complete", "completed", "done", "terminal"}
|
|
17015
|
+
|
|
17016
|
+
|
|
17017
|
+
def _frontier_phase_tasks(stack: dict[str, Any], state: dict[str, Any]) -> list[dict[str, Any]]:
|
|
17018
|
+
active_milestone_id = str(state.get("active_milestone", "") or stack.get("current_frontier", {}).get("active_milestone", "")).strip()
|
|
17019
|
+
active_phase_id = str(state.get("active_phase", "") or stack.get("current_frontier", {}).get("active_phase", "")).strip()
|
|
17020
|
+
_, milestone = _frontier_find_milestone(stack, active_milestone_id) if active_milestone_id else (None, None)
|
|
17021
|
+
phases = milestone.get("phases") if isinstance(milestone, dict) else []
|
|
17022
|
+
rows: list[dict[str, Any]] = []
|
|
17023
|
+
for index, phase in enumerate(phases if isinstance(phases, list) else []):
|
|
17024
|
+
if not isinstance(phase, dict):
|
|
17025
|
+
continue
|
|
17026
|
+
phase_id = str(phase.get("id", "")).strip() or f"frontier-phase-{index + 1}"
|
|
17027
|
+
status = _frontier_status(phase.get("status"), default="planned")
|
|
17028
|
+
rows.append(
|
|
17029
|
+
{
|
|
17030
|
+
"phase_id": phase_id,
|
|
17031
|
+
"title": str(phase.get("label", "")).strip() or str(phase.get("goal", "")).strip() or phase_id,
|
|
17032
|
+
"status": "active" if phase_id == active_phase_id else status,
|
|
17033
|
+
"completed": _frontier_phase_completed(status),
|
|
17034
|
+
"active": phase_id == active_phase_id,
|
|
17035
|
+
"phase": phase,
|
|
17036
|
+
}
|
|
17037
|
+
)
|
|
17038
|
+
return rows
|
|
17039
|
+
|
|
17040
|
+
|
|
17041
|
+
def _frontier_feature_notes(task: dict[str, Any]) -> str:
|
|
17042
|
+
phase = task.get("phase") if isinstance(task.get("phase"), dict) else {}
|
|
17043
|
+
phase_id = str(task.get("phase_id", "")).strip()
|
|
17044
|
+
lines = [
|
|
17045
|
+
f"<!-- ORP:FRONTIER_PHASE:{phase_id} -->",
|
|
17046
|
+
f"ORP frontier phase id: {phase_id}",
|
|
17047
|
+
f"Status: {str(task.get('status', '')).strip() or 'planned'}",
|
|
17048
|
+
]
|
|
17049
|
+
goal = str(phase.get("goal", "")).strip()
|
|
17050
|
+
if goal:
|
|
17051
|
+
lines.extend(["", "Goal:", goal])
|
|
17052
|
+
for label, key in [
|
|
17053
|
+
("Requirements", "requirements"),
|
|
17054
|
+
("Success criteria", "success_criteria"),
|
|
17055
|
+
("Plan", "plans"),
|
|
17056
|
+
]:
|
|
17057
|
+
values = _coerce_string_list(phase.get(key))
|
|
17058
|
+
if values:
|
|
17059
|
+
lines.extend(["", f"{label}:"])
|
|
17060
|
+
lines.extend([f"- {value}" for value in values])
|
|
17061
|
+
return "\n".join(lines).strip()
|
|
17062
|
+
|
|
17063
|
+
|
|
17064
|
+
def _feature_text_for_matching(feature: dict[str, Any]) -> str:
|
|
17065
|
+
parts = [
|
|
17066
|
+
str(feature.get("title", "") or ""),
|
|
17067
|
+
str(feature.get("notes", "") or ""),
|
|
17068
|
+
str(feature.get("detail", "") or ""),
|
|
17069
|
+
]
|
|
17070
|
+
sections = feature.get("detailSections", feature.get("details"))
|
|
17071
|
+
if isinstance(sections, list):
|
|
17072
|
+
for section in sections:
|
|
17073
|
+
if isinstance(section, dict):
|
|
17074
|
+
parts.append(str(section.get("body", "") or ""))
|
|
17075
|
+
parts.append(str(section.get("detail", "") or ""))
|
|
17076
|
+
return "\n".join(parts)
|
|
17077
|
+
|
|
17078
|
+
|
|
17079
|
+
def _frontier_phase_id_from_feature(feature: dict[str, Any]) -> str:
|
|
17080
|
+
match = FRONTIER_FEATURE_MARKER_PATTERN.search(_feature_text_for_matching(feature))
|
|
17081
|
+
return match.group(1).strip() if match else ""
|
|
17082
|
+
|
|
17083
|
+
|
|
17084
|
+
def _frontier_match_features_by_phase(features: list[dict[str, Any]]) -> dict[str, dict[str, Any]]:
|
|
17085
|
+
matches: dict[str, dict[str, Any]] = {}
|
|
17086
|
+
for feature in features:
|
|
17087
|
+
phase_id = _frontier_phase_id_from_feature(feature)
|
|
17088
|
+
if phase_id and phase_id not in matches:
|
|
17089
|
+
matches[phase_id] = feature
|
|
17090
|
+
return matches
|
|
17091
|
+
|
|
17092
|
+
|
|
17093
|
+
def _frontier_sync_update_idea(args: argparse.Namespace, idea_id: str, title: str, notes: str, *, dry_run: bool) -> dict[str, Any]:
|
|
17094
|
+
current = _get_remote_idea(args, idea_id)
|
|
17095
|
+
if dry_run:
|
|
17096
|
+
return current
|
|
17097
|
+
session = _require_hosted_session(args)
|
|
17098
|
+
body = {
|
|
17099
|
+
"title": title,
|
|
17100
|
+
"notes": notes,
|
|
17101
|
+
}
|
|
17102
|
+
updated_at = str(current.get("updatedAt", "")).strip()
|
|
17103
|
+
if updated_at:
|
|
17104
|
+
body["updatedAt"] = updated_at
|
|
17105
|
+
payload = _request_hosted_json(
|
|
17106
|
+
base_url=_resolve_hosted_base_url(args, session),
|
|
17107
|
+
path=f"/api/cli/ideas/{urlparse.quote(idea_id)}",
|
|
17108
|
+
method="PATCH",
|
|
17109
|
+
token=str(session.get("token", "")).strip(),
|
|
17110
|
+
body=body,
|
|
17111
|
+
)
|
|
17112
|
+
return _normalize_remote_idea_payload(payload)["idea"]
|
|
17113
|
+
|
|
17114
|
+
|
|
17115
|
+
def _frontier_sync_create_idea(args: argparse.Namespace, title: str, notes: str, *, dry_run: bool) -> dict[str, Any]:
|
|
17116
|
+
if dry_run:
|
|
17117
|
+
return {"id": "", "title": title, "notes": notes}
|
|
17118
|
+
session = _require_hosted_session(args)
|
|
17119
|
+
payload = _request_hosted_json(
|
|
17120
|
+
base_url=_resolve_hosted_base_url(args, session),
|
|
17121
|
+
path="/api/cli/ideas",
|
|
17122
|
+
method="POST",
|
|
17123
|
+
token=str(session.get("token", "")).strip(),
|
|
17124
|
+
body={"title": title, "notes": notes},
|
|
17125
|
+
)
|
|
17126
|
+
return _normalize_remote_idea_payload(payload)["idea"]
|
|
17127
|
+
|
|
17128
|
+
|
|
17129
|
+
def _frontier_sync_feature(
|
|
17130
|
+
args: argparse.Namespace,
|
|
17131
|
+
*,
|
|
17132
|
+
idea_id: str,
|
|
17133
|
+
task: dict[str, Any],
|
|
17134
|
+
existing: dict[str, Any] | None,
|
|
17135
|
+
dry_run: bool,
|
|
17136
|
+
) -> dict[str, Any]:
|
|
17137
|
+
phase_id = str(task.get("phase_id", "")).strip()
|
|
17138
|
+
body = {
|
|
17139
|
+
"ideaId": idea_id,
|
|
17140
|
+
"title": str(task.get("title", "")).strip(),
|
|
17141
|
+
"notes": _frontier_feature_notes(task),
|
|
17142
|
+
"completed": bool(task.get("completed")),
|
|
17143
|
+
}
|
|
17144
|
+
action = "update" if existing else "create"
|
|
17145
|
+
if dry_run:
|
|
17146
|
+
return {
|
|
17147
|
+
"phase_id": phase_id,
|
|
17148
|
+
"action": action,
|
|
17149
|
+
"feature": existing or {"id": "", "ideaId": idea_id, "title": body["title"], "completed": body["completed"]},
|
|
17150
|
+
}
|
|
17151
|
+
session = _require_hosted_session(args)
|
|
17152
|
+
if existing:
|
|
17153
|
+
updated_at = str(existing.get("updatedAt", "")).strip()
|
|
17154
|
+
if updated_at:
|
|
17155
|
+
body["updatedAt"] = updated_at
|
|
17156
|
+
payload = _request_hosted_json(
|
|
17157
|
+
base_url=_resolve_hosted_base_url(args, session),
|
|
17158
|
+
path=f"/api/cli/features/{urlparse.quote(str(existing.get('id', '')).strip())}",
|
|
17159
|
+
method="PATCH",
|
|
17160
|
+
token=str(session.get("token", "")).strip(),
|
|
17161
|
+
body=body,
|
|
17162
|
+
)
|
|
17163
|
+
else:
|
|
17164
|
+
payload = _request_hosted_json(
|
|
17165
|
+
base_url=_resolve_hosted_base_url(args, session),
|
|
17166
|
+
path=f"/api/cli/ideas/{urlparse.quote(idea_id)}/features",
|
|
17167
|
+
method="POST",
|
|
17168
|
+
token=str(session.get("token", "")).strip(),
|
|
17169
|
+
body=body,
|
|
17170
|
+
)
|
|
17171
|
+
created = _normalize_remote_feature_payload(payload)
|
|
17172
|
+
if bool(task.get("completed")) and str(created.get("id", "")).strip():
|
|
17173
|
+
created = _patch_remote_feature_completion(
|
|
17174
|
+
args,
|
|
17175
|
+
feature_id=str(created.get("id", "")).strip(),
|
|
17176
|
+
completed=True,
|
|
17177
|
+
)
|
|
17178
|
+
payload = {"ok": True, "feature": created}
|
|
17179
|
+
return {
|
|
17180
|
+
"phase_id": phase_id,
|
|
17181
|
+
"action": action,
|
|
17182
|
+
"feature": _normalize_remote_feature_payload(payload),
|
|
17183
|
+
}
|
|
17184
|
+
|
|
17185
|
+
|
|
17186
|
+
def cmd_frontier_sync_idea(args: argparse.Namespace) -> int:
|
|
17187
|
+
repo_root = Path(args.repo_root).resolve()
|
|
17188
|
+
stack = _frontier_load_stack(repo_root)
|
|
17189
|
+
state = _frontier_load_state(repo_root)
|
|
17190
|
+
project_link = _read_link_project(repo_root)
|
|
17191
|
+
requested_idea_id = str(getattr(args, "idea_id", "") or "").strip()
|
|
17192
|
+
idea_id = requested_idea_id or str(project_link.get("idea_id", "")).strip()
|
|
17193
|
+
title, notes = _frontier_plan_notes(repo_root, stack, state)
|
|
17194
|
+
dry_run = bool(getattr(args, "dry_run", False))
|
|
17195
|
+
create_idea = bool(getattr(args, "create_idea", False))
|
|
17196
|
+
|
|
17197
|
+
if not idea_id and not create_idea:
|
|
17198
|
+
raise RuntimeError(
|
|
17199
|
+
"No hosted idea is linked. Run `orp frontier sync-idea --create-idea --json`, or pass --idea-id."
|
|
17200
|
+
)
|
|
17201
|
+
|
|
17202
|
+
idea_created = False
|
|
17203
|
+
if idea_id:
|
|
17204
|
+
idea = _frontier_sync_update_idea(args, idea_id, title, notes, dry_run=dry_run)
|
|
17205
|
+
else:
|
|
17206
|
+
idea = _frontier_sync_create_idea(args, title, notes, dry_run=dry_run)
|
|
17207
|
+
idea_id = str(idea.get("id", "")).strip()
|
|
17208
|
+
idea_created = True
|
|
17209
|
+
|
|
17210
|
+
tasks = _frontier_phase_tasks(stack, state)
|
|
17211
|
+
existing_features = _list_remote_features(args, idea_id) if idea_id else []
|
|
17212
|
+
features_by_phase = _frontier_match_features_by_phase(existing_features)
|
|
17213
|
+
synced_features: list[dict[str, Any]] = []
|
|
17214
|
+
frontier_feature_ids: dict[str, str] = {
|
|
17215
|
+
str(key): str(value)
|
|
17216
|
+
for key, value in (project_link.get("frontier_feature_ids") if isinstance(project_link.get("frontier_feature_ids"), dict) else {}).items()
|
|
17217
|
+
if str(key).strip() and str(value).strip()
|
|
17218
|
+
}
|
|
17219
|
+
active_feature_id = ""
|
|
17220
|
+
|
|
17221
|
+
for task in tasks:
|
|
17222
|
+
phase_id = str(task.get("phase_id", "")).strip()
|
|
17223
|
+
existing = features_by_phase.get(phase_id)
|
|
17224
|
+
synced = _frontier_sync_feature(args, idea_id=idea_id, task=task, existing=existing, dry_run=dry_run)
|
|
17225
|
+
synced_features.append(synced)
|
|
17226
|
+
feature_id = str(synced.get("feature", {}).get("id", "")).strip()
|
|
17227
|
+
if feature_id:
|
|
17228
|
+
frontier_feature_ids[phase_id] = feature_id
|
|
17229
|
+
if bool(task.get("active")):
|
|
17230
|
+
active_feature_id = feature_id
|
|
17231
|
+
|
|
17232
|
+
project_link_path = ""
|
|
17233
|
+
should_link_project = not bool(getattr(args, "no_link_project", False))
|
|
17234
|
+
if should_link_project and not dry_run and idea_id:
|
|
17235
|
+
next_link = {
|
|
17236
|
+
**project_link,
|
|
17237
|
+
"idea_id": idea_id,
|
|
17238
|
+
"idea_title": str(idea.get("title", "")).strip() or title,
|
|
17239
|
+
"project_root": str(repo_root),
|
|
17240
|
+
"active_feature_id": active_feature_id,
|
|
17241
|
+
"frontier_feature_ids": frontier_feature_ids,
|
|
17242
|
+
"linked_at_utc": str(project_link.get("linked_at_utc", "")).strip() or _now_utc(),
|
|
17243
|
+
"source": str(project_link.get("source", "")).strip() or "cli",
|
|
17244
|
+
}
|
|
17245
|
+
project_link_path = _path_for_state(_write_link_project(repo_root, next_link), repo_root)
|
|
17246
|
+
elif should_link_project and project_link:
|
|
17247
|
+
path = _link_project_path(repo_root)
|
|
17248
|
+
project_link_path = _path_for_state(path, repo_root) if path is not None else ""
|
|
17249
|
+
|
|
17250
|
+
result = {
|
|
17251
|
+
"ok": True,
|
|
17252
|
+
"dry_run": dry_run,
|
|
17253
|
+
"idea_created": idea_created and not dry_run,
|
|
17254
|
+
"idea": idea,
|
|
17255
|
+
"tasks": tasks,
|
|
17256
|
+
"features": synced_features,
|
|
17257
|
+
"active_feature_id": active_feature_id,
|
|
17258
|
+
"frontier_feature_ids": frontier_feature_ids,
|
|
17259
|
+
"project_link_path": project_link_path,
|
|
17260
|
+
}
|
|
17261
|
+
if args.json_output:
|
|
17262
|
+
_print_json(result)
|
|
17263
|
+
else:
|
|
17264
|
+
print(f"idea.id={idea_id}")
|
|
17265
|
+
print(f"idea.title={str(idea.get('title', '')).strip() or title}")
|
|
17266
|
+
print(f"features.synced={len(synced_features)}")
|
|
17267
|
+
print(f"active_feature_id={active_feature_id}")
|
|
17268
|
+
if project_link_path:
|
|
17269
|
+
print(f"project.link_path={project_link_path}")
|
|
17270
|
+
return 0
|
|
17271
|
+
|
|
17272
|
+
|
|
16940
17273
|
def cmd_frontier_doctor(args: argparse.Namespace) -> int:
|
|
16941
17274
|
repo_root = Path(args.repo_root).resolve()
|
|
16942
17275
|
payload = _frontier_doctor_payload(repo_root)
|
|
@@ -25873,6 +26206,59 @@ def _normalize_remote_idea_payload(payload: dict[str, Any]) -> dict[str, Any]:
|
|
|
25873
26206
|
}
|
|
25874
26207
|
|
|
25875
26208
|
|
|
26209
|
+
def _normalize_remote_feature_payload(payload: dict[str, Any]) -> dict[str, Any]:
|
|
26210
|
+
feature = payload.get("feature") if isinstance(payload.get("feature"), dict) else payload
|
|
26211
|
+
if not isinstance(feature, dict):
|
|
26212
|
+
raise RuntimeError("Hosted ORP returned an invalid feature payload.")
|
|
26213
|
+
return feature
|
|
26214
|
+
|
|
26215
|
+
|
|
26216
|
+
def _normalize_remote_features_payload(payload: dict[str, Any]) -> list[dict[str, Any]]:
|
|
26217
|
+
features = payload.get("features")
|
|
26218
|
+
if not isinstance(features, list):
|
|
26219
|
+
features = payload.get("items")
|
|
26220
|
+
if not isinstance(features, list):
|
|
26221
|
+
idea = payload.get("idea") if isinstance(payload.get("idea"), dict) else {}
|
|
26222
|
+
features = idea.get("features") if isinstance(idea.get("features"), list) else []
|
|
26223
|
+
return [row for row in features if isinstance(row, dict)]
|
|
26224
|
+
|
|
26225
|
+
|
|
26226
|
+
def _patch_remote_feature_completion(
|
|
26227
|
+
args: argparse.Namespace,
|
|
26228
|
+
*,
|
|
26229
|
+
feature_id: str,
|
|
26230
|
+
completed: bool,
|
|
26231
|
+
) -> dict[str, Any]:
|
|
26232
|
+
session = _require_hosted_session(args)
|
|
26233
|
+
payload = _request_hosted_json(
|
|
26234
|
+
base_url=_resolve_hosted_base_url(args, session),
|
|
26235
|
+
path=f"/api/cli/features/{urlparse.quote(feature_id)}",
|
|
26236
|
+
method="PATCH",
|
|
26237
|
+
token=str(session.get("token", "")).strip(),
|
|
26238
|
+
body={"completed": completed},
|
|
26239
|
+
)
|
|
26240
|
+
return _normalize_remote_feature_payload(payload)
|
|
26241
|
+
|
|
26242
|
+
|
|
26243
|
+
def _list_remote_features(args: argparse.Namespace, idea_id: str, *, fallback_to_idea: bool = True) -> list[dict[str, Any]]:
|
|
26244
|
+
session = _require_hosted_session(args)
|
|
26245
|
+
try:
|
|
26246
|
+
payload = _request_hosted_json(
|
|
26247
|
+
base_url=_resolve_hosted_base_url(args, session),
|
|
26248
|
+
path=f"/api/cli/ideas/{urlparse.quote(idea_id)}/features",
|
|
26249
|
+
token=str(session.get("token", "")).strip(),
|
|
26250
|
+
)
|
|
26251
|
+
if not isinstance(payload, dict):
|
|
26252
|
+
raise RuntimeError("Hosted ORP returned an invalid features payload.")
|
|
26253
|
+
return _normalize_remote_features_payload(payload)
|
|
26254
|
+
except HostedApiError:
|
|
26255
|
+
if not fallback_to_idea:
|
|
26256
|
+
raise
|
|
26257
|
+
idea = _get_remote_idea(args, idea_id)
|
|
26258
|
+
features = idea.get("features") if isinstance(idea.get("features"), list) else []
|
|
26259
|
+
return [row for row in features if isinstance(row, dict)]
|
|
26260
|
+
|
|
26261
|
+
|
|
25876
26262
|
def _get_remote_idea(args: argparse.Namespace, idea_id: str) -> dict[str, Any]:
|
|
25877
26263
|
session = _require_hosted_session(args)
|
|
25878
26264
|
payload = _request_hosted_json(
|
|
@@ -26077,7 +26463,7 @@ def cmd_idea_restore(args: argparse.Namespace) -> int:
|
|
|
26077
26463
|
|
|
26078
26464
|
def cmd_feature_list(args: argparse.Namespace) -> int:
|
|
26079
26465
|
idea = _get_remote_idea(args, args.idea_id)
|
|
26080
|
-
features =
|
|
26466
|
+
features = _list_remote_features(args, args.idea_id)
|
|
26081
26467
|
result = {
|
|
26082
26468
|
"idea_id": str(idea.get("id", "")).strip(),
|
|
26083
26469
|
"idea_title": str(idea.get("title", "")).strip(),
|
|
@@ -26119,7 +26505,8 @@ def _find_feature_by_id(idea_payload: dict[str, Any], feature_id: str) -> dict[s
|
|
|
26119
26505
|
|
|
26120
26506
|
def cmd_feature_show(args: argparse.Namespace) -> int:
|
|
26121
26507
|
idea = _get_remote_idea(args, args.idea_id)
|
|
26122
|
-
|
|
26508
|
+
features = _list_remote_features(args, args.idea_id)
|
|
26509
|
+
feature = _find_feature_by_id({"features": features}, args.feature_id)
|
|
26123
26510
|
result = {
|
|
26124
26511
|
"idea_id": str(idea.get("id", "")).strip(),
|
|
26125
26512
|
"feature": feature,
|
|
@@ -26147,14 +26534,22 @@ def cmd_feature_add(args: argparse.Namespace) -> int:
|
|
|
26147
26534
|
token=str(session.get("token", "")).strip(),
|
|
26148
26535
|
body=body,
|
|
26149
26536
|
)
|
|
26537
|
+
feature = _normalize_remote_feature_payload(payload)
|
|
26538
|
+
if "completed" in body and str(feature.get("id", "")).strip():
|
|
26539
|
+
feature = _patch_remote_feature_completion(
|
|
26540
|
+
args,
|
|
26541
|
+
feature_id=str(feature.get("id", "")).strip(),
|
|
26542
|
+
completed=bool(body["completed"]),
|
|
26543
|
+
)
|
|
26544
|
+
payload = {"ok": True, "feature": feature}
|
|
26150
26545
|
if args.json_output:
|
|
26151
26546
|
_print_json(payload)
|
|
26152
26547
|
else:
|
|
26153
26548
|
_print_pairs(
|
|
26154
26549
|
[
|
|
26155
|
-
("feature.id", str(
|
|
26156
|
-
("feature.title", str(
|
|
26157
|
-
("idea.id", str(
|
|
26550
|
+
("feature.id", str(feature.get("id", "")).strip()),
|
|
26551
|
+
("feature.title", str(feature.get("title", "")).strip()),
|
|
26552
|
+
("idea.id", str(feature.get("ideaId", args.idea_id)).strip()),
|
|
26158
26553
|
]
|
|
26159
26554
|
)
|
|
26160
26555
|
return 0
|
|
@@ -26162,11 +26557,11 @@ def cmd_feature_add(args: argparse.Namespace) -> int:
|
|
|
26162
26557
|
|
|
26163
26558
|
def cmd_feature_update(args: argparse.Namespace) -> int:
|
|
26164
26559
|
session = _require_hosted_session(args)
|
|
26165
|
-
|
|
26166
|
-
current = _find_feature_by_id(
|
|
26560
|
+
features = _list_remote_features(args, args.idea_id)
|
|
26561
|
+
current = _find_feature_by_id({"features": features}, args.feature_id)
|
|
26167
26562
|
body = _build_remote_feature_body(args, args.idea_id, current)
|
|
26168
26563
|
updated_at = str(current.get("updatedAt", "")).strip()
|
|
26169
|
-
if updated_at:
|
|
26564
|
+
if updated_at and _feature_body_has_metadata_update(body):
|
|
26170
26565
|
body["updatedAt"] = updated_at
|
|
26171
26566
|
payload = _request_hosted_json(
|
|
26172
26567
|
base_url=_resolve_hosted_base_url(args, session),
|
|
@@ -26175,14 +26570,15 @@ def cmd_feature_update(args: argparse.Namespace) -> int:
|
|
|
26175
26570
|
token=str(session.get("token", "")).strip(),
|
|
26176
26571
|
body=body,
|
|
26177
26572
|
)
|
|
26573
|
+
feature = _normalize_remote_feature_payload(payload)
|
|
26178
26574
|
if args.json_output:
|
|
26179
26575
|
_print_json(payload)
|
|
26180
26576
|
else:
|
|
26181
26577
|
_print_pairs(
|
|
26182
26578
|
[
|
|
26183
|
-
("feature.id", str(
|
|
26184
|
-
("feature.title", str(
|
|
26185
|
-
("feature.updated_at", str(
|
|
26579
|
+
("feature.id", str(feature.get("id", "")).strip()),
|
|
26580
|
+
("feature.title", str(feature.get("title", "")).strip()),
|
|
26581
|
+
("feature.updated_at", str(feature.get("updatedAt", "")).strip()),
|
|
26186
26582
|
]
|
|
26187
26583
|
)
|
|
26188
26584
|
return 0
|
|
@@ -28398,6 +28794,9 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
28398
28794
|
default=None,
|
|
28399
28795
|
help="Feature visibility override when supported by the hosted workspace",
|
|
28400
28796
|
)
|
|
28797
|
+
completion_group = parser.add_mutually_exclusive_group()
|
|
28798
|
+
completion_group.add_argument("--completed", action="store_true", help="Mark feature as completed")
|
|
28799
|
+
completion_group.add_argument("--incomplete", action="store_true", help="Mark feature as not completed")
|
|
28401
28800
|
|
|
28402
28801
|
s_home = sub.add_parser(
|
|
28403
28802
|
"home",
|
|
@@ -30980,6 +31379,26 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
30980
31379
|
add_json_flag(s_frontier_render)
|
|
30981
31380
|
s_frontier_render.set_defaults(func=cmd_frontier_render, json_output=False)
|
|
30982
31381
|
|
|
31382
|
+
s_frontier_sync_idea = frontier_sub.add_parser(
|
|
31383
|
+
"sync-idea",
|
|
31384
|
+
help="Sync local frontier plan and phase tasks to a hosted ORP idea/features",
|
|
31385
|
+
)
|
|
31386
|
+
s_frontier_sync_idea.add_argument("--idea-id", default="", help="Hosted idea id; defaults to the local project link")
|
|
31387
|
+
s_frontier_sync_idea.add_argument(
|
|
31388
|
+
"--create-idea",
|
|
31389
|
+
action="store_true",
|
|
31390
|
+
help="Create a hosted idea when the project is not already linked",
|
|
31391
|
+
)
|
|
31392
|
+
s_frontier_sync_idea.add_argument(
|
|
31393
|
+
"--no-link-project",
|
|
31394
|
+
action="store_true",
|
|
31395
|
+
help="Do not update the local project link with the synced idea/feature ids",
|
|
31396
|
+
)
|
|
31397
|
+
s_frontier_sync_idea.add_argument("--dry-run", action="store_true", help="Preview hosted changes without writing")
|
|
31398
|
+
add_base_url_flag(s_frontier_sync_idea)
|
|
31399
|
+
add_json_flag(s_frontier_sync_idea)
|
|
31400
|
+
s_frontier_sync_idea.set_defaults(func=cmd_frontier_sync_idea, json_output=False)
|
|
31401
|
+
|
|
30983
31402
|
s_frontier_doctor = frontier_sub.add_parser(
|
|
30984
31403
|
"doctor",
|
|
30985
31404
|
help="Validate frontier consistency and optionally re-render views",
|
|
@@ -27,6 +27,45 @@ The canonical hosted source of truth should be one hosted workspace record:
|
|
|
27
27
|
|
|
28
28
|
Idea notes are a compatibility bridge only after this contract lands.
|
|
29
29
|
|
|
30
|
+
## CLI Sync Contract
|
|
31
|
+
|
|
32
|
+
`orp workspace sync <selector>` is the canonical local-to-hosted sync path.
|
|
33
|
+
The web app should render the hosted workspace state produced by this command,
|
|
34
|
+
not reconstruct a competing workspace model.
|
|
35
|
+
|
|
36
|
+
The local canonical input is a reconciled workspace snapshot:
|
|
37
|
+
|
|
38
|
+
1. Start with the selected ORP workspace ledger, usually
|
|
39
|
+
`orp workspace tabs main`.
|
|
40
|
+
2. If that selector resolves to a local workspace file, match a hosted
|
|
41
|
+
workspace by the same `workspaceId` before writing hosted state.
|
|
42
|
+
3. Reconcile known local projects from ORP project startup state
|
|
43
|
+
(`orp/state.json`, including historical startup result manifests).
|
|
44
|
+
4. Use Clawdad state only to refresh projects already known from the ledger or
|
|
45
|
+
ORP startup manifests, unless a caller explicitly opts into Clawdad-only
|
|
46
|
+
projects.
|
|
47
|
+
5. Use recent Codex session metadata to refresh known project paths with the
|
|
48
|
+
newest local `codex resume ...` session. Codex-only paths are not added by
|
|
49
|
+
default.
|
|
50
|
+
|
|
51
|
+
The pushed hosted state must include, for every project/tab where available:
|
|
52
|
+
|
|
53
|
+
- `title`
|
|
54
|
+
- `project_root` / local `path`
|
|
55
|
+
- resume command plus structured `resume_tool` and `resume_session_id`
|
|
56
|
+
- `last_activity_at_utc`
|
|
57
|
+
- `last_synced_at_utc`
|
|
58
|
+
- `linked_idea_id`
|
|
59
|
+
- `linked_feature_id`
|
|
60
|
+
- `plan`
|
|
61
|
+
- `tasks`
|
|
62
|
+
|
|
63
|
+
Idea notes are only a compatibility mirror. When sync can write the matched
|
|
64
|
+
hosted workspace state directly, the hosted workspace `state` is authoritative
|
|
65
|
+
and the idea-note mirror may be skipped to avoid truncating or conflicting with
|
|
66
|
+
the full payload. If no hosted workspace target exists, sync may still write a
|
|
67
|
+
compact ` ```orp-workspace ` block to the linked idea.
|
|
68
|
+
|
|
30
69
|
## Resource Model
|
|
31
70
|
|
|
32
71
|
### Hosted Workspace
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "open-research-protocol",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.35",
|
|
4
4
|
"description": "ORP CLI (Open Research Protocol): workspace ledgers, secrets, scheduling, governed execution, and agent-friendly research workflows.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Fractal Research Group <cody@frg.earth>",
|