open-research-protocol 0.4.33 → 0.4.34

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,30 @@ 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.34 - 2026-04-30
10
+
11
+ This release connects ORP frontier plans to hosted ORP ideas/features and lets
12
+ workspace snapshots carry canonical project references instead of stale copied
13
+ task text.
14
+
15
+ ### Added
16
+
17
+ - Added `orp frontier sync-idea` to create or update a hosted ORP idea from a
18
+ repo's frontier TAS/current phase, then sync milestone phases as hosted
19
+ feature tasks with completion state.
20
+ - Added project-link metadata for active frontier feature ids so
21
+ `.git/orp/link/project.json` can map local frontier phases back to hosted
22
+ ORP feature records.
23
+ - Added workspace enrichment for linked ORP projects, including compact
24
+ `linkedIdeaId` / `linkedFeatureId` references and local frontier plan/task
25
+ summaries for workspace sync previews.
26
+
27
+ ### Changed
28
+
29
+ - Workspace sync now preserves linked idea/feature references in structured
30
+ workspace manifests so the hosted web app can prefer canonical ORP
31
+ idea/features for plan and task rendering.
32
+
9
33
  ## v0.4.33 - 2026-04-30
10
34
 
11
35
  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",
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.34",
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>",
@@ -265,6 +265,8 @@ function normalizeStructuredTab(rawTab, index) {
265
265
  const title = normalizeOptionalString(rawTab.title);
266
266
  const resume = resolveResumeMetadata(rawTab);
267
267
  const tmuxSessionName = normalizeOptionalString(rawTab.tmuxSessionName);
268
+ const plan = rawTab.plan && typeof rawTab.plan === "object" && !Array.isArray(rawTab.plan) ? rawTab.plan : null;
269
+ const tasks = Array.isArray(rawTab.tasks) ? rawTab.tasks : [];
268
270
 
269
271
  return {
270
272
  lineNumber: index + 1,
@@ -279,6 +281,10 @@ function normalizeStructuredTab(rawTab, index) {
279
281
  remoteUrl: normalizeOptionalUrl(rawTab.remoteUrl, `workspace tab ${index + 1} remoteUrl`),
280
282
  remoteBranch: normalizeOptionalString(rawTab.remoteBranch),
281
283
  bootstrapCommand: normalizeOptionalCommand(rawTab.bootstrapCommand),
284
+ linkedIdeaId: normalizeOptionalString(rawTab.linkedIdeaId ?? rawTab.linked_idea_id),
285
+ linkedFeatureId: normalizeOptionalString(rawTab.linkedFeatureId ?? rawTab.linked_feature_id),
286
+ plan,
287
+ tasks,
282
288
  };
283
289
  }
284
290
 
@@ -307,6 +313,14 @@ function normalizeStructuredProject(rawProject, projectIndex) {
307
313
  codexSessionId: rawSession.codexSessionId,
308
314
  claudeSessionId: rawSession.claudeSessionId,
309
315
  tmuxSessionName: rawSession.tmuxSessionName,
316
+ linkedIdeaId: rawSession.linkedIdeaId ?? rawSession.linked_idea_id ?? rawProject.linkedIdeaId ?? rawProject.linked_idea_id,
317
+ linkedFeatureId:
318
+ rawSession.linkedFeatureId ??
319
+ rawSession.linked_feature_id ??
320
+ rawProject.linkedFeatureId ??
321
+ rawProject.linked_feature_id,
322
+ plan: rawSession.plan ?? rawProject.plan,
323
+ tasks: rawSession.tasks ?? rawProject.tasks,
310
324
  },
311
325
  sessionIndex,
312
326
  );
@@ -381,6 +395,10 @@ export function buildWorkspaceProjectGroups(entries = []) {
381
395
  remoteUrl: normalizeOptionalUrl(entry.remoteUrl, "workspace project remoteUrl"),
382
396
  remoteBranch: normalizeOptionalString(entry.remoteBranch),
383
397
  bootstrapCommand: normalizeOptionalCommand(entry.bootstrapCommand),
398
+ linkedIdeaId: normalizeOptionalString(entry.linkedIdeaId),
399
+ linkedFeatureId: normalizeOptionalString(entry.linkedFeatureId),
400
+ plan: entry.plan && typeof entry.plan === "object" && !Array.isArray(entry.plan) ? entry.plan : null,
401
+ tasks: Array.isArray(entry.tasks) && entry.tasks.length > 0 ? entry.tasks : [],
384
402
  sessions: [],
385
403
  });
386
404
  }
@@ -389,6 +407,14 @@ export function buildWorkspaceProjectGroups(entries = []) {
389
407
  project.remoteUrl = project.remoteUrl || normalizeOptionalUrl(entry.remoteUrl, "workspace project remoteUrl");
390
408
  project.remoteBranch = project.remoteBranch || normalizeOptionalString(entry.remoteBranch);
391
409
  project.bootstrapCommand = project.bootstrapCommand || normalizeOptionalCommand(entry.bootstrapCommand);
410
+ project.linkedIdeaId = project.linkedIdeaId || normalizeOptionalString(entry.linkedIdeaId);
411
+ project.linkedFeatureId = project.linkedFeatureId || normalizeOptionalString(entry.linkedFeatureId);
412
+ project.plan =
413
+ project.plan ||
414
+ (entry.plan && typeof entry.plan === "object" && !Array.isArray(entry.plan) ? entry.plan : null);
415
+ if ((!Array.isArray(project.tasks) || project.tasks.length === 0) && Array.isArray(entry.tasks) && entry.tasks.length > 0) {
416
+ project.tasks = entry.tasks;
417
+ }
392
418
 
393
419
  const resume = resolveResumeMetadata(entry);
394
420
  project.sessions.push(
@@ -413,6 +439,10 @@ export function buildWorkspaceProjectGroups(entries = []) {
413
439
  remoteUrl: project.remoteUrl || undefined,
414
440
  remoteBranch: project.remoteBranch || undefined,
415
441
  bootstrapCommand: project.bootstrapCommand || undefined,
442
+ linkedIdeaId: project.linkedIdeaId || undefined,
443
+ linkedFeatureId: project.linkedFeatureId || undefined,
444
+ plan: project.plan || undefined,
445
+ tasks: Array.isArray(project.tasks) && project.tasks.length > 0 ? project.tasks : undefined,
416
446
  sessionCount: project.sessions.length,
417
447
  sessions: project.sessions,
418
448
  }).filter(([, value]) => value !== undefined),
@@ -1,6 +1,7 @@
1
1
  import os from "node:os";
2
2
  import path from "node:path";
3
3
  import crypto from "node:crypto";
4
+ import fs from "node:fs";
4
5
 
5
6
  import { resolveResumeMetadata } from "./core-plan.js";
6
7
 
@@ -73,6 +74,8 @@ function normalizePreviousHostedTabs(workspace) {
73
74
  lastActivityAt: normalizeOptionalString(tab.last_activity_at_utc ?? tab.lastActivityAtUtc),
74
75
  linkedIdeaId: normalizeOptionalString(tab.linked_idea_id ?? tab.linkedIdeaId),
75
76
  linkedFeatureId: normalizeOptionalString(tab.linked_feature_id ?? tab.linkedFeatureId),
77
+ plan: tab.plan && typeof tab.plan === "object" && !Array.isArray(tab.plan) ? tab.plan : null,
78
+ tasks: Array.isArray(tab.tasks) ? tab.tasks : [],
76
79
  used: false,
77
80
  };
78
81
  });
@@ -111,6 +114,10 @@ function buildHostedProjectGroups(tabs) {
111
114
  remote_url: normalizeOptionalString(tab.remote_url),
112
115
  remote_branch: normalizeOptionalString(tab.remote_branch),
113
116
  bootstrap_command: normalizeOptionalString(tab.bootstrap_command),
117
+ linked_idea_id: normalizeOptionalString(tab.linked_idea_id),
118
+ linked_feature_id: normalizeOptionalString(tab.linked_feature_id),
119
+ plan: tab.plan && typeof tab.plan === "object" && !Array.isArray(tab.plan) ? tab.plan : undefined,
120
+ tasks: Array.isArray(tab.tasks) ? tab.tasks : undefined,
114
121
  sessions: [],
115
122
  });
116
123
  }
@@ -118,6 +125,12 @@ function buildHostedProjectGroups(tabs) {
118
125
  project.remote_url = project.remote_url || normalizeOptionalString(tab.remote_url);
119
126
  project.remote_branch = project.remote_branch || normalizeOptionalString(tab.remote_branch);
120
127
  project.bootstrap_command = project.bootstrap_command || normalizeOptionalString(tab.bootstrap_command);
128
+ project.linked_idea_id = project.linked_idea_id || normalizeOptionalString(tab.linked_idea_id);
129
+ project.linked_feature_id = project.linked_feature_id || normalizeOptionalString(tab.linked_feature_id);
130
+ project.plan = project.plan || (tab.plan && typeof tab.plan === "object" && !Array.isArray(tab.plan) ? tab.plan : undefined);
131
+ if ((!Array.isArray(project.tasks) || project.tasks.length === 0) && Array.isArray(tab.tasks) && tab.tasks.length > 0) {
132
+ project.tasks = tab.tasks;
133
+ }
121
134
  project.sessions.push(
122
135
  Object.fromEntries(
123
136
  Object.entries({
@@ -143,6 +156,10 @@ function buildHostedProjectGroups(tabs) {
143
156
  remote_url: project.remote_url || undefined,
144
157
  remote_branch: project.remote_branch || undefined,
145
158
  bootstrap_command: project.bootstrap_command || undefined,
159
+ linked_idea_id: project.linked_idea_id || undefined,
160
+ linked_feature_id: project.linked_feature_id || undefined,
161
+ plan: project.plan || undefined,
162
+ tasks: Array.isArray(project.tasks) && project.tasks.length > 0 ? project.tasks : undefined,
146
163
  session_count: project.sessions.length,
147
164
  sessions: project.sessions,
148
165
  }).filter(([, value]) => value !== undefined && value !== null),
@@ -150,6 +167,264 @@ function buildHostedProjectGroups(tabs) {
150
167
  );
151
168
  }
152
169
 
170
+ function readJsonIfExists(filePath) {
171
+ try {
172
+ if (!fs.existsSync(filePath)) {
173
+ return null;
174
+ }
175
+ const parsed = JSON.parse(fs.readFileSync(filePath, "utf8"));
176
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : null;
177
+ } catch {
178
+ return null;
179
+ }
180
+ }
181
+
182
+ function readTextIfExists(filePath) {
183
+ try {
184
+ if (!fs.existsSync(filePath)) {
185
+ return null;
186
+ }
187
+ const text = fs.readFileSync(filePath, "utf8").trim();
188
+ return text.length > 0 ? text : null;
189
+ } catch {
190
+ return null;
191
+ }
192
+ }
193
+
194
+ function resolveGitDir(projectRoot) {
195
+ const dotGit = path.join(projectRoot, ".git");
196
+ try {
197
+ const stat = fs.statSync(dotGit);
198
+ if (stat.isDirectory()) {
199
+ return dotGit;
200
+ }
201
+ if (stat.isFile()) {
202
+ const text = fs.readFileSync(dotGit, "utf8").trim();
203
+ const match = text.match(/^gitdir:\s*(.+)$/i);
204
+ if (match) {
205
+ const gitDir = match[1].trim();
206
+ return path.isAbsolute(gitDir) ? gitDir : path.resolve(projectRoot, gitDir);
207
+ }
208
+ }
209
+ } catch {
210
+ return null;
211
+ }
212
+ return null;
213
+ }
214
+
215
+ function readProjectLink(projectRoot, cache) {
216
+ const normalizedRoot = normalizeOptionalString(projectRoot);
217
+ if (!normalizedRoot) {
218
+ return null;
219
+ }
220
+ const cacheKey = `link:${normalizedRoot}`;
221
+ if (cache.has(cacheKey)) {
222
+ return cache.get(cacheKey);
223
+ }
224
+
225
+ const gitDir = resolveGitDir(normalizedRoot);
226
+ const linkPath = gitDir ? path.join(gitDir, "orp", "link", "project.json") : null;
227
+ const link = linkPath ? readJsonIfExists(linkPath) : null;
228
+ const ideaId = normalizeOptionalString(link?.idea_id ?? link?.ideaId);
229
+ if (!ideaId) {
230
+ cache.set(cacheKey, null);
231
+ return null;
232
+ }
233
+ const frontierFeatureIds =
234
+ link?.frontier_feature_ids && typeof link.frontier_feature_ids === "object" && !Array.isArray(link.frontier_feature_ids)
235
+ ? link.frontier_feature_ids
236
+ : link?.frontierFeatureIds && typeof link.frontierFeatureIds === "object" && !Array.isArray(link.frontierFeatureIds)
237
+ ? link.frontierFeatureIds
238
+ : {};
239
+ const normalizedLink = {
240
+ ideaId,
241
+ ideaTitle: normalizeOptionalString(link?.idea_title ?? link?.ideaTitle),
242
+ activeFeatureId: normalizeOptionalString(
243
+ link?.active_feature_id ?? link?.activeFeatureId ?? link?.linked_feature_id ?? link?.linkedFeatureId,
244
+ ),
245
+ frontierFeatureIds,
246
+ };
247
+ cache.set(cacheKey, normalizedLink);
248
+ return normalizedLink;
249
+ }
250
+
251
+ function findFrontierVersion(stack, versionId) {
252
+ const versions = Array.isArray(stack?.versions) ? stack.versions : [];
253
+ return versions.find((version) => normalizeOptionalString(version?.id) === versionId) || null;
254
+ }
255
+
256
+ function findFrontierMilestone(stack, milestoneId) {
257
+ const versions = Array.isArray(stack?.versions) ? stack.versions : [];
258
+ for (const version of versions) {
259
+ const milestones = Array.isArray(version?.milestones) ? version.milestones : [];
260
+ const milestone = milestones.find((row) => normalizeOptionalString(row?.id) === milestoneId);
261
+ if (milestone) {
262
+ return { version, milestone };
263
+ }
264
+ }
265
+ return { version: null, milestone: null };
266
+ }
267
+
268
+ function findFrontierPhase(milestone, phaseId) {
269
+ const phases = Array.isArray(milestone?.phases) ? milestone.phases : [];
270
+ return phases.find((phase) => normalizeOptionalString(phase?.id) === phaseId) || null;
271
+ }
272
+
273
+ function taskStatusFromFrontierStatus(value) {
274
+ const text = normalizeOptionalString(value)?.toLowerCase().replace(/[-\s]+/g, "_") || "";
275
+ if (["complete", "completed", "done", "terminal"].includes(text)) {
276
+ return "done";
277
+ }
278
+ if (["active", "in_progress", "running"].includes(text)) {
279
+ return "in_progress";
280
+ }
281
+ if (["blocked", "stuck"].includes(text)) {
282
+ return "blocked";
283
+ }
284
+ if (["skipped", "canceled", "cancelled"].includes(text)) {
285
+ return "skipped";
286
+ }
287
+ return "todo";
288
+ }
289
+
290
+ function titleFromTas(tasText) {
291
+ if (!tasText) {
292
+ return null;
293
+ }
294
+ const line = tasText
295
+ .split(/\r?\n/)
296
+ .map((row) => row.trim())
297
+ .find((row) => row.startsWith("# "));
298
+ return line ? line.replace(/^#\s+/, "").trim() || null : null;
299
+ }
300
+
301
+ function buildFrontierPlan({ projectRoot, tasText, state, stack }) {
302
+ const activeVersionId = normalizeOptionalString(state?.active_version ?? stack?.current_frontier?.active_version);
303
+ const activeMilestoneId = normalizeOptionalString(state?.active_milestone ?? stack?.current_frontier?.active_milestone);
304
+ const activePhaseId = normalizeOptionalString(state?.active_phase ?? stack?.current_frontier?.active_phase);
305
+ const nextAction = normalizeOptionalString(state?.next_action ?? stack?.current_frontier?.next_action);
306
+ const version = activeVersionId ? findFrontierVersion(stack, activeVersionId) : null;
307
+ const { milestone } = activeMilestoneId ? findFrontierMilestone(stack, activeMilestoneId) : { milestone: null };
308
+ const phase = activePhaseId ? findFrontierPhase(milestone, activePhaseId) : null;
309
+ const tasTitle = titleFromTas(tasText);
310
+ const summary =
311
+ tasTitle ||
312
+ normalizeOptionalString(milestone?.label) ||
313
+ normalizeOptionalString(phase?.label) ||
314
+ normalizeOptionalString(version?.label) ||
315
+ nextAction;
316
+
317
+ const bodyParts = [
318
+ nextAction ? `Current next action: ${nextAction}` : "",
319
+ activeVersionId || activeMilestoneId || activePhaseId
320
+ ? `Active frontier: ${[activeVersionId, activeMilestoneId, activePhaseId].filter(Boolean).join(" / ")}`
321
+ : "",
322
+ tasText || "",
323
+ ].filter(Boolean);
324
+
325
+ if (!summary && bodyParts.length === 0) {
326
+ return null;
327
+ }
328
+
329
+ return {
330
+ summary: summary || null,
331
+ body: bodyParts.join("\n\n"),
332
+ source: tasText ? "orp/frontier/TAS.md" : "orp/frontier/state.json",
333
+ };
334
+ }
335
+
336
+ function buildFrontierTasks({ state, stack }) {
337
+ const activeMilestoneId = normalizeOptionalString(state?.active_milestone ?? stack?.current_frontier?.active_milestone);
338
+ const activePhaseId = normalizeOptionalString(state?.active_phase ?? stack?.current_frontier?.active_phase);
339
+ const { milestone } = activeMilestoneId ? findFrontierMilestone(stack, activeMilestoneId) : { milestone: null };
340
+ const phases = Array.isArray(milestone?.phases) ? milestone.phases : [];
341
+
342
+ return phases
343
+ .map((phase, index) => {
344
+ const id = normalizeOptionalString(phase?.id) || `frontier-task-${index + 1}`;
345
+ const status = id === activePhaseId ? "in_progress" : taskStatusFromFrontierStatus(phase?.status);
346
+ return {
347
+ id,
348
+ title:
349
+ normalizeOptionalString(phase?.label) ||
350
+ normalizeOptionalString(phase?.goal) ||
351
+ id,
352
+ status,
353
+ completed: status === "done",
354
+ };
355
+ })
356
+ .filter((task) => task.title);
357
+ }
358
+
359
+ function readProjectFrontierContext(projectRoot, cache) {
360
+ const normalizedRoot = normalizeOptionalString(projectRoot);
361
+ if (!normalizedRoot) {
362
+ return null;
363
+ }
364
+ if (cache.has(normalizedRoot)) {
365
+ return cache.get(normalizedRoot);
366
+ }
367
+
368
+ const frontierRoot = path.join(normalizedRoot, "orp", "frontier");
369
+ const state = readJsonIfExists(path.join(frontierRoot, "state.json"));
370
+ const stack = readJsonIfExists(path.join(frontierRoot, "version-stack.json"));
371
+ const tasText = readTextIfExists(path.join(frontierRoot, "TAS.md"));
372
+ const projectLink = readProjectLink(normalizedRoot, cache);
373
+ if (!state && !stack && !tasText && !projectLink) {
374
+ cache.set(normalizedRoot, null);
375
+ return null;
376
+ }
377
+
378
+ const context = {
379
+ plan: buildFrontierPlan({ projectRoot: normalizedRoot, tasText, state, stack }),
380
+ tasks: stack ? buildFrontierTasks({ state, stack }) : [],
381
+ link: projectLink,
382
+ };
383
+ cache.set(normalizedRoot, context);
384
+ return context;
385
+ }
386
+
387
+ export function enrichWorkspaceTabsWithProjectContext(tabs = []) {
388
+ const projectContextCache = new Map();
389
+ return tabs.map((tab) => {
390
+ const projectRoot = normalizeOptionalString(tab?.path ?? tab?.project_root ?? tab?.projectRoot);
391
+ const projectContext = readProjectFrontierContext(projectRoot, projectContextCache);
392
+ if (!projectContext) {
393
+ return tab;
394
+ }
395
+ const plan =
396
+ tab?.plan && typeof tab.plan === "object" && !Array.isArray(tab.plan)
397
+ ? tab.plan
398
+ : projectContext.plan || undefined;
399
+ const tasks =
400
+ Array.isArray(tab?.tasks) && tab.tasks.length > 0
401
+ ? tab.tasks
402
+ : Array.isArray(projectContext.tasks) && projectContext.tasks.length > 0
403
+ ? projectContext.tasks
404
+ : undefined;
405
+
406
+ return Object.fromEntries(
407
+ Object.entries({
408
+ ...tab,
409
+ linkedIdeaId: tab?.linkedIdeaId ?? tab?.linked_idea_id ?? projectContext.link?.ideaId,
410
+ linkedFeatureId: tab?.linkedFeatureId ?? tab?.linked_feature_id ?? projectContext.link?.activeFeatureId,
411
+ plan,
412
+ tasks,
413
+ }).filter(([, value]) => value !== undefined && value !== null),
414
+ );
415
+ });
416
+ }
417
+
418
+ export function enrichWorkspaceManifestWithProjectContext(manifest) {
419
+ if (!manifest || typeof manifest !== "object" || Array.isArray(manifest)) {
420
+ return manifest;
421
+ }
422
+ return {
423
+ ...manifest,
424
+ tabs: enrichWorkspaceTabsWithProjectContext(Array.isArray(manifest.tabs) ? manifest.tabs : []),
425
+ };
426
+ }
427
+
153
428
  export function buildHostedWorkspaceState(manifest, options = {}) {
154
429
  if (!manifest || typeof manifest !== "object" || Array.isArray(manifest)) {
155
430
  throw new Error("workspace manifest is required to build a hosted workspace state payload");
@@ -166,11 +441,13 @@ export function buildHostedWorkspaceState(manifest, options = {}) {
166
441
  const capturedAt = normalizeOptionalString(options.capturedAt) || new Date().toISOString();
167
442
  const updatedAt = normalizeOptionalString(options.updatedAt) || capturedAt;
168
443
  const previousTabs = normalizePreviousHostedTabs(previousWorkspace);
444
+ const projectContextCache = new Map();
169
445
 
170
446
  const tabs = manifest.tabs.map((tab, index) => {
171
447
  const previous = matchPreviousHostedTab(tab, previousTabs);
172
448
  const title = normalizeOptionalString(tab.title) || previous?.title || null;
173
449
  const projectRoot = normalizeOptionalString(tab.path);
450
+ const projectContext = readProjectFrontierContext(projectRoot, projectContextCache);
174
451
  const repoLabel = previous?.repoLabel || path.basename(String(projectRoot).replace(/\/+$/, "")) || projectRoot;
175
452
  const terminalTitle = previous?.terminalTitle || title || repoLabel;
176
453
  const resume = resolveResumeMetadata({
@@ -220,8 +497,23 @@ export function buildHostedWorkspaceState(manifest, options = {}) {
220
497
  focus_summary: previous?.focusSummary || undefined,
221
498
  trajectory_summary: previous?.trajectorySummary || undefined,
222
499
  last_activity_at_utc: previous?.lastActivityAt || undefined,
223
- linked_idea_id: previous?.linkedIdeaId || undefined,
224
- linked_feature_id: previous?.linkedFeatureId || undefined,
500
+ linked_feature_id:
501
+ normalizeOptionalString(tab.linkedFeatureId ?? tab.linked_feature_id) ||
502
+ projectContext?.link?.activeFeatureId ||
503
+ previous?.linkedFeatureId ||
504
+ undefined,
505
+ linked_idea_id:
506
+ normalizeOptionalString(tab.linkedIdeaId ?? tab.linked_idea_id) ||
507
+ projectContext?.link?.ideaId ||
508
+ previous?.linkedIdeaId ||
509
+ undefined,
510
+ plan: projectContext?.plan || previous?.plan || undefined,
511
+ tasks:
512
+ Array.isArray(projectContext?.tasks) && projectContext.tasks.length > 0
513
+ ? projectContext.tasks
514
+ : Array.isArray(previous?.tasks) && previous.tasks.length > 0
515
+ ? previous.tasks
516
+ : undefined,
225
517
  }).filter(([, value]) => value !== undefined && value !== null),
226
518
  );
227
519
  });
@@ -41,7 +41,11 @@ export {
41
41
  runWorkspaceAddTab,
42
42
  runWorkspaceRemoveTab,
43
43
  } from "./ledger.js";
44
- export { buildHostedWorkspaceState } from "./hosted-state.js";
44
+ export {
45
+ buildHostedWorkspaceState,
46
+ enrichWorkspaceManifestWithProjectContext,
47
+ enrichWorkspaceTabsWithProjectContext,
48
+ } from "./hosted-state.js";
45
49
  export {
46
50
  applyWorkspaceSlotsToInventory,
47
51
  buildWorkspaceInventory,
@@ -10,6 +10,7 @@ import {
10
10
  resolveResumeMetadata,
11
11
  WORKSPACE_SCHEMA_VERSION,
12
12
  } from "./core-plan.js";
13
+ import { enrichWorkspaceManifestWithProjectContext } from "./hosted-state.js";
13
14
  import { fetchIdeaPayload, loadWorkspaceSource, updateIdeaPayload } from "./orp.js";
14
15
  import { cacheManagedWorkspaceManifest } from "./registry.js";
15
16
 
@@ -198,6 +199,10 @@ function serializeWorkspaceManifest(manifest) {
198
199
  resumeCommand: normalizeOptionalString(entry.resumeCommand) ?? undefined,
199
200
  resumeTool: normalizeOptionalString(entry.resumeTool) ?? undefined,
200
201
  resumeSessionId: normalizeOptionalString(entry.resumeSessionId ?? entry.sessionId) ?? undefined,
202
+ linkedIdeaId: normalizeOptionalString(entry.linkedIdeaId ?? entry.linked_idea_id) ?? undefined,
203
+ linkedFeatureId: normalizeOptionalString(entry.linkedFeatureId ?? entry.linked_feature_id) ?? undefined,
204
+ plan: entry.plan && typeof entry.plan === "object" && !Array.isArray(entry.plan) ? entry.plan : undefined,
205
+ tasks: Array.isArray(entry.tasks) && entry.tasks.length > 0 ? entry.tasks : undefined,
201
206
  codexSessionId:
202
207
  normalizeOptionalString(entry.resumeTool) === "codex"
203
208
  ? normalizeOptionalString(entry.codexSessionId ?? entry.resumeSessionId ?? entry.sessionId) ?? undefined
@@ -250,6 +255,10 @@ export function buildWorkspaceSyncPreview({ source, parsed, targetIdea, workspac
250
255
  resumeSessionId: entry.sessionId || null,
251
256
  codexSessionId: entry.resumeTool === "codex" ? entry.sessionId || null : null,
252
257
  claudeSessionId: entry.resumeTool === "claude" ? entry.sessionId || null : null,
258
+ linkedIdeaId: entry.linkedIdeaId || null,
259
+ linkedFeatureId: entry.linkedFeatureId || null,
260
+ plan: entry.plan || null,
261
+ tasks: Array.isArray(entry.tasks) ? entry.tasks : [],
253
262
  })),
254
263
  }
255
264
  : {
@@ -272,6 +281,7 @@ export function buildWorkspaceSyncPreview({ source, parsed, targetIdea, workspac
272
281
  resolveResumeMetadata(entry).resumeTool === "claude" ? resolveResumeMetadata(entry).resumeSessionId : null,
273
282
  })),
274
283
  };
284
+ const enrichedManifest = enrichWorkspaceManifestWithProjectContext(manifest);
275
285
 
276
286
  const narrativeSourceNotes =
277
287
  source.sourceType === "workspace-file" ? targetIdea.notes || "" : source.notes || targetIdea.notes || "";
@@ -280,7 +290,7 @@ export function buildWorkspaceSyncPreview({ source, parsed, targetIdea, workspac
280
290
  });
281
291
  const nextNotes = composeWorkspaceNotes({
282
292
  narrativeNotes,
283
- manifest,
293
+ manifest: enrichedManifest,
284
294
  });
285
295
 
286
296
  return {
@@ -289,11 +299,11 @@ export function buildWorkspaceSyncPreview({ source, parsed, targetIdea, workspac
289
299
  sourceType: source.sourceType,
290
300
  sourceLabel: source.sourceLabel,
291
301
  parseMode: parsed.parseMode,
292
- workspaceId: manifest.workspaceId,
293
- manifest,
302
+ workspaceId: enrichedManifest.workspaceId,
303
+ manifest: enrichedManifest,
294
304
  nextNotes,
295
305
  nextNotesLength: nextNotes.length,
296
- tabs: manifest.tabs,
306
+ tabs: enrichedManifest.tabs,
297
307
  skipped: parsed.skipped,
298
308
  };
299
309
  }
@@ -1,5 +1,8 @@
1
1
  import test from "node:test";
2
2
  import assert from "node:assert/strict";
3
+ import fs from "node:fs/promises";
4
+ import os from "node:os";
5
+ import path from "node:path";
3
6
 
4
7
  import {
5
8
  buildLaunchPlan,
@@ -243,6 +246,51 @@ Workspace summary.
243
246
  assert.doesNotMatch(preview.nextNotes, /\/Volumes\/Code_2TB\/code\/orp: codex resume/);
244
247
  });
245
248
 
249
+ test("buildWorkspaceSyncPreview enriches workspace-file tabs with linked ORP project context", async () => {
250
+ const projectRoot = await fs.mkdtemp(path.join(os.tmpdir(), "orp-workspace-sync-frontier-"));
251
+ await fs.mkdir(path.join(projectRoot, ".git", "orp", "link"), { recursive: true });
252
+ await fs.writeFile(
253
+ path.join(projectRoot, ".git", "orp", "link", "project.json"),
254
+ JSON.stringify(
255
+ {
256
+ idea_id: "idea-linked",
257
+ active_feature_id: "feature-active",
258
+ project_root: projectRoot,
259
+ },
260
+ null,
261
+ 2,
262
+ ),
263
+ "utf8",
264
+ );
265
+ const source = {
266
+ sourceType: "workspace-file",
267
+ sourceLabel: "/tmp/workspace.json",
268
+ sourcePath: "/tmp/workspace.json",
269
+ title: "Workspace idea",
270
+ notes: "",
271
+ workspaceManifest: {
272
+ version: "1",
273
+ workspaceId: "workspace-file-demo",
274
+ tabs: [{ title: "Linked project", path: projectRoot }],
275
+ },
276
+ };
277
+ const parsed = parseWorkspaceSource(source);
278
+ const preview = buildWorkspaceSyncPreview({
279
+ source,
280
+ parsed,
281
+ targetIdea: {
282
+ id: "idea-123",
283
+ title: "Workspace idea",
284
+ notes: "",
285
+ },
286
+ });
287
+
288
+ assert.equal(preview.tabs[0]?.linkedIdeaId, "idea-linked");
289
+ assert.equal(preview.tabs[0]?.linkedFeatureId, "feature-active");
290
+ assert.match(preview.nextNotes, /"linkedIdeaId": "idea-linked"/);
291
+ assert.match(preview.nextNotes, /"linkedFeatureId": "feature-active"/);
292
+ });
293
+
246
294
  test("resolveWorkspaceSyncTargetIdeaId supports hosted idea and hosted workspace sources", () => {
247
295
  assert.equal(
248
296
  resolveWorkspaceSyncTargetIdeaId({
@@ -0,0 +1,134 @@
1
+ import test from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import fs from "node:fs/promises";
4
+ import os from "node:os";
5
+ import path from "node:path";
6
+
7
+ import { buildHostedWorkspaceState } from "../src/index.js";
8
+
9
+ async function makeFrontierProject() {
10
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), "orp-hosted-state-frontier-"));
11
+ const frontierRoot = path.join(root, "orp", "frontier");
12
+ const linkRoot = path.join(root, ".git", "orp", "link");
13
+ await fs.mkdir(frontierRoot, { recursive: true });
14
+ await fs.mkdir(linkRoot, { recursive: true });
15
+ await fs.writeFile(
16
+ path.join(frontierRoot, "TAS.md"),
17
+ [
18
+ "# ORP TAS: Evidence-Backed Conditional Strategy Controls",
19
+ "",
20
+ "## Active Task Order",
21
+ "",
22
+ "1. Define a small replay metadata taxonomy for semantic regimes.",
23
+ "2. Add a metadata-quality gate.",
24
+ ].join("\n"),
25
+ "utf8",
26
+ );
27
+ await fs.writeFile(
28
+ path.join(frontierRoot, "state.json"),
29
+ JSON.stringify(
30
+ {
31
+ active_version: "v0",
32
+ active_milestone: "v0.2",
33
+ active_phase: "regime-metadata-quality",
34
+ next_action: "Implement replay metadata taxonomy and metadata-quality gates.",
35
+ },
36
+ null,
37
+ 2,
38
+ ),
39
+ "utf8",
40
+ );
41
+ await fs.writeFile(
42
+ path.join(frontierRoot, "version-stack.json"),
43
+ JSON.stringify(
44
+ {
45
+ versions: [
46
+ {
47
+ id: "v0",
48
+ label: "Dry-run Topstep 50K lab",
49
+ milestones: [
50
+ {
51
+ id: "v0.2",
52
+ label: "Evidence-backed conditional strategy controls",
53
+ phases: [
54
+ {
55
+ id: "signal-quality-and-control-provenance",
56
+ label: "Signal quality and control provenance",
57
+ status: "completed",
58
+ },
59
+ {
60
+ id: "regime-metadata-quality",
61
+ label: "Regime metadata quality",
62
+ status: "active",
63
+ },
64
+ {
65
+ id: "first-regime-sample-capture",
66
+ label: "First regime sample capture",
67
+ status: "planned",
68
+ },
69
+ ],
70
+ },
71
+ ],
72
+ },
73
+ ],
74
+ },
75
+ null,
76
+ 2,
77
+ ),
78
+ "utf8",
79
+ );
80
+ await fs.writeFile(
81
+ path.join(linkRoot, "project.json"),
82
+ JSON.stringify(
83
+ {
84
+ idea_id: "idea-123",
85
+ idea_title: "Canonical futures idea",
86
+ active_feature_id: "feature-regime-metadata-quality",
87
+ frontier_feature_ids: {
88
+ "regime-metadata-quality": "feature-regime-metadata-quality",
89
+ },
90
+ project_root: root,
91
+ },
92
+ null,
93
+ 2,
94
+ ),
95
+ "utf8",
96
+ );
97
+ return root;
98
+ }
99
+
100
+ test("buildHostedWorkspaceState compiles local ORP frontier plan and tasks", async () => {
101
+ const projectRoot = await makeFrontierProject();
102
+ const state = buildHostedWorkspaceState({
103
+ version: "1",
104
+ workspaceId: "main-cody-1",
105
+ title: "main-cody-1",
106
+ tabs: [
107
+ {
108
+ title: "futures-prop-trading-lab",
109
+ path: projectRoot,
110
+ resumeTool: "codex",
111
+ resumeSessionId: "019d4f24-c8ba-78b2-a726-48b1ce9f0fe9",
112
+ },
113
+ ],
114
+ });
115
+
116
+ assert.equal(state.tabs.length, 1);
117
+ assert.equal(state.tabs[0].plan.summary, "ORP TAS: Evidence-Backed Conditional Strategy Controls");
118
+ assert.equal(state.tabs[0].plan.source, "orp/frontier/TAS.md");
119
+ assert.equal(state.tabs[0].linked_idea_id, "idea-123");
120
+ assert.equal(state.tabs[0].linked_feature_id, "feature-regime-metadata-quality");
121
+ assert.match(state.tabs[0].plan.body, /Current next action: Implement replay metadata taxonomy/);
122
+ assert.deepEqual(
123
+ state.tabs[0].tasks.map((task) => [task.id, task.status]),
124
+ [
125
+ ["signal-quality-and-control-provenance", "done"],
126
+ ["regime-metadata-quality", "in_progress"],
127
+ ["first-regime-sample-capture", "todo"],
128
+ ],
129
+ );
130
+ assert.equal(state.projects[0].plan.summary, "ORP TAS: Evidence-Backed Conditional Strategy Controls");
131
+ assert.equal(state.projects[0].tasks.length, 3);
132
+ assert.equal(state.projects[0].linked_idea_id, "idea-123");
133
+ assert.equal(state.projects[0].linked_feature_id, "feature-regime-metadata-quality");
134
+ });