open-research-protocol 0.4.33 → 0.4.35

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -218,6 +218,22 @@ export function findHostedWorkspaceLinkedToIdea(workspaces = [], ideaId) {
218
218
  );
219
219
  }
220
220
 
221
+ export function findHostedWorkspaceByWorkspaceId(workspaces = [], workspaceId) {
222
+ const targetWorkspaceId = normalizeOptionalString(workspaceId);
223
+ if (!targetWorkspaceId || !Array.isArray(workspaces)) {
224
+ return null;
225
+ }
226
+
227
+ return (
228
+ workspaces.find((workspace) => {
229
+ if (!workspace || typeof workspace !== "object" || Array.isArray(workspace)) {
230
+ return false;
231
+ }
232
+ return normalizeOptionalString(workspace.workspace_id ?? workspace.id) === targetWorkspaceId;
233
+ }) || null
234
+ );
235
+ }
236
+
221
237
  export async function findHostedWorkspaceByLinkedIdea(ideaId, options = {}) {
222
238
  const payload = await fetchHostedWorkspacesPayload(options);
223
239
  return findHostedWorkspaceLinkedToIdea(payload.workspaces, ideaId);
@@ -742,6 +758,13 @@ export function buildWorkspaceManifestFromHostedWorkspacePayload(payload) {
742
758
  resumeSessionId: getTextValue(tab, "resume_session_id", "resumeSessionId"),
743
759
  codexSessionId: getTextValue(tab, "codex_session_id", "codexSessionId"),
744
760
  tmuxSessionName: getTextValue(tab, "tmux_session_name", "tmuxSessionName"),
761
+ linkedIdeaId: getTextValue(tab, "linked_idea_id", "linkedIdeaId"),
762
+ linkedFeatureId: getTextValue(tab, "linked_feature_id", "linkedFeatureId"),
763
+ plan: getObjectValue(tab, "plan") || undefined,
764
+ tasks: getArrayValue(tab, "tasks").length > 0 ? getArrayValue(tab, "tasks") : undefined,
765
+ lastActivityAt: getTextValue(tab, "last_activity_at_utc", "lastActivityAtUtc"),
766
+ lastSyncedAt: getTextValue(tab, "last_synced_at_utc", "lastSyncedAtUtc"),
767
+ syncSource: getTextValue(tab, "sync_source", "syncSource"),
745
768
  }).filter(([, value]) => value !== undefined && value !== null),
746
769
  ),
747
770
  ),
@@ -53,12 +53,14 @@ function buildResumeSessionRows(tabs) {
53
53
  Object.entries({
54
54
  title: normalizeOptionalString(tab.title) ?? undefined,
55
55
  path: normalizeOptionalString(tab.path) ?? undefined,
56
- resumeCommand: resumeCommand ?? undefined,
57
- resumeTool: resumeTool ?? undefined,
58
- resumeSessionId: resumeSessionId ?? undefined,
59
- codexSessionId: resumeTool === "codex" ? resumeSessionId ?? undefined : undefined,
60
- }).filter(([, value]) => value !== undefined),
61
- );
56
+ resumeCommand: resumeCommand ?? undefined,
57
+ resumeTool: resumeTool ?? undefined,
58
+ resumeSessionId: resumeSessionId ?? undefined,
59
+ codexSessionId: resumeTool === "codex" ? resumeSessionId ?? undefined : undefined,
60
+ lastActivityAt: normalizeOptionalString(tab.lastActivityAt ?? tab.last_activity_at_utc) ?? undefined,
61
+ lastSyncedAt: normalizeOptionalString(tab.lastSyncedAt ?? tab.last_synced_at_utc) ?? undefined,
62
+ }).filter(([, value]) => value !== undefined),
63
+ );
62
64
  })
63
65
  .filter(Boolean);
64
66
  }
@@ -112,11 +114,13 @@ function normalizeRegistryEntry(rawEntry) {
112
114
  title: normalizeOptionalString(session.title) ?? undefined,
113
115
  path: normalizeOptionalString(session.path) ?? undefined,
114
116
  resumeCommand: resumeCommand ?? undefined,
115
- resumeTool: normalizeOptionalString(session.resumeTool) ?? undefined,
116
- resumeSessionId: resumeSessionId ?? undefined,
117
- codexSessionId: normalizeOptionalString(session.codexSessionId) ?? undefined,
118
- }).filter(([, value]) => value !== undefined),
119
- );
117
+ resumeTool: normalizeOptionalString(session.resumeTool) ?? undefined,
118
+ resumeSessionId: resumeSessionId ?? undefined,
119
+ codexSessionId: normalizeOptionalString(session.codexSessionId) ?? undefined,
120
+ lastActivityAt: normalizeOptionalString(session.lastActivityAt ?? session.last_activity_at_utc) ?? undefined,
121
+ lastSyncedAt: normalizeOptionalString(session.lastSyncedAt ?? session.last_synced_at_utc) ?? undefined,
122
+ }).filter(([, value]) => value !== undefined),
123
+ );
120
124
  })
121
125
  .filter(Boolean)
122
126
  : Array.isArray(rawEntry.codexSessions)
@@ -137,6 +141,8 @@ function normalizeRegistryEntry(rawEntry) {
137
141
  resumeTool: codexSessionId ? "codex" : undefined,
138
142
  resumeSessionId: codexSessionId,
139
143
  codexSessionId,
144
+ lastActivityAt: normalizeOptionalString(session.lastActivityAt ?? session.last_activity_at_utc) ?? undefined,
145
+ lastSyncedAt: normalizeOptionalString(session.lastSyncedAt ?? session.last_synced_at_utc) ?? undefined,
140
146
  }).filter(([, value]) => value !== undefined),
141
147
  );
142
148
  })
@@ -351,6 +357,13 @@ function serializeManagedWorkspaceManifest(manifest) {
351
357
  resumeSessionId: resumeSessionId ?? undefined,
352
358
  codexSessionId: resumeTool === "codex" ? resumeSessionId ?? undefined : undefined,
353
359
  claudeSessionId: resumeTool === "claude" ? resumeSessionId ?? undefined : undefined,
360
+ linkedIdeaId: normalizeOptionalString(tab.linkedIdeaId ?? tab.linked_idea_id) ?? undefined,
361
+ linkedFeatureId: normalizeOptionalString(tab.linkedFeatureId ?? tab.linked_feature_id) ?? undefined,
362
+ plan: tab.plan && typeof tab.plan === "object" && !Array.isArray(tab.plan) ? tab.plan : undefined,
363
+ tasks: Array.isArray(tab.tasks) && tab.tasks.length > 0 ? tab.tasks : undefined,
364
+ lastActivityAt: normalizeOptionalString(tab.lastActivityAt ?? tab.last_activity_at_utc) ?? undefined,
365
+ lastSyncedAt: normalizeOptionalString(tab.lastSyncedAt ?? tab.last_synced_at_utc) ?? undefined,
366
+ syncSource: normalizeOptionalString(tab.syncSource ?? tab.sync_source) ?? undefined,
354
367
  }).filter(([, value]) => value !== undefined),
355
368
  );
356
369
  })
@@ -10,11 +10,22 @@ import {
10
10
  resolveResumeMetadata,
11
11
  WORKSPACE_SCHEMA_VERSION,
12
12
  } from "./core-plan.js";
13
- import { fetchIdeaPayload, loadWorkspaceSource, updateIdeaPayload } from "./orp.js";
13
+ import { buildHostedWorkspaceState, enrichWorkspaceManifestWithProjectContext } from "./hosted-state.js";
14
+ import { mergeLocalProjectInventoryIntoManifest } from "./local-inventory.js";
15
+ import {
16
+ buildWorkspaceManifestFromHostedWorkspacePayload,
17
+ fetchHostedWorkspacesPayload,
18
+ fetchIdeaPayload,
19
+ findHostedWorkspaceByWorkspaceId,
20
+ loadWorkspaceSource,
21
+ pushHostedWorkspaceState,
22
+ updateIdeaPayload,
23
+ } from "./orp.js";
14
24
  import { cacheManagedWorkspaceManifest } from "./registry.js";
15
25
 
16
26
  const STRUCTURED_WORKSPACE_BLOCK_PATTERN = /```orp-workspace\s*[\s\S]*?```/i;
17
27
  const WORKSPACE_TITLE_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
28
+ const MAX_HOSTED_IDEA_NOTES_LENGTH = 9500;
18
29
 
19
30
  function printSyncHelp() {
20
31
  console.log(`ORP workspace sync
@@ -128,11 +139,38 @@ async function resolveWorkspaceSyncTargetSource(source, options) {
128
139
  if (!options.workspaceFile && !options.notesFile && (source.sourceType === "hosted-idea" || source.sourceType === "hosted-workspace")) {
129
140
  return source;
130
141
  }
131
- return loadWorkspaceSource({
142
+ const targetSource = await loadWorkspaceSource({
132
143
  ideaId: options.ideaId,
133
144
  baseUrl: options.baseUrl,
134
145
  orpCommand: options.orpCommand,
135
146
  });
147
+ if (targetSource.sourceType !== "workspace-file") {
148
+ return targetSource;
149
+ }
150
+
151
+ const workspaceId =
152
+ normalizeOptionalString(source.workspaceManifest?.workspaceId) ||
153
+ normalizeOptionalString(targetSource.workspaceManifest?.workspaceId);
154
+ if (!workspaceId) {
155
+ return targetSource;
156
+ }
157
+
158
+ const payload = await fetchHostedWorkspacesPayload(options).catch(() => null);
159
+ const hostedWorkspace = findHostedWorkspaceByWorkspaceId(payload?.workspaces || [], workspaceId);
160
+ if (!hostedWorkspace) {
161
+ return targetSource;
162
+ }
163
+
164
+ return {
165
+ sourceType: "hosted-workspace",
166
+ sourceLabel: hostedWorkspace.title || workspaceId,
167
+ title: hostedWorkspace.title || workspaceId,
168
+ workspaceManifest: buildWorkspaceManifestFromHostedWorkspacePayload(hostedWorkspace),
169
+ notes: "",
170
+ hostedWorkspace,
171
+ payload: { ok: true, workspace: hostedWorkspace },
172
+ bridgedFromLocalWorkspaceFile: targetSource.sourcePath || targetSource.sourceLabel || true,
173
+ };
136
174
  }
137
175
 
138
176
  export function validateWorkspaceTitle(value, label = "--title") {
@@ -198,6 +236,15 @@ function serializeWorkspaceManifest(manifest) {
198
236
  resumeCommand: normalizeOptionalString(entry.resumeCommand) ?? undefined,
199
237
  resumeTool: normalizeOptionalString(entry.resumeTool) ?? undefined,
200
238
  resumeSessionId: normalizeOptionalString(entry.resumeSessionId ?? entry.sessionId) ?? undefined,
239
+ linkedIdeaId: normalizeOptionalString(entry.linkedIdeaId ?? entry.linked_idea_id) ?? undefined,
240
+ linkedFeatureId: normalizeOptionalString(entry.linkedFeatureId ?? entry.linked_feature_id) ?? undefined,
241
+ plan: entry.plan && typeof entry.plan === "object" && !Array.isArray(entry.plan) ? entry.plan : undefined,
242
+ tasks: Array.isArray(entry.tasks) && entry.tasks.length > 0 ? entry.tasks : undefined,
243
+ lastActivityAt:
244
+ normalizeOptionalString(entry.lastActivityAt ?? entry.last_activity_at_utc ?? entry.lastActivityAtUtc) ?? undefined,
245
+ lastSyncedAt:
246
+ normalizeOptionalString(entry.lastSyncedAt ?? entry.last_synced_at_utc ?? entry.lastSyncedAtUtc) ?? undefined,
247
+ syncSource: normalizeOptionalString(entry.syncSource ?? entry.sync_source) ?? undefined,
201
248
  codexSessionId:
202
249
  normalizeOptionalString(entry.resumeTool) === "codex"
203
250
  ? normalizeOptionalString(entry.codexSessionId ?? entry.resumeSessionId ?? entry.sessionId) ?? undefined
@@ -232,6 +279,110 @@ function composeWorkspaceNotes({ narrativeNotes, manifest }) {
232
279
  ]);
233
280
  }
234
281
 
282
+ function serializeCompactWorkspaceManifest(manifest, options = {}) {
283
+ const tabs = manifest.tabs.map((entry) =>
284
+ Object.fromEntries(
285
+ Object.entries({
286
+ title: normalizeOptionalString(entry.title) ?? undefined,
287
+ path: String(entry.path).trim(),
288
+ remoteUrl: normalizeOptionalString(entry.remoteUrl) ?? undefined,
289
+ remoteBranch: normalizeOptionalString(entry.remoteBranch) ?? undefined,
290
+ bootstrapCommand: normalizeOptionalString(entry.bootstrapCommand) ?? undefined,
291
+ resumeCommand: normalizeOptionalString(entry.resumeCommand) ?? undefined,
292
+ resumeTool: normalizeOptionalString(entry.resumeTool) ?? undefined,
293
+ resumeSessionId: normalizeOptionalString(entry.resumeSessionId ?? entry.sessionId) ?? undefined,
294
+ linkedIdeaId: normalizeOptionalString(entry.linkedIdeaId ?? entry.linked_idea_id) ?? undefined,
295
+ linkedFeatureId: normalizeOptionalString(entry.linkedFeatureId ?? entry.linked_feature_id) ?? undefined,
296
+ lastActivityAt:
297
+ normalizeOptionalString(entry.lastActivityAt ?? entry.last_activity_at_utc ?? entry.lastActivityAtUtc) ?? undefined,
298
+ lastSyncedAt:
299
+ normalizeOptionalString(entry.lastSyncedAt ?? entry.last_synced_at_utc ?? entry.lastSyncedAtUtc) ?? undefined,
300
+ }).filter(([, value]) => value !== undefined),
301
+ ),
302
+ );
303
+
304
+ return JSON.stringify(
305
+ Object.fromEntries(
306
+ Object.entries({
307
+ version: WORKSPACE_SCHEMA_VERSION,
308
+ workspaceId: normalizeOptionalString(manifest.workspaceId) ?? undefined,
309
+ title: normalizeOptionalString(manifest.title) ?? undefined,
310
+ source: "hosted-workspace",
311
+ hostedWorkspaceId: normalizeOptionalString(options.hostedWorkspaceId) ?? undefined,
312
+ tabCount: tabs.length,
313
+ projectCount: new Set(tabs.map((tab) => tab.path).filter(Boolean)).size,
314
+ tabs,
315
+ }).filter(([, value]) => value !== undefined),
316
+ ),
317
+ null,
318
+ 2,
319
+ );
320
+ }
321
+
322
+ function buildStoredIdeaNotes({ narrativeNotes, manifest, hostedWorkspaceId }) {
323
+ const fullNotes = composeWorkspaceNotes({ narrativeNotes, manifest });
324
+ if (fullNotes.length <= MAX_HOSTED_IDEA_NOTES_LENGTH) {
325
+ return {
326
+ notes: fullNotes,
327
+ compacted: false,
328
+ omittedPlanTaskDetails: false,
329
+ };
330
+ }
331
+
332
+ const compactManifest = serializeCompactWorkspaceManifest(manifest, { hostedWorkspaceId });
333
+ const compactNotes = combineNoteSections([
334
+ narrativeNotes,
335
+ hostedWorkspaceId
336
+ ? `Full workspace state, including plans and tasks, is stored on hosted ORP workspace ${hostedWorkspaceId}.`
337
+ : "Full workspace plan and task state is omitted from this idea-note compatibility mirror.",
338
+ `\`\`\`orp-workspace\n${compactManifest}\n\`\`\``,
339
+ ]);
340
+ if (compactNotes.length <= MAX_HOSTED_IDEA_NOTES_LENGTH) {
341
+ return {
342
+ notes: compactNotes,
343
+ compacted: true,
344
+ omittedPlanTaskDetails: true,
345
+ };
346
+ }
347
+
348
+ const buildPointerManifest = (includeTabs) => ({
349
+ version: WORKSPACE_SCHEMA_VERSION,
350
+ workspaceId: normalizeOptionalString(manifest.workspaceId),
351
+ title: normalizeOptionalString(manifest.title),
352
+ source: "hosted-workspace",
353
+ hostedWorkspaceId: normalizeOptionalString(hostedWorkspaceId),
354
+ tabCount: Array.isArray(manifest.tabs) ? manifest.tabs.length : 0,
355
+ projectCount: new Set((manifest.tabs || []).map((tab) => tab.path).filter(Boolean)).size,
356
+ tabs: includeTabs
357
+ ? (manifest.tabs || []).map((tab) =>
358
+ Object.fromEntries(
359
+ Object.entries({
360
+ title: normalizeOptionalString(tab.title) ?? undefined,
361
+ path: normalizeOptionalString(tab.path) ?? undefined,
362
+ }).filter(([, value]) => value !== undefined),
363
+ ),
364
+ )
365
+ : undefined,
366
+ });
367
+ const pointerText = (includeTabs) => JSON.stringify(buildPointerManifest(includeTabs), null, 2);
368
+ const pointerNotes = (includeTabs) =>
369
+ combineNoteSections([
370
+ narrativeNotes,
371
+ hostedWorkspaceId
372
+ ? `Full workspace state is stored on hosted ORP workspace ${hostedWorkspaceId}.`
373
+ : "Full workspace state is stored in the ORP workspace sync payload.",
374
+ `\`\`\`orp-workspace\n${pointerText(includeTabs)}\n\`\`\``,
375
+ ]);
376
+ return {
377
+ notes:
378
+ pointerNotes(true).length <= MAX_HOSTED_IDEA_NOTES_LENGTH
379
+ ? pointerNotes(true)
380
+ : pointerNotes(false),
381
+ compacted: true,
382
+ omittedPlanTaskDetails: true,
383
+ };
384
+ }
385
+
235
386
  export function buildWorkspaceSyncPreview({ source, parsed, targetIdea, workspaceTitle = null }) {
236
387
  const manifest = parsed.manifest
237
388
  ? {
@@ -250,6 +401,13 @@ export function buildWorkspaceSyncPreview({ source, parsed, targetIdea, workspac
250
401
  resumeSessionId: entry.sessionId || null,
251
402
  codexSessionId: entry.resumeTool === "codex" ? entry.sessionId || null : null,
252
403
  claudeSessionId: entry.resumeTool === "claude" ? entry.sessionId || null : null,
404
+ linkedIdeaId: entry.linkedIdeaId || null,
405
+ linkedFeatureId: entry.linkedFeatureId || null,
406
+ plan: entry.plan || null,
407
+ tasks: Array.isArray(entry.tasks) ? entry.tasks : [],
408
+ lastActivityAt: entry.lastActivityAt || null,
409
+ lastSyncedAt: entry.lastSyncedAt || null,
410
+ syncSource: entry.syncSource || null,
253
411
  })),
254
412
  }
255
413
  : {
@@ -270,8 +428,20 @@ export function buildWorkspaceSyncPreview({ source, parsed, targetIdea, workspac
270
428
  resolveResumeMetadata(entry).resumeTool === "codex" ? resolveResumeMetadata(entry).resumeSessionId : null,
271
429
  claudeSessionId:
272
430
  resolveResumeMetadata(entry).resumeTool === "claude" ? resolveResumeMetadata(entry).resumeSessionId : null,
431
+ lastActivityAt: entry.lastActivityAt || null,
432
+ lastSyncedAt: entry.lastSyncedAt || null,
433
+ syncSource: entry.syncSource || null,
273
434
  })),
274
435
  };
436
+ const syncTimestamp = new Date().toISOString();
437
+ const timestampedManifest = {
438
+ ...manifest,
439
+ tabs: manifest.tabs.map((tab) => ({
440
+ ...tab,
441
+ lastSyncedAt: tab.lastSyncedAt || syncTimestamp,
442
+ })),
443
+ };
444
+ const enrichedManifest = enrichWorkspaceManifestWithProjectContext(timestampedManifest);
275
445
 
276
446
  const narrativeSourceNotes =
277
447
  source.sourceType === "workspace-file" ? targetIdea.notes || "" : source.notes || targetIdea.notes || "";
@@ -280,7 +450,7 @@ export function buildWorkspaceSyncPreview({ source, parsed, targetIdea, workspac
280
450
  });
281
451
  const nextNotes = composeWorkspaceNotes({
282
452
  narrativeNotes,
283
- manifest,
453
+ manifest: enrichedManifest,
284
454
  });
285
455
 
286
456
  return {
@@ -289,11 +459,11 @@ export function buildWorkspaceSyncPreview({ source, parsed, targetIdea, workspac
289
459
  sourceType: source.sourceType,
290
460
  sourceLabel: source.sourceLabel,
291
461
  parseMode: parsed.parseMode,
292
- workspaceId: manifest.workspaceId,
293
- manifest,
462
+ workspaceId: enrichedManifest.workspaceId,
463
+ manifest: enrichedManifest,
294
464
  nextNotes,
295
465
  nextNotesLength: nextNotes.length,
296
- tabs: manifest.tabs,
466
+ tabs: enrichedManifest.tabs,
297
467
  skipped: parsed.skipped,
298
468
  };
299
469
  }
@@ -308,10 +478,19 @@ function summarizeSyncPreview(preview) {
308
478
  ` tabs: ${preview.tabs.length}`,
309
479
  ` stored notes: ${preview.nextNotesLength} chars`,
310
480
  ];
481
+ if (preview.compactedIdeaNotes) {
482
+ lines.push(` idea notes: compact compatibility mirror; hosted workspace state carries full details`);
483
+ }
484
+ if (preview.hostedWorkspaceId) {
485
+ lines.push(` hosted push target: ${preview.hostedWorkspaceId}`);
486
+ }
311
487
 
312
488
  if (preview.skipped.length > 0) {
313
489
  lines.push(` skipped non-path lines: ${preview.skipped.length}`);
314
490
  }
491
+ if (preview.inventory) {
492
+ lines.push(` local inventory: ${preview.inventory.rowCount} rows / ${preview.inventory.projectCount} projects`);
493
+ }
315
494
  return lines.join("\n");
316
495
  }
317
496
 
@@ -330,10 +509,12 @@ export async function runWorkspaceSync(argv = process.argv.slice(2)) {
330
509
  if (parsed.entries.length === 0) {
331
510
  throw new Error("No launchable workspace lines were found in the provided source.");
332
511
  }
512
+ const targetSource = await resolveWorkspaceSyncTargetSource(source, options);
333
513
  const resolvedWorkspaceTitle = options.title
334
514
  ? validateWorkspaceTitle(options.title)
335
- : normalizeOptionalString(parsed.manifest?.title) || (await promptForWorkspaceTitle());
336
- const targetSource = await resolveWorkspaceSyncTargetSource(source, options);
515
+ : normalizeOptionalString(targetSource.hostedWorkspace?.title) ||
516
+ normalizeOptionalString(parsed.manifest?.title) ||
517
+ (await promptForWorkspaceTitle());
337
518
  const targetIdeaId = resolveWorkspaceSyncTargetIdeaId(targetSource);
338
519
  if (!targetIdeaId) {
339
520
  throw new Error(
@@ -356,23 +537,69 @@ export async function runWorkspaceSync(argv = process.argv.slice(2)) {
356
537
  targetIdea: targetPayload.idea,
357
538
  workspaceTitle: resolvedWorkspaceTitle,
358
539
  });
540
+ const reconciled = await mergeLocalProjectInventoryIntoManifest(preview.manifest, {
541
+ ...options,
542
+ workspaceSelector: options.ideaId,
543
+ });
544
+ const hostedWorkspaceId = normalizeOptionalString(targetSource.hostedWorkspace?.workspace_id ?? targetSource.hostedWorkspace?.id);
545
+ const narrativeNotes = extractWorkspaceNarrativeNotes(preview.nextNotes, {
546
+ stripLegacyWorkspaceLines: true,
547
+ });
548
+ const storedIdeaNotes = buildStoredIdeaNotes({
549
+ narrativeNotes,
550
+ manifest: reconciled.manifest,
551
+ hostedWorkspaceId,
552
+ });
553
+ const finalPreview = {
554
+ ...preview,
555
+ manifest: reconciled.manifest,
556
+ tabs: reconciled.manifest.tabs,
557
+ nextNotes: storedIdeaNotes.notes,
558
+ inventory: reconciled.inventory,
559
+ hostedWorkspaceId,
560
+ compactedIdeaNotes: storedIdeaNotes.compacted,
561
+ omittedPlanTaskDetailsFromIdeaNotes: storedIdeaNotes.omittedPlanTaskDetails,
562
+ };
563
+ finalPreview.nextNotesLength = finalPreview.nextNotes.length;
359
564
 
360
565
  if (options.json) {
361
- process.stdout.write(`${JSON.stringify(preview, null, 2)}\n`);
566
+ process.stdout.write(`${JSON.stringify(finalPreview, null, 2)}\n`);
362
567
  return 0;
363
568
  }
364
569
 
365
570
  if (options.dryRun) {
366
- process.stdout.write(`${summarizeSyncPreview(preview)}\n`);
571
+ process.stdout.write(`${summarizeSyncPreview(finalPreview)}\n`);
367
572
  return 0;
368
573
  }
369
574
 
370
- const updated = await updateIdeaPayload(targetIdeaId, { notes: preview.nextNotes }, options);
371
- const managedCache = await cacheManagedWorkspaceManifest(preview.manifest);
575
+ let pushedWorkspace = null;
576
+ if (hostedWorkspaceId) {
577
+ const state = buildHostedWorkspaceState(finalPreview.manifest, {
578
+ previousWorkspace: targetSource.hostedWorkspace,
579
+ updatedAt: new Date().toISOString(),
580
+ localInventory: finalPreview.inventory,
581
+ });
582
+ const pushed = await pushHostedWorkspaceState(hostedWorkspaceId, state, options);
583
+ pushedWorkspace = pushed?.workspace || null;
584
+ }
585
+ const updated = hostedWorkspaceId
586
+ ? { title: finalPreview.targetIdeaTitle }
587
+ : await updateIdeaPayload(targetIdeaId, { notes: finalPreview.nextNotes }, options);
588
+ const managedCache = await cacheManagedWorkspaceManifest(finalPreview.manifest);
372
589
  process.stdout.write(
373
- `Synced workspace '${preview.workspaceId}' to idea '${updated.title || preview.targetIdeaTitle}'.\n`,
590
+ `Synced workspace '${finalPreview.workspaceId}' to idea '${updated.title || finalPreview.targetIdeaTitle}'.\n`,
374
591
  );
375
- process.stdout.write(`Tabs: ${preview.tabs.length}. Stored notes: ${preview.nextNotesLength} chars.\n`);
592
+ if (pushedWorkspace) {
593
+ process.stdout.write(`Pushed hosted workspace state to '${hostedWorkspaceId}'.\n`);
594
+ process.stdout.write("Skipped idea-note mirror update because hosted workspace state is authoritative.\n");
595
+ }
596
+ if (hostedWorkspaceId) {
597
+ process.stdout.write(
598
+ `Tabs: ${finalPreview.tabs.length}. Compatibility notes would be ${finalPreview.nextNotesLength} chars.\n`,
599
+ );
600
+ } else {
601
+ process.stdout.write(`Tabs: ${finalPreview.tabs.length}. Stored notes: ${finalPreview.nextNotesLength} chars.\n`);
602
+ }
376
603
  process.stdout.write(`Updated local workspace cache at ${managedCache.manifestPath}.\n`);
377
604
  return 0;
378
605
  }
@@ -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({