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.
@@ -265,6 +265,14 @@ 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 : [];
270
+ const lastActivityAt = normalizeOptionalString(
271
+ rawTab.lastActivityAt ?? rawTab.last_activity_at_utc ?? rawTab.lastActivityAtUtc,
272
+ );
273
+ const lastSyncedAt = normalizeOptionalString(
274
+ rawTab.lastSyncedAt ?? rawTab.last_synced_at_utc ?? rawTab.lastSyncedAtUtc,
275
+ );
268
276
 
269
277
  return {
270
278
  lineNumber: index + 1,
@@ -279,6 +287,13 @@ function normalizeStructuredTab(rawTab, index) {
279
287
  remoteUrl: normalizeOptionalUrl(rawTab.remoteUrl, `workspace tab ${index + 1} remoteUrl`),
280
288
  remoteBranch: normalizeOptionalString(rawTab.remoteBranch),
281
289
  bootstrapCommand: normalizeOptionalCommand(rawTab.bootstrapCommand),
290
+ linkedIdeaId: normalizeOptionalString(rawTab.linkedIdeaId ?? rawTab.linked_idea_id),
291
+ linkedFeatureId: normalizeOptionalString(rawTab.linkedFeatureId ?? rawTab.linked_feature_id),
292
+ plan,
293
+ tasks,
294
+ lastActivityAt,
295
+ lastSyncedAt,
296
+ syncSource: normalizeOptionalString(rawTab.syncSource ?? rawTab.sync_source),
282
297
  };
283
298
  }
284
299
 
@@ -307,6 +322,29 @@ function normalizeStructuredProject(rawProject, projectIndex) {
307
322
  codexSessionId: rawSession.codexSessionId,
308
323
  claudeSessionId: rawSession.claudeSessionId,
309
324
  tmuxSessionName: rawSession.tmuxSessionName,
325
+ linkedIdeaId: rawSession.linkedIdeaId ?? rawSession.linked_idea_id ?? rawProject.linkedIdeaId ?? rawProject.linked_idea_id,
326
+ linkedFeatureId:
327
+ rawSession.linkedFeatureId ??
328
+ rawSession.linked_feature_id ??
329
+ rawProject.linkedFeatureId ??
330
+ rawProject.linked_feature_id,
331
+ plan: rawSession.plan ?? rawProject.plan,
332
+ tasks: rawSession.tasks ?? rawProject.tasks,
333
+ lastActivityAt:
334
+ rawSession.lastActivityAt ??
335
+ rawSession.last_activity_at_utc ??
336
+ rawSession.lastActivityAtUtc ??
337
+ rawProject.lastActivityAt ??
338
+ rawProject.last_activity_at_utc ??
339
+ rawProject.lastActivityAtUtc,
340
+ lastSyncedAt:
341
+ rawSession.lastSyncedAt ??
342
+ rawSession.last_synced_at_utc ??
343
+ rawSession.lastSyncedAtUtc ??
344
+ rawProject.lastSyncedAt ??
345
+ rawProject.last_synced_at_utc ??
346
+ rawProject.lastSyncedAtUtc,
347
+ syncSource: rawSession.syncSource ?? rawSession.sync_source ?? rawProject.syncSource ?? rawProject.sync_source,
310
348
  },
311
349
  sessionIndex,
312
350
  );
@@ -381,6 +419,12 @@ export function buildWorkspaceProjectGroups(entries = []) {
381
419
  remoteUrl: normalizeOptionalUrl(entry.remoteUrl, "workspace project remoteUrl"),
382
420
  remoteBranch: normalizeOptionalString(entry.remoteBranch),
383
421
  bootstrapCommand: normalizeOptionalCommand(entry.bootstrapCommand),
422
+ linkedIdeaId: normalizeOptionalString(entry.linkedIdeaId),
423
+ linkedFeatureId: normalizeOptionalString(entry.linkedFeatureId),
424
+ plan: entry.plan && typeof entry.plan === "object" && !Array.isArray(entry.plan) ? entry.plan : null,
425
+ tasks: Array.isArray(entry.tasks) && entry.tasks.length > 0 ? entry.tasks : [],
426
+ lastActivityAt: normalizeOptionalString(entry.lastActivityAt ?? entry.last_activity_at_utc),
427
+ lastSyncedAt: normalizeOptionalString(entry.lastSyncedAt ?? entry.last_synced_at_utc),
384
428
  sessions: [],
385
429
  });
386
430
  }
@@ -389,6 +433,16 @@ export function buildWorkspaceProjectGroups(entries = []) {
389
433
  project.remoteUrl = project.remoteUrl || normalizeOptionalUrl(entry.remoteUrl, "workspace project remoteUrl");
390
434
  project.remoteBranch = project.remoteBranch || normalizeOptionalString(entry.remoteBranch);
391
435
  project.bootstrapCommand = project.bootstrapCommand || normalizeOptionalCommand(entry.bootstrapCommand);
436
+ project.linkedIdeaId = project.linkedIdeaId || normalizeOptionalString(entry.linkedIdeaId);
437
+ project.linkedFeatureId = project.linkedFeatureId || normalizeOptionalString(entry.linkedFeatureId);
438
+ project.plan =
439
+ project.plan ||
440
+ (entry.plan && typeof entry.plan === "object" && !Array.isArray(entry.plan) ? entry.plan : null);
441
+ project.lastActivityAt = project.lastActivityAt || normalizeOptionalString(entry.lastActivityAt ?? entry.last_activity_at_utc);
442
+ project.lastSyncedAt = project.lastSyncedAt || normalizeOptionalString(entry.lastSyncedAt ?? entry.last_synced_at_utc);
443
+ if ((!Array.isArray(project.tasks) || project.tasks.length === 0) && Array.isArray(entry.tasks) && entry.tasks.length > 0) {
444
+ project.tasks = entry.tasks;
445
+ }
392
446
 
393
447
  const resume = resolveResumeMetadata(entry);
394
448
  project.sessions.push(
@@ -400,6 +454,8 @@ export function buildWorkspaceProjectGroups(entries = []) {
400
454
  resumeSessionId: resume.resumeSessionId || undefined,
401
455
  codexSessionId: resume.resumeTool === "codex" ? resume.resumeSessionId || undefined : undefined,
402
456
  claudeSessionId: resume.resumeTool === "claude" ? resume.resumeSessionId || undefined : undefined,
457
+ lastActivityAt: normalizeOptionalString(entry.lastActivityAt ?? entry.last_activity_at_utc) || undefined,
458
+ lastSyncedAt: normalizeOptionalString(entry.lastSyncedAt ?? entry.last_synced_at_utc) || undefined,
403
459
  }).filter(([, value]) => value !== undefined),
404
460
  ),
405
461
  );
@@ -413,6 +469,12 @@ export function buildWorkspaceProjectGroups(entries = []) {
413
469
  remoteUrl: project.remoteUrl || undefined,
414
470
  remoteBranch: project.remoteBranch || undefined,
415
471
  bootstrapCommand: project.bootstrapCommand || undefined,
472
+ linkedIdeaId: project.linkedIdeaId || undefined,
473
+ linkedFeatureId: project.linkedFeatureId || undefined,
474
+ plan: project.plan || undefined,
475
+ tasks: Array.isArray(project.tasks) && project.tasks.length > 0 ? project.tasks : undefined,
476
+ lastActivityAt: project.lastActivityAt || undefined,
477
+ lastSyncedAt: project.lastSyncedAt || undefined,
416
478
  sessionCount: project.sessions.length,
417
479
  sessions: project.sessions,
418
480
  }).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
 
@@ -71,8 +72,11 @@ function normalizePreviousHostedTabs(workspace) {
71
72
  focusSummary: normalizeOptionalString(tab.focus_summary ?? tab.focusSummary),
72
73
  trajectorySummary: normalizeOptionalString(tab.trajectory_summary ?? tab.trajectorySummary),
73
74
  lastActivityAt: normalizeOptionalString(tab.last_activity_at_utc ?? tab.lastActivityAtUtc),
75
+ lastSyncedAt: normalizeOptionalString(tab.last_synced_at_utc ?? tab.lastSyncedAtUtc),
74
76
  linkedIdeaId: normalizeOptionalString(tab.linked_idea_id ?? tab.linkedIdeaId),
75
77
  linkedFeatureId: normalizeOptionalString(tab.linked_feature_id ?? tab.linkedFeatureId),
78
+ plan: tab.plan && typeof tab.plan === "object" && !Array.isArray(tab.plan) ? tab.plan : null,
79
+ tasks: Array.isArray(tab.tasks) ? tab.tasks : [],
76
80
  used: false,
77
81
  };
78
82
  });
@@ -111,6 +115,12 @@ function buildHostedProjectGroups(tabs) {
111
115
  remote_url: normalizeOptionalString(tab.remote_url),
112
116
  remote_branch: normalizeOptionalString(tab.remote_branch),
113
117
  bootstrap_command: normalizeOptionalString(tab.bootstrap_command),
118
+ linked_idea_id: normalizeOptionalString(tab.linked_idea_id),
119
+ linked_feature_id: normalizeOptionalString(tab.linked_feature_id),
120
+ last_activity_at_utc: normalizeOptionalString(tab.last_activity_at_utc),
121
+ last_synced_at_utc: normalizeOptionalString(tab.last_synced_at_utc),
122
+ plan: tab.plan && typeof tab.plan === "object" && !Array.isArray(tab.plan) ? tab.plan : undefined,
123
+ tasks: Array.isArray(tab.tasks) ? tab.tasks : undefined,
114
124
  sessions: [],
115
125
  });
116
126
  }
@@ -118,6 +128,14 @@ function buildHostedProjectGroups(tabs) {
118
128
  project.remote_url = project.remote_url || normalizeOptionalString(tab.remote_url);
119
129
  project.remote_branch = project.remote_branch || normalizeOptionalString(tab.remote_branch);
120
130
  project.bootstrap_command = project.bootstrap_command || normalizeOptionalString(tab.bootstrap_command);
131
+ project.linked_idea_id = project.linked_idea_id || normalizeOptionalString(tab.linked_idea_id);
132
+ project.linked_feature_id = project.linked_feature_id || normalizeOptionalString(tab.linked_feature_id);
133
+ project.last_activity_at_utc = project.last_activity_at_utc || normalizeOptionalString(tab.last_activity_at_utc);
134
+ project.last_synced_at_utc = project.last_synced_at_utc || normalizeOptionalString(tab.last_synced_at_utc);
135
+ project.plan = project.plan || (tab.plan && typeof tab.plan === "object" && !Array.isArray(tab.plan) ? tab.plan : undefined);
136
+ if ((!Array.isArray(project.tasks) || project.tasks.length === 0) && Array.isArray(tab.tasks) && tab.tasks.length > 0) {
137
+ project.tasks = tab.tasks;
138
+ }
121
139
  project.sessions.push(
122
140
  Object.fromEntries(
123
141
  Object.entries({
@@ -130,6 +148,8 @@ function buildHostedProjectGroups(tabs) {
130
148
  claude_session_id: normalizeOptionalString(tab.claude_session_id),
131
149
  status: normalizeOptionalString(tab.status),
132
150
  current_task: normalizeOptionalString(tab.current_task),
151
+ last_activity_at_utc: normalizeOptionalString(tab.last_activity_at_utc),
152
+ last_synced_at_utc: normalizeOptionalString(tab.last_synced_at_utc),
133
153
  }).filter(([, value]) => value !== undefined && value !== null),
134
154
  ),
135
155
  );
@@ -143,6 +163,12 @@ function buildHostedProjectGroups(tabs) {
143
163
  remote_url: project.remote_url || undefined,
144
164
  remote_branch: project.remote_branch || undefined,
145
165
  bootstrap_command: project.bootstrap_command || undefined,
166
+ linked_idea_id: project.linked_idea_id || undefined,
167
+ linked_feature_id: project.linked_feature_id || undefined,
168
+ last_activity_at_utc: project.last_activity_at_utc || undefined,
169
+ last_synced_at_utc: project.last_synced_at_utc || undefined,
170
+ plan: project.plan || undefined,
171
+ tasks: Array.isArray(project.tasks) && project.tasks.length > 0 ? project.tasks : undefined,
146
172
  session_count: project.sessions.length,
147
173
  sessions: project.sessions,
148
174
  }).filter(([, value]) => value !== undefined && value !== null),
@@ -150,6 +176,264 @@ function buildHostedProjectGroups(tabs) {
150
176
  );
151
177
  }
152
178
 
179
+ function readJsonIfExists(filePath) {
180
+ try {
181
+ if (!fs.existsSync(filePath)) {
182
+ return null;
183
+ }
184
+ const parsed = JSON.parse(fs.readFileSync(filePath, "utf8"));
185
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : null;
186
+ } catch {
187
+ return null;
188
+ }
189
+ }
190
+
191
+ function readTextIfExists(filePath) {
192
+ try {
193
+ if (!fs.existsSync(filePath)) {
194
+ return null;
195
+ }
196
+ const text = fs.readFileSync(filePath, "utf8").trim();
197
+ return text.length > 0 ? text : null;
198
+ } catch {
199
+ return null;
200
+ }
201
+ }
202
+
203
+ function resolveGitDir(projectRoot) {
204
+ const dotGit = path.join(projectRoot, ".git");
205
+ try {
206
+ const stat = fs.statSync(dotGit);
207
+ if (stat.isDirectory()) {
208
+ return dotGit;
209
+ }
210
+ if (stat.isFile()) {
211
+ const text = fs.readFileSync(dotGit, "utf8").trim();
212
+ const match = text.match(/^gitdir:\s*(.+)$/i);
213
+ if (match) {
214
+ const gitDir = match[1].trim();
215
+ return path.isAbsolute(gitDir) ? gitDir : path.resolve(projectRoot, gitDir);
216
+ }
217
+ }
218
+ } catch {
219
+ return null;
220
+ }
221
+ return null;
222
+ }
223
+
224
+ function readProjectLink(projectRoot, cache) {
225
+ const normalizedRoot = normalizeOptionalString(projectRoot);
226
+ if (!normalizedRoot) {
227
+ return null;
228
+ }
229
+ const cacheKey = `link:${normalizedRoot}`;
230
+ if (cache.has(cacheKey)) {
231
+ return cache.get(cacheKey);
232
+ }
233
+
234
+ const gitDir = resolveGitDir(normalizedRoot);
235
+ const linkPath = gitDir ? path.join(gitDir, "orp", "link", "project.json") : null;
236
+ const link = linkPath ? readJsonIfExists(linkPath) : null;
237
+ const ideaId = normalizeOptionalString(link?.idea_id ?? link?.ideaId);
238
+ if (!ideaId) {
239
+ cache.set(cacheKey, null);
240
+ return null;
241
+ }
242
+ const frontierFeatureIds =
243
+ link?.frontier_feature_ids && typeof link.frontier_feature_ids === "object" && !Array.isArray(link.frontier_feature_ids)
244
+ ? link.frontier_feature_ids
245
+ : link?.frontierFeatureIds && typeof link.frontierFeatureIds === "object" && !Array.isArray(link.frontierFeatureIds)
246
+ ? link.frontierFeatureIds
247
+ : {};
248
+ const normalizedLink = {
249
+ ideaId,
250
+ ideaTitle: normalizeOptionalString(link?.idea_title ?? link?.ideaTitle),
251
+ activeFeatureId: normalizeOptionalString(
252
+ link?.active_feature_id ?? link?.activeFeatureId ?? link?.linked_feature_id ?? link?.linkedFeatureId,
253
+ ),
254
+ frontierFeatureIds,
255
+ };
256
+ cache.set(cacheKey, normalizedLink);
257
+ return normalizedLink;
258
+ }
259
+
260
+ function findFrontierVersion(stack, versionId) {
261
+ const versions = Array.isArray(stack?.versions) ? stack.versions : [];
262
+ return versions.find((version) => normalizeOptionalString(version?.id) === versionId) || null;
263
+ }
264
+
265
+ function findFrontierMilestone(stack, milestoneId) {
266
+ const versions = Array.isArray(stack?.versions) ? stack.versions : [];
267
+ for (const version of versions) {
268
+ const milestones = Array.isArray(version?.milestones) ? version.milestones : [];
269
+ const milestone = milestones.find((row) => normalizeOptionalString(row?.id) === milestoneId);
270
+ if (milestone) {
271
+ return { version, milestone };
272
+ }
273
+ }
274
+ return { version: null, milestone: null };
275
+ }
276
+
277
+ function findFrontierPhase(milestone, phaseId) {
278
+ const phases = Array.isArray(milestone?.phases) ? milestone.phases : [];
279
+ return phases.find((phase) => normalizeOptionalString(phase?.id) === phaseId) || null;
280
+ }
281
+
282
+ function taskStatusFromFrontierStatus(value) {
283
+ const text = normalizeOptionalString(value)?.toLowerCase().replace(/[-\s]+/g, "_") || "";
284
+ if (["complete", "completed", "done", "terminal"].includes(text)) {
285
+ return "done";
286
+ }
287
+ if (["active", "in_progress", "running"].includes(text)) {
288
+ return "in_progress";
289
+ }
290
+ if (["blocked", "stuck"].includes(text)) {
291
+ return "blocked";
292
+ }
293
+ if (["skipped", "canceled", "cancelled"].includes(text)) {
294
+ return "skipped";
295
+ }
296
+ return "todo";
297
+ }
298
+
299
+ function titleFromTas(tasText) {
300
+ if (!tasText) {
301
+ return null;
302
+ }
303
+ const line = tasText
304
+ .split(/\r?\n/)
305
+ .map((row) => row.trim())
306
+ .find((row) => row.startsWith("# "));
307
+ return line ? line.replace(/^#\s+/, "").trim() || null : null;
308
+ }
309
+
310
+ function buildFrontierPlan({ projectRoot, tasText, state, stack }) {
311
+ const activeVersionId = normalizeOptionalString(state?.active_version ?? stack?.current_frontier?.active_version);
312
+ const activeMilestoneId = normalizeOptionalString(state?.active_milestone ?? stack?.current_frontier?.active_milestone);
313
+ const activePhaseId = normalizeOptionalString(state?.active_phase ?? stack?.current_frontier?.active_phase);
314
+ const nextAction = normalizeOptionalString(state?.next_action ?? stack?.current_frontier?.next_action);
315
+ const version = activeVersionId ? findFrontierVersion(stack, activeVersionId) : null;
316
+ const { milestone } = activeMilestoneId ? findFrontierMilestone(stack, activeMilestoneId) : { milestone: null };
317
+ const phase = activePhaseId ? findFrontierPhase(milestone, activePhaseId) : null;
318
+ const tasTitle = titleFromTas(tasText);
319
+ const summary =
320
+ tasTitle ||
321
+ normalizeOptionalString(milestone?.label) ||
322
+ normalizeOptionalString(phase?.label) ||
323
+ normalizeOptionalString(version?.label) ||
324
+ nextAction;
325
+
326
+ const bodyParts = [
327
+ nextAction ? `Current next action: ${nextAction}` : "",
328
+ activeVersionId || activeMilestoneId || activePhaseId
329
+ ? `Active frontier: ${[activeVersionId, activeMilestoneId, activePhaseId].filter(Boolean).join(" / ")}`
330
+ : "",
331
+ tasText || "",
332
+ ].filter(Boolean);
333
+
334
+ if (!summary && bodyParts.length === 0) {
335
+ return null;
336
+ }
337
+
338
+ return {
339
+ summary: summary || null,
340
+ body: bodyParts.join("\n\n"),
341
+ source: tasText ? "orp/frontier/TAS.md" : "orp/frontier/state.json",
342
+ };
343
+ }
344
+
345
+ function buildFrontierTasks({ state, stack }) {
346
+ const activeMilestoneId = normalizeOptionalString(state?.active_milestone ?? stack?.current_frontier?.active_milestone);
347
+ const activePhaseId = normalizeOptionalString(state?.active_phase ?? stack?.current_frontier?.active_phase);
348
+ const { milestone } = activeMilestoneId ? findFrontierMilestone(stack, activeMilestoneId) : { milestone: null };
349
+ const phases = Array.isArray(milestone?.phases) ? milestone.phases : [];
350
+
351
+ return phases
352
+ .map((phase, index) => {
353
+ const id = normalizeOptionalString(phase?.id) || `frontier-task-${index + 1}`;
354
+ const status = id === activePhaseId ? "in_progress" : taskStatusFromFrontierStatus(phase?.status);
355
+ return {
356
+ id,
357
+ title:
358
+ normalizeOptionalString(phase?.label) ||
359
+ normalizeOptionalString(phase?.goal) ||
360
+ id,
361
+ status,
362
+ completed: status === "done",
363
+ };
364
+ })
365
+ .filter((task) => task.title);
366
+ }
367
+
368
+ function readProjectFrontierContext(projectRoot, cache) {
369
+ const normalizedRoot = normalizeOptionalString(projectRoot);
370
+ if (!normalizedRoot) {
371
+ return null;
372
+ }
373
+ if (cache.has(normalizedRoot)) {
374
+ return cache.get(normalizedRoot);
375
+ }
376
+
377
+ const frontierRoot = path.join(normalizedRoot, "orp", "frontier");
378
+ const state = readJsonIfExists(path.join(frontierRoot, "state.json"));
379
+ const stack = readJsonIfExists(path.join(frontierRoot, "version-stack.json"));
380
+ const tasText = readTextIfExists(path.join(frontierRoot, "TAS.md"));
381
+ const projectLink = readProjectLink(normalizedRoot, cache);
382
+ if (!state && !stack && !tasText && !projectLink) {
383
+ cache.set(normalizedRoot, null);
384
+ return null;
385
+ }
386
+
387
+ const context = {
388
+ plan: buildFrontierPlan({ projectRoot: normalizedRoot, tasText, state, stack }),
389
+ tasks: stack ? buildFrontierTasks({ state, stack }) : [],
390
+ link: projectLink,
391
+ };
392
+ cache.set(normalizedRoot, context);
393
+ return context;
394
+ }
395
+
396
+ export function enrichWorkspaceTabsWithProjectContext(tabs = []) {
397
+ const projectContextCache = new Map();
398
+ return tabs.map((tab) => {
399
+ const projectRoot = normalizeOptionalString(tab?.path ?? tab?.project_root ?? tab?.projectRoot);
400
+ const projectContext = readProjectFrontierContext(projectRoot, projectContextCache);
401
+ if (!projectContext) {
402
+ return tab;
403
+ }
404
+ const plan =
405
+ tab?.plan && typeof tab.plan === "object" && !Array.isArray(tab.plan)
406
+ ? tab.plan
407
+ : projectContext.plan || undefined;
408
+ const tasks =
409
+ Array.isArray(tab?.tasks) && tab.tasks.length > 0
410
+ ? tab.tasks
411
+ : Array.isArray(projectContext.tasks) && projectContext.tasks.length > 0
412
+ ? projectContext.tasks
413
+ : undefined;
414
+
415
+ return Object.fromEntries(
416
+ Object.entries({
417
+ ...tab,
418
+ linkedIdeaId: tab?.linkedIdeaId ?? tab?.linked_idea_id ?? projectContext.link?.ideaId,
419
+ linkedFeatureId: tab?.linkedFeatureId ?? tab?.linked_feature_id ?? projectContext.link?.activeFeatureId,
420
+ plan,
421
+ tasks,
422
+ }).filter(([, value]) => value !== undefined && value !== null),
423
+ );
424
+ });
425
+ }
426
+
427
+ export function enrichWorkspaceManifestWithProjectContext(manifest) {
428
+ if (!manifest || typeof manifest !== "object" || Array.isArray(manifest)) {
429
+ return manifest;
430
+ }
431
+ return {
432
+ ...manifest,
433
+ tabs: enrichWorkspaceTabsWithProjectContext(Array.isArray(manifest.tabs) ? manifest.tabs : []),
434
+ };
435
+ }
436
+
153
437
  export function buildHostedWorkspaceState(manifest, options = {}) {
154
438
  if (!manifest || typeof manifest !== "object" || Array.isArray(manifest)) {
155
439
  throw new Error("workspace manifest is required to build a hosted workspace state payload");
@@ -166,12 +450,14 @@ export function buildHostedWorkspaceState(manifest, options = {}) {
166
450
  const capturedAt = normalizeOptionalString(options.capturedAt) || new Date().toISOString();
167
451
  const updatedAt = normalizeOptionalString(options.updatedAt) || capturedAt;
168
452
  const previousTabs = normalizePreviousHostedTabs(previousWorkspace);
453
+ const projectContextCache = new Map();
169
454
 
170
455
  const tabs = manifest.tabs.map((tab, index) => {
171
456
  const previous = matchPreviousHostedTab(tab, previousTabs);
172
457
  const title = normalizeOptionalString(tab.title) || previous?.title || null;
173
458
  const projectRoot = normalizeOptionalString(tab.path);
174
- const repoLabel = previous?.repoLabel || path.basename(String(projectRoot).replace(/\/+$/, "")) || projectRoot;
459
+ const projectContext = readProjectFrontierContext(projectRoot, projectContextCache);
460
+ const repoLabel = title || previous?.repoLabel || path.basename(String(projectRoot).replace(/\/+$/, "")) || projectRoot;
175
461
  const terminalTitle = previous?.terminalTitle || title || repoLabel;
176
462
  const resume = resolveResumeMetadata({
177
463
  resumeCommand: tab.resumeCommand,
@@ -198,6 +484,27 @@ export function buildHostedWorkspaceState(manifest, options = {}) {
198
484
  title,
199
485
  orderIndex: index,
200
486
  });
487
+ const plan =
488
+ tab.plan && typeof tab.plan === "object" && !Array.isArray(tab.plan)
489
+ ? tab.plan
490
+ : projectContext?.plan || previous?.plan || undefined;
491
+ const tasks =
492
+ Array.isArray(tab.tasks) && tab.tasks.length > 0
493
+ ? tab.tasks
494
+ : Array.isArray(projectContext?.tasks) && projectContext.tasks.length > 0
495
+ ? projectContext.tasks
496
+ : Array.isArray(previous?.tasks) && previous.tasks.length > 0
497
+ ? previous.tasks
498
+ : undefined;
499
+ const lastActivityAt =
500
+ normalizeOptionalString(tab.lastActivityAt ?? tab.last_activity_at_utc ?? tab.lastActivityAtUtc) ||
501
+ previous?.lastActivityAt ||
502
+ undefined;
503
+ const lastSyncedAt =
504
+ normalizeOptionalString(tab.lastSyncedAt ?? tab.last_synced_at_utc ?? tab.lastSyncedAtUtc) ||
505
+ previous?.lastSyncedAt ||
506
+ updatedAt ||
507
+ undefined;
201
508
 
202
509
  return Object.fromEntries(
203
510
  Object.entries({
@@ -219,9 +526,21 @@ export function buildHostedWorkspaceState(manifest, options = {}) {
219
526
  current_task: previous?.currentTask || undefined,
220
527
  focus_summary: previous?.focusSummary || undefined,
221
528
  trajectory_summary: previous?.trajectorySummary || undefined,
222
- last_activity_at_utc: previous?.lastActivityAt || undefined,
223
- linked_idea_id: previous?.linkedIdeaId || undefined,
224
- linked_feature_id: previous?.linkedFeatureId || undefined,
529
+ last_activity_at_utc: lastActivityAt,
530
+ last_synced_at_utc: lastSyncedAt,
531
+ sync_source: normalizeOptionalString(tab.syncSource ?? tab.sync_source) || undefined,
532
+ linked_feature_id:
533
+ normalizeOptionalString(tab.linkedFeatureId ?? tab.linked_feature_id) ||
534
+ projectContext?.link?.activeFeatureId ||
535
+ previous?.linkedFeatureId ||
536
+ undefined,
537
+ linked_idea_id:
538
+ normalizeOptionalString(tab.linkedIdeaId ?? tab.linked_idea_id) ||
539
+ projectContext?.link?.ideaId ||
540
+ previous?.linkedIdeaId ||
541
+ undefined,
542
+ plan,
543
+ tasks,
225
544
  }).filter(([, value]) => value !== undefined && value !== null),
226
545
  );
227
546
  });
@@ -239,6 +558,10 @@ export function buildHostedWorkspaceState(manifest, options = {}) {
239
558
  durable_backend: options.durableBackend || "manual-ledger",
240
559
  }).filter(([, value]) => value !== undefined && value !== null),
241
560
  );
561
+ const sourceContract =
562
+ options.localInventory?.contract && typeof options.localInventory.contract === "object" && !Array.isArray(options.localInventory.contract)
563
+ ? options.localInventory.contract
564
+ : undefined;
242
565
 
243
566
  const stateVersion = Math.max(1, (getHostedIntegerValue(previousState, "state_version", "stateVersion") || 0) + 1);
244
567
  const snapshotSeed = JSON.stringify({
@@ -266,6 +589,7 @@ export function buildHostedWorkspaceState(manifest, options = {}) {
266
589
  tab_count: tabs.length,
267
590
  project_count: projects.length,
268
591
  capture_context: Object.keys(captureContext).length > 0 ? captureContext : undefined,
592
+ source_contract: sourceContract,
269
593
  projects,
270
594
  tabs,
271
595
  }).filter(([, value]) => value !== undefined && value !== null),
@@ -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,
@@ -59,6 +63,7 @@ export {
59
63
  fetchIdeaPayload,
60
64
  fetchIdeasPayload,
61
65
  fetchHostedWorkspacesPayload,
66
+ findHostedWorkspaceByWorkspaceId,
62
67
  findHostedWorkspaceByLinkedIdea,
63
68
  findHostedWorkspaceLinkedToIdea,
64
69
  loadWorkspaceSource,
@@ -68,6 +73,11 @@ export {
68
73
  resolveWorkspaceSelectorFromCollections,
69
74
  updateIdeaPayload,
70
75
  } from "./orp.js";
76
+ export {
77
+ buildLocalProjectInventory,
78
+ inferLocalProjectRoots,
79
+ mergeLocalProjectInventoryIntoManifest,
80
+ } from "./local-inventory.js";
71
81
  export {
72
82
  cacheManagedWorkspaceManifest,
73
83
  clearWorkspaceSlot,