open-research-protocol 0.4.32 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "open-research-protocol",
3
- "version": "0.4.32",
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({