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/CHANGELOG.md +46 -0
- package/cli/orp.py +1381 -12
- package/package.json +1 -1
- package/packages/orp-workspace-launcher/src/core-plan.js +30 -0
- package/packages/orp-workspace-launcher/src/hosted-state.js +294 -2
- package/packages/orp-workspace-launcher/src/index.js +5 -1
- package/packages/orp-workspace-launcher/src/sync.js +14 -4
- package/packages/orp-workspace-launcher/test/core-plan.test.js +48 -0
- package/packages/orp-workspace-launcher/test/hosted-state.test.js +134 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "open-research-protocol",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.34",
|
|
4
4
|
"description": "ORP CLI (Open Research Protocol): workspace ledgers, secrets, scheduling, governed execution, and agent-friendly research workflows.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Fractal Research Group <cody@frg.earth>",
|
|
@@ -265,6 +265,8 @@ function normalizeStructuredTab(rawTab, index) {
|
|
|
265
265
|
const title = normalizeOptionalString(rawTab.title);
|
|
266
266
|
const resume = resolveResumeMetadata(rawTab);
|
|
267
267
|
const tmuxSessionName = normalizeOptionalString(rawTab.tmuxSessionName);
|
|
268
|
+
const plan = rawTab.plan && typeof rawTab.plan === "object" && !Array.isArray(rawTab.plan) ? rawTab.plan : null;
|
|
269
|
+
const tasks = Array.isArray(rawTab.tasks) ? rawTab.tasks : [];
|
|
268
270
|
|
|
269
271
|
return {
|
|
270
272
|
lineNumber: index + 1,
|
|
@@ -279,6 +281,10 @@ function normalizeStructuredTab(rawTab, index) {
|
|
|
279
281
|
remoteUrl: normalizeOptionalUrl(rawTab.remoteUrl, `workspace tab ${index + 1} remoteUrl`),
|
|
280
282
|
remoteBranch: normalizeOptionalString(rawTab.remoteBranch),
|
|
281
283
|
bootstrapCommand: normalizeOptionalCommand(rawTab.bootstrapCommand),
|
|
284
|
+
linkedIdeaId: normalizeOptionalString(rawTab.linkedIdeaId ?? rawTab.linked_idea_id),
|
|
285
|
+
linkedFeatureId: normalizeOptionalString(rawTab.linkedFeatureId ?? rawTab.linked_feature_id),
|
|
286
|
+
plan,
|
|
287
|
+
tasks,
|
|
282
288
|
};
|
|
283
289
|
}
|
|
284
290
|
|
|
@@ -307,6 +313,14 @@ function normalizeStructuredProject(rawProject, projectIndex) {
|
|
|
307
313
|
codexSessionId: rawSession.codexSessionId,
|
|
308
314
|
claudeSessionId: rawSession.claudeSessionId,
|
|
309
315
|
tmuxSessionName: rawSession.tmuxSessionName,
|
|
316
|
+
linkedIdeaId: rawSession.linkedIdeaId ?? rawSession.linked_idea_id ?? rawProject.linkedIdeaId ?? rawProject.linked_idea_id,
|
|
317
|
+
linkedFeatureId:
|
|
318
|
+
rawSession.linkedFeatureId ??
|
|
319
|
+
rawSession.linked_feature_id ??
|
|
320
|
+
rawProject.linkedFeatureId ??
|
|
321
|
+
rawProject.linked_feature_id,
|
|
322
|
+
plan: rawSession.plan ?? rawProject.plan,
|
|
323
|
+
tasks: rawSession.tasks ?? rawProject.tasks,
|
|
310
324
|
},
|
|
311
325
|
sessionIndex,
|
|
312
326
|
);
|
|
@@ -381,6 +395,10 @@ export function buildWorkspaceProjectGroups(entries = []) {
|
|
|
381
395
|
remoteUrl: normalizeOptionalUrl(entry.remoteUrl, "workspace project remoteUrl"),
|
|
382
396
|
remoteBranch: normalizeOptionalString(entry.remoteBranch),
|
|
383
397
|
bootstrapCommand: normalizeOptionalCommand(entry.bootstrapCommand),
|
|
398
|
+
linkedIdeaId: normalizeOptionalString(entry.linkedIdeaId),
|
|
399
|
+
linkedFeatureId: normalizeOptionalString(entry.linkedFeatureId),
|
|
400
|
+
plan: entry.plan && typeof entry.plan === "object" && !Array.isArray(entry.plan) ? entry.plan : null,
|
|
401
|
+
tasks: Array.isArray(entry.tasks) && entry.tasks.length > 0 ? entry.tasks : [],
|
|
384
402
|
sessions: [],
|
|
385
403
|
});
|
|
386
404
|
}
|
|
@@ -389,6 +407,14 @@ export function buildWorkspaceProjectGroups(entries = []) {
|
|
|
389
407
|
project.remoteUrl = project.remoteUrl || normalizeOptionalUrl(entry.remoteUrl, "workspace project remoteUrl");
|
|
390
408
|
project.remoteBranch = project.remoteBranch || normalizeOptionalString(entry.remoteBranch);
|
|
391
409
|
project.bootstrapCommand = project.bootstrapCommand || normalizeOptionalCommand(entry.bootstrapCommand);
|
|
410
|
+
project.linkedIdeaId = project.linkedIdeaId || normalizeOptionalString(entry.linkedIdeaId);
|
|
411
|
+
project.linkedFeatureId = project.linkedFeatureId || normalizeOptionalString(entry.linkedFeatureId);
|
|
412
|
+
project.plan =
|
|
413
|
+
project.plan ||
|
|
414
|
+
(entry.plan && typeof entry.plan === "object" && !Array.isArray(entry.plan) ? entry.plan : null);
|
|
415
|
+
if ((!Array.isArray(project.tasks) || project.tasks.length === 0) && Array.isArray(entry.tasks) && entry.tasks.length > 0) {
|
|
416
|
+
project.tasks = entry.tasks;
|
|
417
|
+
}
|
|
392
418
|
|
|
393
419
|
const resume = resolveResumeMetadata(entry);
|
|
394
420
|
project.sessions.push(
|
|
@@ -413,6 +439,10 @@ export function buildWorkspaceProjectGroups(entries = []) {
|
|
|
413
439
|
remoteUrl: project.remoteUrl || undefined,
|
|
414
440
|
remoteBranch: project.remoteBranch || undefined,
|
|
415
441
|
bootstrapCommand: project.bootstrapCommand || undefined,
|
|
442
|
+
linkedIdeaId: project.linkedIdeaId || undefined,
|
|
443
|
+
linkedFeatureId: project.linkedFeatureId || undefined,
|
|
444
|
+
plan: project.plan || undefined,
|
|
445
|
+
tasks: Array.isArray(project.tasks) && project.tasks.length > 0 ? project.tasks : undefined,
|
|
416
446
|
sessionCount: project.sessions.length,
|
|
417
447
|
sessions: project.sessions,
|
|
418
448
|
}).filter(([, value]) => value !== undefined),
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import os from "node:os";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import crypto from "node:crypto";
|
|
4
|
+
import fs from "node:fs";
|
|
4
5
|
|
|
5
6
|
import { resolveResumeMetadata } from "./core-plan.js";
|
|
6
7
|
|
|
@@ -73,6 +74,8 @@ function normalizePreviousHostedTabs(workspace) {
|
|
|
73
74
|
lastActivityAt: normalizeOptionalString(tab.last_activity_at_utc ?? tab.lastActivityAtUtc),
|
|
74
75
|
linkedIdeaId: normalizeOptionalString(tab.linked_idea_id ?? tab.linkedIdeaId),
|
|
75
76
|
linkedFeatureId: normalizeOptionalString(tab.linked_feature_id ?? tab.linkedFeatureId),
|
|
77
|
+
plan: tab.plan && typeof tab.plan === "object" && !Array.isArray(tab.plan) ? tab.plan : null,
|
|
78
|
+
tasks: Array.isArray(tab.tasks) ? tab.tasks : [],
|
|
76
79
|
used: false,
|
|
77
80
|
};
|
|
78
81
|
});
|
|
@@ -111,6 +114,10 @@ function buildHostedProjectGroups(tabs) {
|
|
|
111
114
|
remote_url: normalizeOptionalString(tab.remote_url),
|
|
112
115
|
remote_branch: normalizeOptionalString(tab.remote_branch),
|
|
113
116
|
bootstrap_command: normalizeOptionalString(tab.bootstrap_command),
|
|
117
|
+
linked_idea_id: normalizeOptionalString(tab.linked_idea_id),
|
|
118
|
+
linked_feature_id: normalizeOptionalString(tab.linked_feature_id),
|
|
119
|
+
plan: tab.plan && typeof tab.plan === "object" && !Array.isArray(tab.plan) ? tab.plan : undefined,
|
|
120
|
+
tasks: Array.isArray(tab.tasks) ? tab.tasks : undefined,
|
|
114
121
|
sessions: [],
|
|
115
122
|
});
|
|
116
123
|
}
|
|
@@ -118,6 +125,12 @@ function buildHostedProjectGroups(tabs) {
|
|
|
118
125
|
project.remote_url = project.remote_url || normalizeOptionalString(tab.remote_url);
|
|
119
126
|
project.remote_branch = project.remote_branch || normalizeOptionalString(tab.remote_branch);
|
|
120
127
|
project.bootstrap_command = project.bootstrap_command || normalizeOptionalString(tab.bootstrap_command);
|
|
128
|
+
project.linked_idea_id = project.linked_idea_id || normalizeOptionalString(tab.linked_idea_id);
|
|
129
|
+
project.linked_feature_id = project.linked_feature_id || normalizeOptionalString(tab.linked_feature_id);
|
|
130
|
+
project.plan = project.plan || (tab.plan && typeof tab.plan === "object" && !Array.isArray(tab.plan) ? tab.plan : undefined);
|
|
131
|
+
if ((!Array.isArray(project.tasks) || project.tasks.length === 0) && Array.isArray(tab.tasks) && tab.tasks.length > 0) {
|
|
132
|
+
project.tasks = tab.tasks;
|
|
133
|
+
}
|
|
121
134
|
project.sessions.push(
|
|
122
135
|
Object.fromEntries(
|
|
123
136
|
Object.entries({
|
|
@@ -143,6 +156,10 @@ function buildHostedProjectGroups(tabs) {
|
|
|
143
156
|
remote_url: project.remote_url || undefined,
|
|
144
157
|
remote_branch: project.remote_branch || undefined,
|
|
145
158
|
bootstrap_command: project.bootstrap_command || undefined,
|
|
159
|
+
linked_idea_id: project.linked_idea_id || undefined,
|
|
160
|
+
linked_feature_id: project.linked_feature_id || undefined,
|
|
161
|
+
plan: project.plan || undefined,
|
|
162
|
+
tasks: Array.isArray(project.tasks) && project.tasks.length > 0 ? project.tasks : undefined,
|
|
146
163
|
session_count: project.sessions.length,
|
|
147
164
|
sessions: project.sessions,
|
|
148
165
|
}).filter(([, value]) => value !== undefined && value !== null),
|
|
@@ -150,6 +167,264 @@ function buildHostedProjectGroups(tabs) {
|
|
|
150
167
|
);
|
|
151
168
|
}
|
|
152
169
|
|
|
170
|
+
function readJsonIfExists(filePath) {
|
|
171
|
+
try {
|
|
172
|
+
if (!fs.existsSync(filePath)) {
|
|
173
|
+
return null;
|
|
174
|
+
}
|
|
175
|
+
const parsed = JSON.parse(fs.readFileSync(filePath, "utf8"));
|
|
176
|
+
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : null;
|
|
177
|
+
} catch {
|
|
178
|
+
return null;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function readTextIfExists(filePath) {
|
|
183
|
+
try {
|
|
184
|
+
if (!fs.existsSync(filePath)) {
|
|
185
|
+
return null;
|
|
186
|
+
}
|
|
187
|
+
const text = fs.readFileSync(filePath, "utf8").trim();
|
|
188
|
+
return text.length > 0 ? text : null;
|
|
189
|
+
} catch {
|
|
190
|
+
return null;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function resolveGitDir(projectRoot) {
|
|
195
|
+
const dotGit = path.join(projectRoot, ".git");
|
|
196
|
+
try {
|
|
197
|
+
const stat = fs.statSync(dotGit);
|
|
198
|
+
if (stat.isDirectory()) {
|
|
199
|
+
return dotGit;
|
|
200
|
+
}
|
|
201
|
+
if (stat.isFile()) {
|
|
202
|
+
const text = fs.readFileSync(dotGit, "utf8").trim();
|
|
203
|
+
const match = text.match(/^gitdir:\s*(.+)$/i);
|
|
204
|
+
if (match) {
|
|
205
|
+
const gitDir = match[1].trim();
|
|
206
|
+
return path.isAbsolute(gitDir) ? gitDir : path.resolve(projectRoot, gitDir);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
} catch {
|
|
210
|
+
return null;
|
|
211
|
+
}
|
|
212
|
+
return null;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function readProjectLink(projectRoot, cache) {
|
|
216
|
+
const normalizedRoot = normalizeOptionalString(projectRoot);
|
|
217
|
+
if (!normalizedRoot) {
|
|
218
|
+
return null;
|
|
219
|
+
}
|
|
220
|
+
const cacheKey = `link:${normalizedRoot}`;
|
|
221
|
+
if (cache.has(cacheKey)) {
|
|
222
|
+
return cache.get(cacheKey);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const gitDir = resolveGitDir(normalizedRoot);
|
|
226
|
+
const linkPath = gitDir ? path.join(gitDir, "orp", "link", "project.json") : null;
|
|
227
|
+
const link = linkPath ? readJsonIfExists(linkPath) : null;
|
|
228
|
+
const ideaId = normalizeOptionalString(link?.idea_id ?? link?.ideaId);
|
|
229
|
+
if (!ideaId) {
|
|
230
|
+
cache.set(cacheKey, null);
|
|
231
|
+
return null;
|
|
232
|
+
}
|
|
233
|
+
const frontierFeatureIds =
|
|
234
|
+
link?.frontier_feature_ids && typeof link.frontier_feature_ids === "object" && !Array.isArray(link.frontier_feature_ids)
|
|
235
|
+
? link.frontier_feature_ids
|
|
236
|
+
: link?.frontierFeatureIds && typeof link.frontierFeatureIds === "object" && !Array.isArray(link.frontierFeatureIds)
|
|
237
|
+
? link.frontierFeatureIds
|
|
238
|
+
: {};
|
|
239
|
+
const normalizedLink = {
|
|
240
|
+
ideaId,
|
|
241
|
+
ideaTitle: normalizeOptionalString(link?.idea_title ?? link?.ideaTitle),
|
|
242
|
+
activeFeatureId: normalizeOptionalString(
|
|
243
|
+
link?.active_feature_id ?? link?.activeFeatureId ?? link?.linked_feature_id ?? link?.linkedFeatureId,
|
|
244
|
+
),
|
|
245
|
+
frontierFeatureIds,
|
|
246
|
+
};
|
|
247
|
+
cache.set(cacheKey, normalizedLink);
|
|
248
|
+
return normalizedLink;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function findFrontierVersion(stack, versionId) {
|
|
252
|
+
const versions = Array.isArray(stack?.versions) ? stack.versions : [];
|
|
253
|
+
return versions.find((version) => normalizeOptionalString(version?.id) === versionId) || null;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function findFrontierMilestone(stack, milestoneId) {
|
|
257
|
+
const versions = Array.isArray(stack?.versions) ? stack.versions : [];
|
|
258
|
+
for (const version of versions) {
|
|
259
|
+
const milestones = Array.isArray(version?.milestones) ? version.milestones : [];
|
|
260
|
+
const milestone = milestones.find((row) => normalizeOptionalString(row?.id) === milestoneId);
|
|
261
|
+
if (milestone) {
|
|
262
|
+
return { version, milestone };
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
return { version: null, milestone: null };
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function findFrontierPhase(milestone, phaseId) {
|
|
269
|
+
const phases = Array.isArray(milestone?.phases) ? milestone.phases : [];
|
|
270
|
+
return phases.find((phase) => normalizeOptionalString(phase?.id) === phaseId) || null;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function taskStatusFromFrontierStatus(value) {
|
|
274
|
+
const text = normalizeOptionalString(value)?.toLowerCase().replace(/[-\s]+/g, "_") || "";
|
|
275
|
+
if (["complete", "completed", "done", "terminal"].includes(text)) {
|
|
276
|
+
return "done";
|
|
277
|
+
}
|
|
278
|
+
if (["active", "in_progress", "running"].includes(text)) {
|
|
279
|
+
return "in_progress";
|
|
280
|
+
}
|
|
281
|
+
if (["blocked", "stuck"].includes(text)) {
|
|
282
|
+
return "blocked";
|
|
283
|
+
}
|
|
284
|
+
if (["skipped", "canceled", "cancelled"].includes(text)) {
|
|
285
|
+
return "skipped";
|
|
286
|
+
}
|
|
287
|
+
return "todo";
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function titleFromTas(tasText) {
|
|
291
|
+
if (!tasText) {
|
|
292
|
+
return null;
|
|
293
|
+
}
|
|
294
|
+
const line = tasText
|
|
295
|
+
.split(/\r?\n/)
|
|
296
|
+
.map((row) => row.trim())
|
|
297
|
+
.find((row) => row.startsWith("# "));
|
|
298
|
+
return line ? line.replace(/^#\s+/, "").trim() || null : null;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function buildFrontierPlan({ projectRoot, tasText, state, stack }) {
|
|
302
|
+
const activeVersionId = normalizeOptionalString(state?.active_version ?? stack?.current_frontier?.active_version);
|
|
303
|
+
const activeMilestoneId = normalizeOptionalString(state?.active_milestone ?? stack?.current_frontier?.active_milestone);
|
|
304
|
+
const activePhaseId = normalizeOptionalString(state?.active_phase ?? stack?.current_frontier?.active_phase);
|
|
305
|
+
const nextAction = normalizeOptionalString(state?.next_action ?? stack?.current_frontier?.next_action);
|
|
306
|
+
const version = activeVersionId ? findFrontierVersion(stack, activeVersionId) : null;
|
|
307
|
+
const { milestone } = activeMilestoneId ? findFrontierMilestone(stack, activeMilestoneId) : { milestone: null };
|
|
308
|
+
const phase = activePhaseId ? findFrontierPhase(milestone, activePhaseId) : null;
|
|
309
|
+
const tasTitle = titleFromTas(tasText);
|
|
310
|
+
const summary =
|
|
311
|
+
tasTitle ||
|
|
312
|
+
normalizeOptionalString(milestone?.label) ||
|
|
313
|
+
normalizeOptionalString(phase?.label) ||
|
|
314
|
+
normalizeOptionalString(version?.label) ||
|
|
315
|
+
nextAction;
|
|
316
|
+
|
|
317
|
+
const bodyParts = [
|
|
318
|
+
nextAction ? `Current next action: ${nextAction}` : "",
|
|
319
|
+
activeVersionId || activeMilestoneId || activePhaseId
|
|
320
|
+
? `Active frontier: ${[activeVersionId, activeMilestoneId, activePhaseId].filter(Boolean).join(" / ")}`
|
|
321
|
+
: "",
|
|
322
|
+
tasText || "",
|
|
323
|
+
].filter(Boolean);
|
|
324
|
+
|
|
325
|
+
if (!summary && bodyParts.length === 0) {
|
|
326
|
+
return null;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
return {
|
|
330
|
+
summary: summary || null,
|
|
331
|
+
body: bodyParts.join("\n\n"),
|
|
332
|
+
source: tasText ? "orp/frontier/TAS.md" : "orp/frontier/state.json",
|
|
333
|
+
};
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
function buildFrontierTasks({ state, stack }) {
|
|
337
|
+
const activeMilestoneId = normalizeOptionalString(state?.active_milestone ?? stack?.current_frontier?.active_milestone);
|
|
338
|
+
const activePhaseId = normalizeOptionalString(state?.active_phase ?? stack?.current_frontier?.active_phase);
|
|
339
|
+
const { milestone } = activeMilestoneId ? findFrontierMilestone(stack, activeMilestoneId) : { milestone: null };
|
|
340
|
+
const phases = Array.isArray(milestone?.phases) ? milestone.phases : [];
|
|
341
|
+
|
|
342
|
+
return phases
|
|
343
|
+
.map((phase, index) => {
|
|
344
|
+
const id = normalizeOptionalString(phase?.id) || `frontier-task-${index + 1}`;
|
|
345
|
+
const status = id === activePhaseId ? "in_progress" : taskStatusFromFrontierStatus(phase?.status);
|
|
346
|
+
return {
|
|
347
|
+
id,
|
|
348
|
+
title:
|
|
349
|
+
normalizeOptionalString(phase?.label) ||
|
|
350
|
+
normalizeOptionalString(phase?.goal) ||
|
|
351
|
+
id,
|
|
352
|
+
status,
|
|
353
|
+
completed: status === "done",
|
|
354
|
+
};
|
|
355
|
+
})
|
|
356
|
+
.filter((task) => task.title);
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
function readProjectFrontierContext(projectRoot, cache) {
|
|
360
|
+
const normalizedRoot = normalizeOptionalString(projectRoot);
|
|
361
|
+
if (!normalizedRoot) {
|
|
362
|
+
return null;
|
|
363
|
+
}
|
|
364
|
+
if (cache.has(normalizedRoot)) {
|
|
365
|
+
return cache.get(normalizedRoot);
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
const frontierRoot = path.join(normalizedRoot, "orp", "frontier");
|
|
369
|
+
const state = readJsonIfExists(path.join(frontierRoot, "state.json"));
|
|
370
|
+
const stack = readJsonIfExists(path.join(frontierRoot, "version-stack.json"));
|
|
371
|
+
const tasText = readTextIfExists(path.join(frontierRoot, "TAS.md"));
|
|
372
|
+
const projectLink = readProjectLink(normalizedRoot, cache);
|
|
373
|
+
if (!state && !stack && !tasText && !projectLink) {
|
|
374
|
+
cache.set(normalizedRoot, null);
|
|
375
|
+
return null;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
const context = {
|
|
379
|
+
plan: buildFrontierPlan({ projectRoot: normalizedRoot, tasText, state, stack }),
|
|
380
|
+
tasks: stack ? buildFrontierTasks({ state, stack }) : [],
|
|
381
|
+
link: projectLink,
|
|
382
|
+
};
|
|
383
|
+
cache.set(normalizedRoot, context);
|
|
384
|
+
return context;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
export function enrichWorkspaceTabsWithProjectContext(tabs = []) {
|
|
388
|
+
const projectContextCache = new Map();
|
|
389
|
+
return tabs.map((tab) => {
|
|
390
|
+
const projectRoot = normalizeOptionalString(tab?.path ?? tab?.project_root ?? tab?.projectRoot);
|
|
391
|
+
const projectContext = readProjectFrontierContext(projectRoot, projectContextCache);
|
|
392
|
+
if (!projectContext) {
|
|
393
|
+
return tab;
|
|
394
|
+
}
|
|
395
|
+
const plan =
|
|
396
|
+
tab?.plan && typeof tab.plan === "object" && !Array.isArray(tab.plan)
|
|
397
|
+
? tab.plan
|
|
398
|
+
: projectContext.plan || undefined;
|
|
399
|
+
const tasks =
|
|
400
|
+
Array.isArray(tab?.tasks) && tab.tasks.length > 0
|
|
401
|
+
? tab.tasks
|
|
402
|
+
: Array.isArray(projectContext.tasks) && projectContext.tasks.length > 0
|
|
403
|
+
? projectContext.tasks
|
|
404
|
+
: undefined;
|
|
405
|
+
|
|
406
|
+
return Object.fromEntries(
|
|
407
|
+
Object.entries({
|
|
408
|
+
...tab,
|
|
409
|
+
linkedIdeaId: tab?.linkedIdeaId ?? tab?.linked_idea_id ?? projectContext.link?.ideaId,
|
|
410
|
+
linkedFeatureId: tab?.linkedFeatureId ?? tab?.linked_feature_id ?? projectContext.link?.activeFeatureId,
|
|
411
|
+
plan,
|
|
412
|
+
tasks,
|
|
413
|
+
}).filter(([, value]) => value !== undefined && value !== null),
|
|
414
|
+
);
|
|
415
|
+
});
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
export function enrichWorkspaceManifestWithProjectContext(manifest) {
|
|
419
|
+
if (!manifest || typeof manifest !== "object" || Array.isArray(manifest)) {
|
|
420
|
+
return manifest;
|
|
421
|
+
}
|
|
422
|
+
return {
|
|
423
|
+
...manifest,
|
|
424
|
+
tabs: enrichWorkspaceTabsWithProjectContext(Array.isArray(manifest.tabs) ? manifest.tabs : []),
|
|
425
|
+
};
|
|
426
|
+
}
|
|
427
|
+
|
|
153
428
|
export function buildHostedWorkspaceState(manifest, options = {}) {
|
|
154
429
|
if (!manifest || typeof manifest !== "object" || Array.isArray(manifest)) {
|
|
155
430
|
throw new Error("workspace manifest is required to build a hosted workspace state payload");
|
|
@@ -166,11 +441,13 @@ export function buildHostedWorkspaceState(manifest, options = {}) {
|
|
|
166
441
|
const capturedAt = normalizeOptionalString(options.capturedAt) || new Date().toISOString();
|
|
167
442
|
const updatedAt = normalizeOptionalString(options.updatedAt) || capturedAt;
|
|
168
443
|
const previousTabs = normalizePreviousHostedTabs(previousWorkspace);
|
|
444
|
+
const projectContextCache = new Map();
|
|
169
445
|
|
|
170
446
|
const tabs = manifest.tabs.map((tab, index) => {
|
|
171
447
|
const previous = matchPreviousHostedTab(tab, previousTabs);
|
|
172
448
|
const title = normalizeOptionalString(tab.title) || previous?.title || null;
|
|
173
449
|
const projectRoot = normalizeOptionalString(tab.path);
|
|
450
|
+
const projectContext = readProjectFrontierContext(projectRoot, projectContextCache);
|
|
174
451
|
const repoLabel = previous?.repoLabel || path.basename(String(projectRoot).replace(/\/+$/, "")) || projectRoot;
|
|
175
452
|
const terminalTitle = previous?.terminalTitle || title || repoLabel;
|
|
176
453
|
const resume = resolveResumeMetadata({
|
|
@@ -220,8 +497,23 @@ export function buildHostedWorkspaceState(manifest, options = {}) {
|
|
|
220
497
|
focus_summary: previous?.focusSummary || undefined,
|
|
221
498
|
trajectory_summary: previous?.trajectorySummary || undefined,
|
|
222
499
|
last_activity_at_utc: previous?.lastActivityAt || undefined,
|
|
223
|
-
|
|
224
|
-
|
|
500
|
+
linked_feature_id:
|
|
501
|
+
normalizeOptionalString(tab.linkedFeatureId ?? tab.linked_feature_id) ||
|
|
502
|
+
projectContext?.link?.activeFeatureId ||
|
|
503
|
+
previous?.linkedFeatureId ||
|
|
504
|
+
undefined,
|
|
505
|
+
linked_idea_id:
|
|
506
|
+
normalizeOptionalString(tab.linkedIdeaId ?? tab.linked_idea_id) ||
|
|
507
|
+
projectContext?.link?.ideaId ||
|
|
508
|
+
previous?.linkedIdeaId ||
|
|
509
|
+
undefined,
|
|
510
|
+
plan: projectContext?.plan || previous?.plan || undefined,
|
|
511
|
+
tasks:
|
|
512
|
+
Array.isArray(projectContext?.tasks) && projectContext.tasks.length > 0
|
|
513
|
+
? projectContext.tasks
|
|
514
|
+
: Array.isArray(previous?.tasks) && previous.tasks.length > 0
|
|
515
|
+
? previous.tasks
|
|
516
|
+
: undefined,
|
|
225
517
|
}).filter(([, value]) => value !== undefined && value !== null),
|
|
226
518
|
);
|
|
227
519
|
});
|
|
@@ -41,7 +41,11 @@ export {
|
|
|
41
41
|
runWorkspaceAddTab,
|
|
42
42
|
runWorkspaceRemoveTab,
|
|
43
43
|
} from "./ledger.js";
|
|
44
|
-
export {
|
|
44
|
+
export {
|
|
45
|
+
buildHostedWorkspaceState,
|
|
46
|
+
enrichWorkspaceManifestWithProjectContext,
|
|
47
|
+
enrichWorkspaceTabsWithProjectContext,
|
|
48
|
+
} from "./hosted-state.js";
|
|
45
49
|
export {
|
|
46
50
|
applyWorkspaceSlotsToInventory,
|
|
47
51
|
buildWorkspaceInventory,
|
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
resolveResumeMetadata,
|
|
11
11
|
WORKSPACE_SCHEMA_VERSION,
|
|
12
12
|
} from "./core-plan.js";
|
|
13
|
+
import { enrichWorkspaceManifestWithProjectContext } from "./hosted-state.js";
|
|
13
14
|
import { fetchIdeaPayload, loadWorkspaceSource, updateIdeaPayload } from "./orp.js";
|
|
14
15
|
import { cacheManagedWorkspaceManifest } from "./registry.js";
|
|
15
16
|
|
|
@@ -198,6 +199,10 @@ function serializeWorkspaceManifest(manifest) {
|
|
|
198
199
|
resumeCommand: normalizeOptionalString(entry.resumeCommand) ?? undefined,
|
|
199
200
|
resumeTool: normalizeOptionalString(entry.resumeTool) ?? undefined,
|
|
200
201
|
resumeSessionId: normalizeOptionalString(entry.resumeSessionId ?? entry.sessionId) ?? undefined,
|
|
202
|
+
linkedIdeaId: normalizeOptionalString(entry.linkedIdeaId ?? entry.linked_idea_id) ?? undefined,
|
|
203
|
+
linkedFeatureId: normalizeOptionalString(entry.linkedFeatureId ?? entry.linked_feature_id) ?? undefined,
|
|
204
|
+
plan: entry.plan && typeof entry.plan === "object" && !Array.isArray(entry.plan) ? entry.plan : undefined,
|
|
205
|
+
tasks: Array.isArray(entry.tasks) && entry.tasks.length > 0 ? entry.tasks : undefined,
|
|
201
206
|
codexSessionId:
|
|
202
207
|
normalizeOptionalString(entry.resumeTool) === "codex"
|
|
203
208
|
? normalizeOptionalString(entry.codexSessionId ?? entry.resumeSessionId ?? entry.sessionId) ?? undefined
|
|
@@ -250,6 +255,10 @@ export function buildWorkspaceSyncPreview({ source, parsed, targetIdea, workspac
|
|
|
250
255
|
resumeSessionId: entry.sessionId || null,
|
|
251
256
|
codexSessionId: entry.resumeTool === "codex" ? entry.sessionId || null : null,
|
|
252
257
|
claudeSessionId: entry.resumeTool === "claude" ? entry.sessionId || null : null,
|
|
258
|
+
linkedIdeaId: entry.linkedIdeaId || null,
|
|
259
|
+
linkedFeatureId: entry.linkedFeatureId || null,
|
|
260
|
+
plan: entry.plan || null,
|
|
261
|
+
tasks: Array.isArray(entry.tasks) ? entry.tasks : [],
|
|
253
262
|
})),
|
|
254
263
|
}
|
|
255
264
|
: {
|
|
@@ -272,6 +281,7 @@ export function buildWorkspaceSyncPreview({ source, parsed, targetIdea, workspac
|
|
|
272
281
|
resolveResumeMetadata(entry).resumeTool === "claude" ? resolveResumeMetadata(entry).resumeSessionId : null,
|
|
273
282
|
})),
|
|
274
283
|
};
|
|
284
|
+
const enrichedManifest = enrichWorkspaceManifestWithProjectContext(manifest);
|
|
275
285
|
|
|
276
286
|
const narrativeSourceNotes =
|
|
277
287
|
source.sourceType === "workspace-file" ? targetIdea.notes || "" : source.notes || targetIdea.notes || "";
|
|
@@ -280,7 +290,7 @@ export function buildWorkspaceSyncPreview({ source, parsed, targetIdea, workspac
|
|
|
280
290
|
});
|
|
281
291
|
const nextNotes = composeWorkspaceNotes({
|
|
282
292
|
narrativeNotes,
|
|
283
|
-
manifest,
|
|
293
|
+
manifest: enrichedManifest,
|
|
284
294
|
});
|
|
285
295
|
|
|
286
296
|
return {
|
|
@@ -289,11 +299,11 @@ export function buildWorkspaceSyncPreview({ source, parsed, targetIdea, workspac
|
|
|
289
299
|
sourceType: source.sourceType,
|
|
290
300
|
sourceLabel: source.sourceLabel,
|
|
291
301
|
parseMode: parsed.parseMode,
|
|
292
|
-
workspaceId:
|
|
293
|
-
manifest,
|
|
302
|
+
workspaceId: enrichedManifest.workspaceId,
|
|
303
|
+
manifest: enrichedManifest,
|
|
294
304
|
nextNotes,
|
|
295
305
|
nextNotesLength: nextNotes.length,
|
|
296
|
-
tabs:
|
|
306
|
+
tabs: enrichedManifest.tabs,
|
|
297
307
|
skipped: parsed.skipped,
|
|
298
308
|
};
|
|
299
309
|
}
|
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import test from "node:test";
|
|
2
2
|
import assert from "node:assert/strict";
|
|
3
|
+
import fs from "node:fs/promises";
|
|
4
|
+
import os from "node:os";
|
|
5
|
+
import path from "node:path";
|
|
3
6
|
|
|
4
7
|
import {
|
|
5
8
|
buildLaunchPlan,
|
|
@@ -243,6 +246,51 @@ Workspace summary.
|
|
|
243
246
|
assert.doesNotMatch(preview.nextNotes, /\/Volumes\/Code_2TB\/code\/orp: codex resume/);
|
|
244
247
|
});
|
|
245
248
|
|
|
249
|
+
test("buildWorkspaceSyncPreview enriches workspace-file tabs with linked ORP project context", async () => {
|
|
250
|
+
const projectRoot = await fs.mkdtemp(path.join(os.tmpdir(), "orp-workspace-sync-frontier-"));
|
|
251
|
+
await fs.mkdir(path.join(projectRoot, ".git", "orp", "link"), { recursive: true });
|
|
252
|
+
await fs.writeFile(
|
|
253
|
+
path.join(projectRoot, ".git", "orp", "link", "project.json"),
|
|
254
|
+
JSON.stringify(
|
|
255
|
+
{
|
|
256
|
+
idea_id: "idea-linked",
|
|
257
|
+
active_feature_id: "feature-active",
|
|
258
|
+
project_root: projectRoot,
|
|
259
|
+
},
|
|
260
|
+
null,
|
|
261
|
+
2,
|
|
262
|
+
),
|
|
263
|
+
"utf8",
|
|
264
|
+
);
|
|
265
|
+
const source = {
|
|
266
|
+
sourceType: "workspace-file",
|
|
267
|
+
sourceLabel: "/tmp/workspace.json",
|
|
268
|
+
sourcePath: "/tmp/workspace.json",
|
|
269
|
+
title: "Workspace idea",
|
|
270
|
+
notes: "",
|
|
271
|
+
workspaceManifest: {
|
|
272
|
+
version: "1",
|
|
273
|
+
workspaceId: "workspace-file-demo",
|
|
274
|
+
tabs: [{ title: "Linked project", path: projectRoot }],
|
|
275
|
+
},
|
|
276
|
+
};
|
|
277
|
+
const parsed = parseWorkspaceSource(source);
|
|
278
|
+
const preview = buildWorkspaceSyncPreview({
|
|
279
|
+
source,
|
|
280
|
+
parsed,
|
|
281
|
+
targetIdea: {
|
|
282
|
+
id: "idea-123",
|
|
283
|
+
title: "Workspace idea",
|
|
284
|
+
notes: "",
|
|
285
|
+
},
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
assert.equal(preview.tabs[0]?.linkedIdeaId, "idea-linked");
|
|
289
|
+
assert.equal(preview.tabs[0]?.linkedFeatureId, "feature-active");
|
|
290
|
+
assert.match(preview.nextNotes, /"linkedIdeaId": "idea-linked"/);
|
|
291
|
+
assert.match(preview.nextNotes, /"linkedFeatureId": "feature-active"/);
|
|
292
|
+
});
|
|
293
|
+
|
|
246
294
|
test("resolveWorkspaceSyncTargetIdeaId supports hosted idea and hosted workspace sources", () => {
|
|
247
295
|
assert.equal(
|
|
248
296
|
resolveWorkspaceSyncTargetIdeaId({
|