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 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
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 = idea.get("features") if isinstance(idea.get("features"), list) else []
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
- feature = _find_feature_by_id(idea, args.feature_id)
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(payload.get("id", "")).strip()),
26156
- ("feature.title", str(payload.get("title", "")).strip()),
26157
- ("idea.id", str(payload.get("ideaId", args.idea_id)).strip()),
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
- idea = _get_remote_idea(args, args.idea_id)
26166
- current = _find_feature_by_id(idea, args.feature_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(payload.get("id", "")).strip()),
26184
- ("feature.title", str(payload.get("title", "")).strip()),
26185
- ("feature.updated_at", str(payload.get("updatedAt", "")).strip()),
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.33",
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>",