open-research-protocol 0.4.34 → 0.4.36

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,12 +10,22 @@ import {
10
10
  resolveResumeMetadata,
11
11
  WORKSPACE_SCHEMA_VERSION,
12
12
  } from "./core-plan.js";
13
- import { enrichWorkspaceManifestWithProjectContext } from "./hosted-state.js";
14
- 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";
15
24
  import { cacheManagedWorkspaceManifest } from "./registry.js";
16
25
 
17
26
  const STRUCTURED_WORKSPACE_BLOCK_PATTERN = /```orp-workspace\s*[\s\S]*?```/i;
18
27
  const WORKSPACE_TITLE_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
28
+ const MAX_HOSTED_IDEA_NOTES_LENGTH = 9500;
19
29
 
20
30
  function printSyncHelp() {
21
31
  console.log(`ORP workspace sync
@@ -129,11 +139,38 @@ async function resolveWorkspaceSyncTargetSource(source, options) {
129
139
  if (!options.workspaceFile && !options.notesFile && (source.sourceType === "hosted-idea" || source.sourceType === "hosted-workspace")) {
130
140
  return source;
131
141
  }
132
- return loadWorkspaceSource({
142
+ const targetSource = await loadWorkspaceSource({
133
143
  ideaId: options.ideaId,
134
144
  baseUrl: options.baseUrl,
135
145
  orpCommand: options.orpCommand,
136
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
+ };
137
174
  }
138
175
 
139
176
  export function validateWorkspaceTitle(value, label = "--title") {
@@ -203,6 +240,11 @@ function serializeWorkspaceManifest(manifest) {
203
240
  linkedFeatureId: normalizeOptionalString(entry.linkedFeatureId ?? entry.linked_feature_id) ?? undefined,
204
241
  plan: entry.plan && typeof entry.plan === "object" && !Array.isArray(entry.plan) ? entry.plan : undefined,
205
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,
206
248
  codexSessionId:
207
249
  normalizeOptionalString(entry.resumeTool) === "codex"
208
250
  ? normalizeOptionalString(entry.codexSessionId ?? entry.resumeSessionId ?? entry.sessionId) ?? undefined
@@ -237,6 +279,110 @@ function composeWorkspaceNotes({ narrativeNotes, manifest }) {
237
279
  ]);
238
280
  }
239
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
+
240
386
  export function buildWorkspaceSyncPreview({ source, parsed, targetIdea, workspaceTitle = null }) {
241
387
  const manifest = parsed.manifest
242
388
  ? {
@@ -259,6 +405,9 @@ export function buildWorkspaceSyncPreview({ source, parsed, targetIdea, workspac
259
405
  linkedFeatureId: entry.linkedFeatureId || null,
260
406
  plan: entry.plan || null,
261
407
  tasks: Array.isArray(entry.tasks) ? entry.tasks : [],
408
+ lastActivityAt: entry.lastActivityAt || null,
409
+ lastSyncedAt: entry.lastSyncedAt || null,
410
+ syncSource: entry.syncSource || null,
262
411
  })),
263
412
  }
264
413
  : {
@@ -279,9 +428,20 @@ export function buildWorkspaceSyncPreview({ source, parsed, targetIdea, workspac
279
428
  resolveResumeMetadata(entry).resumeTool === "codex" ? resolveResumeMetadata(entry).resumeSessionId : null,
280
429
  claudeSessionId:
281
430
  resolveResumeMetadata(entry).resumeTool === "claude" ? resolveResumeMetadata(entry).resumeSessionId : null,
431
+ lastActivityAt: entry.lastActivityAt || null,
432
+ lastSyncedAt: entry.lastSyncedAt || null,
433
+ syncSource: entry.syncSource || null,
282
434
  })),
283
435
  };
284
- const enrichedManifest = enrichWorkspaceManifestWithProjectContext(manifest);
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);
285
445
 
286
446
  const narrativeSourceNotes =
287
447
  source.sourceType === "workspace-file" ? targetIdea.notes || "" : source.notes || targetIdea.notes || "";
@@ -316,12 +476,25 @@ function summarizeSyncPreview(preview) {
316
476
  ` parse mode: ${preview.parseMode}`,
317
477
  ` workspace id: ${preview.workspaceId}`,
318
478
  ` tabs: ${preview.tabs.length}`,
319
- ` stored notes: ${preview.nextNotesLength} chars`,
320
479
  ];
480
+ if (preview.hostedWorkspaceId) {
481
+ lines.push(` compatibility notes: ${preview.nextNotesLength} chars (not written when hosted push succeeds)`);
482
+ } else {
483
+ lines.push(` stored notes: ${preview.nextNotesLength} chars`);
484
+ }
485
+ if (preview.compactedIdeaNotes && !preview.hostedWorkspaceId) {
486
+ lines.push(` idea notes: compact compatibility mirror; hosted workspace state carries full details`);
487
+ }
488
+ if (preview.hostedWorkspaceId) {
489
+ lines.push(` hosted push target: ${preview.hostedWorkspaceId}`);
490
+ }
321
491
 
322
492
  if (preview.skipped.length > 0) {
323
493
  lines.push(` skipped non-path lines: ${preview.skipped.length}`);
324
494
  }
495
+ if (preview.inventory) {
496
+ lines.push(` local inventory: ${preview.inventory.rowCount} rows / ${preview.inventory.projectCount} projects`);
497
+ }
325
498
  return lines.join("\n");
326
499
  }
327
500
 
@@ -340,10 +513,12 @@ export async function runWorkspaceSync(argv = process.argv.slice(2)) {
340
513
  if (parsed.entries.length === 0) {
341
514
  throw new Error("No launchable workspace lines were found in the provided source.");
342
515
  }
516
+ const targetSource = await resolveWorkspaceSyncTargetSource(source, options);
343
517
  const resolvedWorkspaceTitle = options.title
344
518
  ? validateWorkspaceTitle(options.title)
345
- : normalizeOptionalString(parsed.manifest?.title) || (await promptForWorkspaceTitle());
346
- const targetSource = await resolveWorkspaceSyncTargetSource(source, options);
519
+ : normalizeOptionalString(targetSource.hostedWorkspace?.title) ||
520
+ normalizeOptionalString(parsed.manifest?.title) ||
521
+ (await promptForWorkspaceTitle());
347
522
  const targetIdeaId = resolveWorkspaceSyncTargetIdeaId(targetSource);
348
523
  if (!targetIdeaId) {
349
524
  throw new Error(
@@ -366,23 +541,69 @@ export async function runWorkspaceSync(argv = process.argv.slice(2)) {
366
541
  targetIdea: targetPayload.idea,
367
542
  workspaceTitle: resolvedWorkspaceTitle,
368
543
  });
544
+ const reconciled = await mergeLocalProjectInventoryIntoManifest(preview.manifest, {
545
+ ...options,
546
+ workspaceSelector: options.ideaId,
547
+ });
548
+ const hostedWorkspaceId = normalizeOptionalString(targetSource.hostedWorkspace?.workspace_id ?? targetSource.hostedWorkspace?.id);
549
+ const narrativeNotes = extractWorkspaceNarrativeNotes(preview.nextNotes, {
550
+ stripLegacyWorkspaceLines: true,
551
+ });
552
+ const storedIdeaNotes = buildStoredIdeaNotes({
553
+ narrativeNotes,
554
+ manifest: reconciled.manifest,
555
+ hostedWorkspaceId,
556
+ });
557
+ const finalPreview = {
558
+ ...preview,
559
+ manifest: reconciled.manifest,
560
+ tabs: reconciled.manifest.tabs,
561
+ nextNotes: storedIdeaNotes.notes,
562
+ inventory: reconciled.inventory,
563
+ hostedWorkspaceId,
564
+ compactedIdeaNotes: storedIdeaNotes.compacted,
565
+ omittedPlanTaskDetailsFromIdeaNotes: storedIdeaNotes.omittedPlanTaskDetails,
566
+ };
567
+ finalPreview.nextNotesLength = finalPreview.nextNotes.length;
369
568
 
370
569
  if (options.json) {
371
- process.stdout.write(`${JSON.stringify(preview, null, 2)}\n`);
570
+ process.stdout.write(`${JSON.stringify(finalPreview, null, 2)}\n`);
372
571
  return 0;
373
572
  }
374
573
 
375
574
  if (options.dryRun) {
376
- process.stdout.write(`${summarizeSyncPreview(preview)}\n`);
575
+ process.stdout.write(`${summarizeSyncPreview(finalPreview)}\n`);
377
576
  return 0;
378
577
  }
379
578
 
380
- const updated = await updateIdeaPayload(targetIdeaId, { notes: preview.nextNotes }, options);
381
- const managedCache = await cacheManagedWorkspaceManifest(preview.manifest);
579
+ let pushedWorkspace = null;
580
+ if (hostedWorkspaceId) {
581
+ const state = buildHostedWorkspaceState(finalPreview.manifest, {
582
+ previousWorkspace: targetSource.hostedWorkspace,
583
+ updatedAt: new Date().toISOString(),
584
+ localInventory: finalPreview.inventory,
585
+ });
586
+ const pushed = await pushHostedWorkspaceState(hostedWorkspaceId, state, options);
587
+ pushedWorkspace = pushed?.workspace || null;
588
+ }
589
+ const updated = hostedWorkspaceId
590
+ ? { title: finalPreview.targetIdeaTitle }
591
+ : await updateIdeaPayload(targetIdeaId, { notes: finalPreview.nextNotes }, options);
592
+ const managedCache = await cacheManagedWorkspaceManifest(finalPreview.manifest);
382
593
  process.stdout.write(
383
- `Synced workspace '${preview.workspaceId}' to idea '${updated.title || preview.targetIdeaTitle}'.\n`,
594
+ `Synced workspace '${finalPreview.workspaceId}' to idea '${updated.title || finalPreview.targetIdeaTitle}'.\n`,
384
595
  );
385
- process.stdout.write(`Tabs: ${preview.tabs.length}. Stored notes: ${preview.nextNotesLength} chars.\n`);
596
+ if (pushedWorkspace) {
597
+ process.stdout.write(`Pushed hosted workspace state to '${hostedWorkspaceId}'.\n`);
598
+ process.stdout.write("Skipped idea-note mirror update because hosted workspace state is authoritative.\n");
599
+ }
600
+ if (hostedWorkspaceId) {
601
+ process.stdout.write(
602
+ `Tabs: ${finalPreview.tabs.length}. Compatibility notes would be ${finalPreview.nextNotesLength} chars.\n`,
603
+ );
604
+ } else {
605
+ process.stdout.write(`Tabs: ${finalPreview.tabs.length}. Stored notes: ${finalPreview.nextNotesLength} chars.\n`);
606
+ }
386
607
  process.stdout.write(`Updated local workspace cache at ${managedCache.manifestPath}.\n`);
387
608
  return 0;
388
609
  }
@@ -132,3 +132,56 @@ test("buildHostedWorkspaceState compiles local ORP frontier plan and tasks", asy
132
132
  assert.equal(state.projects[0].linked_idea_id, "idea-123");
133
133
  assert.equal(state.projects[0].linked_feature_id, "feature-regime-metadata-quality");
134
134
  });
135
+
136
+ test("buildHostedWorkspaceState preserves manifest plan tasks and activity timestamps", () => {
137
+ const state = buildHostedWorkspaceState(
138
+ {
139
+ version: "1",
140
+ workspaceId: "main-cody-1",
141
+ title: "main-cody-1",
142
+ tabs: [
143
+ {
144
+ title: "tailnet-app",
145
+ path: "/Volumes/Code_2TB/code/tailnet-app",
146
+ resumeTool: "codex",
147
+ resumeSessionId: "019dcd50-111d-7451-bd01-dbc21336c679",
148
+ linkedIdeaId: "idea-tailnet",
149
+ linkedFeatureId: "feature-tailnet",
150
+ plan: {
151
+ summary: "Ship Tailnet App workspace sync",
152
+ body: "Keep the hosted workspace aligned with local project inventory.",
153
+ },
154
+ tasks: [
155
+ {
156
+ id: "sync-contract",
157
+ title: "Define sync contract",
158
+ status: "in_progress",
159
+ },
160
+ ],
161
+ lastActivityAt: "2026-04-30T02:59:15.000Z",
162
+ lastSyncedAt: "2026-04-30T12:00:00.000Z",
163
+ syncSource: "orp-project-startup",
164
+ },
165
+ ],
166
+ },
167
+ {
168
+ updatedAt: "2026-04-30T12:30:00.000Z",
169
+ localInventory: {
170
+ contract: {
171
+ source_of_truth: "orp-workspace-ledger",
172
+ },
173
+ },
174
+ },
175
+ );
176
+
177
+ assert.equal(state.tabs[0].plan.summary, "Ship Tailnet App workspace sync");
178
+ assert.equal(state.tabs[0].tasks[0].id, "sync-contract");
179
+ assert.equal(state.tabs[0].linked_idea_id, "idea-tailnet");
180
+ assert.equal(state.tabs[0].linked_feature_id, "feature-tailnet");
181
+ assert.equal(state.tabs[0].last_activity_at_utc, "2026-04-30T02:59:15.000Z");
182
+ assert.equal(state.tabs[0].last_synced_at_utc, "2026-04-30T12:00:00.000Z");
183
+ assert.equal(state.tabs[0].sync_source, "orp-project-startup");
184
+ assert.equal(state.projects[0].last_activity_at_utc, "2026-04-30T02:59:15.000Z");
185
+ assert.equal(state.projects[0].sessions[0].last_synced_at_utc, "2026-04-30T12:00:00.000Z");
186
+ assert.equal(state.source_contract.source_of_truth, "orp-workspace-ledger");
187
+ });
@@ -0,0 +1,126 @@
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 { mergeLocalProjectInventoryIntoManifest } from "../src/index.js";
8
+
9
+ async function makeTempDir() {
10
+ return fs.mkdtemp(path.join(os.tmpdir(), "orp-local-inventory-"));
11
+ }
12
+
13
+ async function writeJson(filePath, payload) {
14
+ await fs.mkdir(path.dirname(filePath), { recursive: true });
15
+ await fs.writeFile(filePath, `${JSON.stringify(payload, null, 2)}\n`, "utf8");
16
+ }
17
+
18
+ test("mergeLocalProjectInventoryIntoManifest reconciles ORP startup, Clawdad, and known Codex sessions", async () => {
19
+ const root = await makeTempDir();
20
+ const codexHome = path.join(root, "codex-home");
21
+ const clawdadStatePath = path.join(root, "clawdad", "state.json");
22
+ const existingPath = path.join(root, "existing");
23
+ const tailnetPath = path.join(root, "tailnet-app");
24
+ const financialPath = path.join(root, "financial-stack");
25
+
26
+ await fs.mkdir(existingPath, { recursive: true });
27
+ await writeJson(path.join(tailnetPath, "orp", "state.json"), {
28
+ startup: {
29
+ updated_at_utc: "2026-04-30T02:59:15Z",
30
+ workspace: {
31
+ requested: true,
32
+ workspace: "main",
33
+ path: tailnetPath,
34
+ result: {
35
+ manifest: {
36
+ version: "1",
37
+ workspaceId: "main",
38
+ title: "main",
39
+ tabs: [
40
+ {
41
+ title: "financial-stack",
42
+ path: financialPath,
43
+ resumeTool: "codex",
44
+ resumeSessionId: "019dc348-ce52-7f52-8ac8-0200a9bf946a",
45
+ },
46
+ ],
47
+ },
48
+ tab: {
49
+ title: "Tailnet App",
50
+ path: tailnetPath,
51
+ bootstrapCommand: "npm test",
52
+ resumeTool: "codex",
53
+ resumeSessionId: "019dcd50-111d-7451-bd01-dbc21336c679",
54
+ },
55
+ },
56
+ },
57
+ },
58
+ });
59
+ await writeJson(clawdadStatePath, {
60
+ projects: {
61
+ [financialPath]: {
62
+ status: "completed",
63
+ last_dispatch: "2026-04-25T20:47:50Z",
64
+ last_response: "2026-04-25T20:49:37Z",
65
+ registered_at: "2026-04-25T20:11:21.625Z",
66
+ sessions: {
67
+ "019dc348-ce52-7f52-8ac8-0200a9bf946a": {
68
+ slug: "financial-stack",
69
+ provider: "codex",
70
+ quarantined: "true",
71
+ },
72
+ "019dc644-d31d-78e1-a3ed-8575aead1c96": {
73
+ slug: "financial-stack",
74
+ provider: "codex",
75
+ tracked_at: "2026-04-25T20:11:21.625Z",
76
+ },
77
+ },
78
+ quarantined_sessions: {
79
+ "019dc348-ce52-7f52-8ac8-0200a9bf946a": true,
80
+ },
81
+ },
82
+ },
83
+ });
84
+ const codexSessionPath = path.join(codexHome, "sessions", "2026", "04", "30", "rollout-existing.jsonl");
85
+ await fs.mkdir(path.dirname(codexSessionPath), { recursive: true });
86
+ await fs.writeFile(codexSessionPath, `${JSON.stringify({
87
+ timestamp: "2026-04-30T12:00:00.000Z",
88
+ type: "session_meta",
89
+ payload: {
90
+ id: "019df000-1111-7222-8333-444455556666",
91
+ cwd: existingPath,
92
+ timestamp: "2026-04-30T12:00:00.000Z",
93
+ },
94
+ })}\n`, "utf8");
95
+
96
+ const merged = await mergeLocalProjectInventoryIntoManifest(
97
+ {
98
+ version: "1",
99
+ workspaceId: "main",
100
+ title: "main",
101
+ tabs: [
102
+ {
103
+ title: "existing",
104
+ path: existingPath,
105
+ resumeTool: "codex",
106
+ resumeSessionId: "019d0000-1111-7222-8333-444455556666",
107
+ },
108
+ ],
109
+ },
110
+ {
111
+ localProjectRoots: [root],
112
+ clawdadStatePath,
113
+ codexHome,
114
+ workspaceSelector: "main",
115
+ codexScanDays: 30,
116
+ },
117
+ );
118
+
119
+ const byPath = new Map(merged.manifest.tabs.map((tab) => [tab.path, tab]));
120
+ assert.equal(byPath.get(existingPath)?.resumeSessionId, "019df000-1111-7222-8333-444455556666");
121
+ assert.equal(byPath.get(tailnetPath)?.bootstrapCommand, "npm test");
122
+ assert.equal(byPath.get(tailnetPath)?.resumeSessionId, "019dcd50-111d-7451-bd01-dbc21336c679");
123
+ assert.equal(byPath.get(financialPath)?.resumeSessionId, "019dc644-d31d-78e1-a3ed-8575aead1c96");
124
+ assert.equal([...byPath.values()].some((tab) => tab.resumeSessionId === "019dc348-ce52-7f52-8ac8-0200a9bf946a"), false);
125
+ assert.equal(merged.inventory.projectCount, 3);
126
+ });
@@ -6,6 +6,7 @@ import path from "node:path";
6
6
 
7
7
  import {
8
8
  chooseImplicitMainCandidate,
9
+ findHostedWorkspaceByWorkspaceId,
9
10
  loadWorkspaceSource,
10
11
  resolveWorkspaceSelectorFromCollections,
11
12
  resolveWorkspaceWatchTargets,
@@ -44,6 +45,24 @@ test("resolveWorkspaceSelectorFromCollections matches hosted ideas by saved work
44
45
  assert.equal(byTitleSlug?.title, "Main Cody 1");
45
46
  });
46
47
 
48
+ test("findHostedWorkspaceByWorkspaceId matches hosted workspace records by durable id", () => {
49
+ const workspace = findHostedWorkspaceByWorkspaceId(
50
+ [
51
+ {
52
+ workspace_id: "focused-items",
53
+ title: "focused-items",
54
+ },
55
+ {
56
+ workspace_id: "captured-iterm-window-20260401t032225z",
57
+ title: "main-workspace",
58
+ },
59
+ ],
60
+ "captured-iterm-window-20260401t032225z",
61
+ );
62
+
63
+ assert.equal(workspace?.title, "main-workspace");
64
+ });
65
+
47
66
  test("resolveWorkspaceSelectorFromCollections can match local tracked workspaces by title", () => {
48
67
  const resolved = resolveWorkspaceSelectorFromCollections("ORP Main", {
49
68
  localWorkspaces: [