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 +24 -0
- package/cli/orp.py +430 -11
- package/package.json +1 -1
- package/packages/orp-workspace-launcher/src/core-plan.js +30 -0
- package/packages/orp-workspace-launcher/src/hosted-state.js +294 -2
- package/packages/orp-workspace-launcher/src/index.js +5 -1
- package/packages/orp-workspace-launcher/src/sync.js +14 -4
- package/packages/orp-workspace-launcher/test/core-plan.test.js +48 -0
- package/packages/orp-workspace-launcher/test/hosted-state.test.js +134 -0
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 =
|
|
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",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "open-research-protocol",
|
|
3
|
-
"version": "0.4.
|
|
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
|
-
|
|
224
|
-
|
|
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 {
|
|
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:
|
|
293
|
-
manifest,
|
|
302
|
+
workspaceId: enrichedManifest.workspaceId,
|
|
303
|
+
manifest: enrichedManifest,
|
|
294
304
|
nextNotes,
|
|
295
305
|
nextNotesLength: nextNotes.length,
|
|
296
|
-
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
|
+
});
|