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.
@@ -0,0 +1,681 @@
1
+ import fs from "node:fs/promises";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import process from "node:process";
5
+
6
+ import { scanCodexSessions } from "./codex.js";
7
+ import { deriveBaseTitle, normalizeWorkspaceManifest, resolveResumeMetadata } from "./core-plan.js";
8
+
9
+ const DEFAULT_CODEX_SCAN_DAYS = 14;
10
+ const DEFAULT_ORP_STATE_SCAN_DEPTH = 4;
11
+ const SKIP_SCAN_DIRS = new Set([
12
+ ".cache",
13
+ ".git",
14
+ ".next",
15
+ ".turbo",
16
+ ".venv",
17
+ "__pycache__",
18
+ "build",
19
+ "coverage",
20
+ "dist",
21
+ "node_modules",
22
+ "target",
23
+ "tmp",
24
+ "venv",
25
+ ]);
26
+
27
+ function normalizeOptionalString(value) {
28
+ if (value == null) {
29
+ return null;
30
+ }
31
+ const trimmed = String(value).trim();
32
+ return trimmed.length > 0 ? trimmed : null;
33
+ }
34
+
35
+ function normalizePath(value) {
36
+ const normalized = normalizeOptionalString(value);
37
+ return normalized ? path.resolve(normalized) : null;
38
+ }
39
+
40
+ function uniqueStrings(values = []) {
41
+ return [...new Set(values.map((value) => normalizeOptionalString(value)).filter(Boolean))];
42
+ }
43
+
44
+ function isObject(value) {
45
+ return value && typeof value === "object" && !Array.isArray(value);
46
+ }
47
+
48
+ function isWithinRoot(root, candidate) {
49
+ const rootPath = normalizePath(root);
50
+ const candidatePath = normalizePath(candidate);
51
+ if (!rootPath || !candidatePath) {
52
+ return false;
53
+ }
54
+ return candidatePath === rootPath || candidatePath.startsWith(`${rootPath}${path.sep}`);
55
+ }
56
+
57
+ function pathDepth(candidate) {
58
+ return normalizePath(candidate)?.split(path.sep).filter(Boolean).length || 0;
59
+ }
60
+
61
+ function commonAncestor(paths = []) {
62
+ const normalized = paths.map((candidate) => normalizePath(candidate)).filter(Boolean);
63
+ if (normalized.length === 0) {
64
+ return null;
65
+ }
66
+ const split = normalized.map((candidate) => candidate.split(path.sep).filter(Boolean));
67
+ const parts = [];
68
+ for (let index = 0; ; index += 1) {
69
+ const value = split[0]?.[index];
70
+ if (!value || split.some((row) => row[index] !== value)) {
71
+ break;
72
+ }
73
+ parts.push(value);
74
+ }
75
+ return parts.length > 0 ? `${path.sep}${parts.join(path.sep)}` : path.parse(normalized[0]).root;
76
+ }
77
+
78
+ function splitRootList(value) {
79
+ if (Array.isArray(value)) {
80
+ return uniqueStrings(value);
81
+ }
82
+ const normalized = normalizeOptionalString(value);
83
+ return normalized ? uniqueStrings(normalized.split(path.delimiter)) : [];
84
+ }
85
+
86
+ export function inferLocalProjectRoots(manifest, options = {}) {
87
+ const explicitRoots = [
88
+ ...splitRootList(options.localProjectRoots),
89
+ ...splitRootList(options.localProjectRoot),
90
+ ...splitRootList(options.env?.ORP_WORKSPACE_LOCAL_PROJECT_ROOTS ?? process.env.ORP_WORKSPACE_LOCAL_PROJECT_ROOTS),
91
+ ].map((candidate) => path.resolve(candidate));
92
+ if (explicitRoots.length > 0) {
93
+ return uniqueStrings(explicitRoots);
94
+ }
95
+
96
+ const tabPaths = Array.isArray(manifest?.tabs)
97
+ ? manifest.tabs.map((tab) => normalizePath(tab?.path)).filter(Boolean)
98
+ : [];
99
+ if (tabPaths.length === 0) {
100
+ return [];
101
+ }
102
+
103
+ const candidate = commonAncestor(tabPaths.map((entry) => path.dirname(entry)));
104
+ if (candidate && pathDepth(candidate) >= 3) {
105
+ return [candidate];
106
+ }
107
+
108
+ return uniqueStrings(tabPaths.map((entry) => path.dirname(entry)));
109
+ }
110
+
111
+ function newestIso(...values) {
112
+ let newest = 0;
113
+ for (const value of values) {
114
+ const normalized = normalizeOptionalString(value);
115
+ const ms = normalized ? Date.parse(normalized) : 0;
116
+ if (Number.isFinite(ms) && ms > newest) {
117
+ newest = ms;
118
+ }
119
+ }
120
+ return newest > 0 ? new Date(newest).toISOString() : null;
121
+ }
122
+
123
+ function isoFromMs(value) {
124
+ return typeof value === "number" && Number.isFinite(value) && value > 0 ? new Date(value).toISOString() : null;
125
+ }
126
+
127
+ function titleFromPath(projectPath) {
128
+ const normalized = normalizePath(projectPath);
129
+ return normalized ? path.basename(normalized) || normalized : null;
130
+ }
131
+
132
+ function tabKey(tab) {
133
+ const normalizedPath = normalizePath(tab?.path);
134
+ const resume = resolveResumeMetadata(tab || {});
135
+ if (normalizedPath && resume.resumeTool && resume.resumeSessionId) {
136
+ return `${normalizedPath}|${resume.resumeTool}|${resume.resumeSessionId}`;
137
+ }
138
+ return `${normalizedPath}|path-only|${normalizeOptionalString(tab?.title) || ""}`;
139
+ }
140
+
141
+ function stripUndefined(record) {
142
+ return Object.fromEntries(Object.entries(record).filter(([, value]) => value !== undefined && value !== null));
143
+ }
144
+
145
+ function buildResumeFields({ resumeTool, resumeSessionId, resumeCommand }) {
146
+ const tool = normalizeOptionalString(resumeTool);
147
+ const sessionId = normalizeOptionalString(resumeSessionId);
148
+ const command = normalizeOptionalString(resumeCommand) || (tool && sessionId ? `${tool} resume ${sessionId}` : null);
149
+ return {
150
+ resumeCommand: command,
151
+ resumeTool: tool,
152
+ resumeSessionId: sessionId,
153
+ codexSessionId: tool === "codex" ? sessionId : null,
154
+ claudeSessionId: tool === "claude" ? sessionId : null,
155
+ };
156
+ }
157
+
158
+ async function readJson(filePath) {
159
+ try {
160
+ const raw = await fs.readFile(filePath, "utf8");
161
+ const parsed = JSON.parse(raw);
162
+ return isObject(parsed) ? parsed : null;
163
+ } catch {
164
+ return null;
165
+ }
166
+ }
167
+
168
+ async function statIso(filePath) {
169
+ try {
170
+ const stat = await fs.stat(filePath);
171
+ return isoFromMs(stat.mtimeMs);
172
+ } catch {
173
+ return null;
174
+ }
175
+ }
176
+
177
+ async function walkOrpStateFiles(root, options = {}, depth = 0, output = []) {
178
+ const maxDepth = Number.isFinite(options.orpStateScanDepth)
179
+ ? options.orpStateScanDepth
180
+ : DEFAULT_ORP_STATE_SCAN_DEPTH;
181
+ if (depth > maxDepth) {
182
+ return output;
183
+ }
184
+
185
+ const statePath = path.join(root, "orp", "state.json");
186
+ try {
187
+ const stat = await fs.stat(statePath);
188
+ if (stat.isFile()) {
189
+ output.push(statePath);
190
+ }
191
+ } catch {
192
+ // No ORP state in this directory.
193
+ }
194
+
195
+ if (depth >= maxDepth) {
196
+ return output;
197
+ }
198
+
199
+ let entries;
200
+ try {
201
+ entries = await fs.readdir(root, { withFileTypes: true });
202
+ } catch {
203
+ return output;
204
+ }
205
+
206
+ for (const entry of entries) {
207
+ if (!entry.isDirectory() || SKIP_SCAN_DIRS.has(entry.name) || entry.name.startsWith("._")) {
208
+ continue;
209
+ }
210
+ if (entry.name === "orp") {
211
+ continue;
212
+ }
213
+ await walkOrpStateFiles(path.join(root, entry.name), options, depth + 1, output);
214
+ }
215
+ return output;
216
+ }
217
+
218
+ function tabsFromOrpStartupState(state, statePath, options = {}) {
219
+ const startup = isObject(state?.startup) ? state.startup : {};
220
+ const workspace = isObject(startup.workspace) ? startup.workspace : {};
221
+ const requestedWorkspace = normalizeOptionalString(workspace.workspace);
222
+ const selector = normalizeOptionalString(options.workspaceSelector ?? options.ideaId ?? options.workspace);
223
+ if (selector && requestedWorkspace && requestedWorkspace !== selector) {
224
+ return [];
225
+ }
226
+
227
+ const rows = [];
228
+ const resultManifest = isObject(workspace.result?.manifest) ? workspace.result.manifest : null;
229
+ const targetWorkspaceId = normalizeOptionalString(options.workspaceId);
230
+ if (resultManifest) {
231
+ try {
232
+ const manifest = normalizeWorkspaceManifest(resultManifest);
233
+ if (!targetWorkspaceId || !manifest.workspaceId || manifest.workspaceId === targetWorkspaceId) {
234
+ rows.push(
235
+ ...manifest.tabs.map((tab) =>
236
+ stripUndefined({
237
+ ...tab,
238
+ title: normalizeOptionalString(tab.title) || titleFromPath(tab.path),
239
+ lastActivityAt: normalizeOptionalString(tab.lastActivityAt) || normalizeOptionalString(startup.updated_at_utc) || undefined,
240
+ syncSource: "orp-project-startup",
241
+ inventorySourcePath: statePath,
242
+ }),
243
+ ),
244
+ );
245
+ }
246
+ } catch {
247
+ // Ignore malformed historical startup manifests and keep scanning.
248
+ }
249
+ }
250
+
251
+ const resultTab = isObject(workspace.result?.tab) ? workspace.result.tab : null;
252
+ const projectPath = normalizePath(resultTab?.path ?? workspace.path);
253
+ if (!projectPath) {
254
+ return rows;
255
+ }
256
+
257
+ const resume = buildResumeFields({
258
+ resumeCommand: resultTab?.resumeCommand,
259
+ resumeTool: resultTab?.resumeTool ?? (workspace.codex_session_id ? "codex" : null),
260
+ resumeSessionId: resultTab?.resumeSessionId ?? resultTab?.codexSessionId ?? workspace.codex_session_id,
261
+ });
262
+
263
+ rows.push(
264
+ stripUndefined({
265
+ title: normalizeOptionalString(resultTab?.title) || titleFromPath(projectPath),
266
+ path: projectPath,
267
+ remoteUrl: normalizeOptionalString(resultTab?.remoteUrl ?? workspace.remote_url),
268
+ remoteBranch: normalizeOptionalString(resultTab?.remoteBranch ?? workspace.remote_branch),
269
+ bootstrapCommand: normalizeOptionalString(resultTab?.bootstrapCommand),
270
+ ...resume,
271
+ linkedIdeaId: normalizeOptionalString(resultTab?.linkedIdeaId ?? resultTab?.linked_idea_id),
272
+ linkedFeatureId: normalizeOptionalString(resultTab?.linkedFeatureId ?? resultTab?.linked_feature_id),
273
+ plan: isObject(resultTab?.plan) ? resultTab.plan : undefined,
274
+ tasks: Array.isArray(resultTab?.tasks) && resultTab.tasks.length > 0 ? resultTab.tasks : undefined,
275
+ lastActivityAt: normalizeOptionalString(startup.updated_at_utc) || undefined,
276
+ syncSource: "orp-project-startup",
277
+ inventorySourcePath: statePath,
278
+ }),
279
+ );
280
+ return rows;
281
+ }
282
+
283
+ async function collectOrpStartupRows(roots, options = {}) {
284
+ const rows = [];
285
+ for (const root of roots) {
286
+ const stateFiles = await walkOrpStateFiles(root, options);
287
+ for (const statePath of stateFiles) {
288
+ const state = await readJson(statePath);
289
+ if (!state) {
290
+ continue;
291
+ }
292
+ const tabs = tabsFromOrpStartupState(state, statePath, options);
293
+ if (tabs.length === 0) {
294
+ continue;
295
+ }
296
+ const fallbackActivityAt = await statIso(statePath);
297
+ rows.push(
298
+ ...tabs.map((tab) => ({
299
+ ...tab,
300
+ lastActivityAt: tab.lastActivityAt || fallbackActivityAt || undefined,
301
+ })),
302
+ );
303
+ }
304
+ }
305
+ return rows;
306
+ }
307
+
308
+ function resolveClawdadStatePath(options = {}) {
309
+ if (options.clawdadStatePath) {
310
+ return path.resolve(options.clawdadStatePath);
311
+ }
312
+ const env = options.env || process.env;
313
+ const clawdadHome = normalizeOptionalString(options.clawdadHome) || normalizeOptionalString(env.CLAWDAD_HOME);
314
+ return path.join(clawdadHome ? path.resolve(clawdadHome) : path.join(os.homedir(), ".clawdad"), "state.json");
315
+ }
316
+
317
+ function isQuarantinedSession(project, sessionId, session) {
318
+ if (session?.quarantined === true || normalizeOptionalString(session?.quarantined) === "true") {
319
+ return true;
320
+ }
321
+ const quarantined = project?.quarantined_sessions;
322
+ if (Array.isArray(quarantined)) {
323
+ return quarantined.includes(sessionId);
324
+ }
325
+ return Boolean(isObject(quarantined) && quarantined[sessionId]);
326
+ }
327
+
328
+ function latestClawdadSessionIso(project, session) {
329
+ return newestIso(
330
+ session?.last_response,
331
+ session?.last_response_at,
332
+ session?.last_dispatch,
333
+ session?.last_dispatch_at,
334
+ session?.updated_at,
335
+ session?.created_at,
336
+ session?.tracked_at,
337
+ project?.last_response,
338
+ project?.last_dispatch,
339
+ project?.registered_at,
340
+ );
341
+ }
342
+
343
+ function collectClawdadProjectRows(projectPath, project) {
344
+ const sessions = isObject(project?.sessions) ? project.sessions : {};
345
+ const codexSessions = Object.entries(sessions)
346
+ .filter(([sessionId, session]) => {
347
+ if (!isObject(session) || isQuarantinedSession(project, sessionId, session)) {
348
+ return false;
349
+ }
350
+ const provider =
351
+ normalizeOptionalString(session.provider_override)?.toLowerCase() ||
352
+ normalizeOptionalString(session.provider)?.toLowerCase();
353
+ return provider === "codex";
354
+ })
355
+ .sort(([leftId, leftSession], [rightId, rightSession]) => {
356
+ const activeSessionId = normalizeOptionalString(project?.active_session_id);
357
+ if (leftId === activeSessionId && rightId !== activeSessionId) {
358
+ return -1;
359
+ }
360
+ if (rightId === activeSessionId && leftId !== activeSessionId) {
361
+ return 1;
362
+ }
363
+ const rightMs = Date.parse(latestClawdadSessionIso(project, rightSession) || "") || 0;
364
+ const leftMs = Date.parse(latestClawdadSessionIso(project, leftSession) || "") || 0;
365
+ return rightMs - leftMs;
366
+ });
367
+
368
+ if (codexSessions.length > 0) {
369
+ const [sessionId, session] = codexSessions[0];
370
+ return [
371
+ stripUndefined({
372
+ title: normalizeOptionalString(session.slug) || titleFromPath(projectPath),
373
+ path: projectPath,
374
+ ...buildResumeFields({
375
+ resumeTool: "codex",
376
+ resumeSessionId: sessionId,
377
+ }),
378
+ lastActivityAt: latestClawdadSessionIso(project, session) || undefined,
379
+ syncSource: "clawdad",
380
+ }),
381
+ ];
382
+ }
383
+
384
+ return [
385
+ stripUndefined({
386
+ title: titleFromPath(projectPath),
387
+ path: projectPath,
388
+ lastActivityAt: newestIso(project?.last_response, project?.last_dispatch, project?.registered_at) || undefined,
389
+ syncSource: "clawdad",
390
+ }),
391
+ ];
392
+ }
393
+
394
+ async function collectClawdadRows(roots, options = {}) {
395
+ const clawdadStatePath = resolveClawdadStatePath(options);
396
+ const state = await readJson(clawdadStatePath);
397
+ if (!state || !isObject(state.projects)) {
398
+ return [];
399
+ }
400
+ const knownProjectPaths = options.knownProjectPaths instanceof Set ? options.knownProjectPaths : null;
401
+ const includeClawdadOnlyProjects = Boolean(options.includeClawdadOnlyProjects);
402
+ const rows = [];
403
+ for (const [projectPathValue, project] of Object.entries(state.projects)) {
404
+ const projectPath = normalizePath(projectPathValue);
405
+ if (!projectPath || !roots.some((root) => isWithinRoot(root, projectPath))) {
406
+ continue;
407
+ }
408
+ if (!includeClawdadOnlyProjects && knownProjectPaths && !knownProjectPaths.has(projectPath)) {
409
+ continue;
410
+ }
411
+ rows.push(...collectClawdadProjectRows(projectPath, project));
412
+ }
413
+ return rows;
414
+ }
415
+
416
+ function latestSessionMs(session) {
417
+ return Math.max(Number(session?.updatedMs || 0), Number(session?.timestampMs || 0));
418
+ }
419
+
420
+ function collectKnownProjectPaths(manifest, rows = []) {
421
+ return new Set(
422
+ [
423
+ ...(Array.isArray(manifest?.tabs) ? manifest.tabs.map((tab) => normalizePath(tab?.path)) : []),
424
+ ...rows.map((row) => normalizePath(row.path)),
425
+ ].filter(Boolean),
426
+ );
427
+ }
428
+
429
+ function latestCodexSessionRows(sessions, knownPaths, options = {}) {
430
+ const rowsByPath = new Map();
431
+ const includeCodexOnlyProjects = Boolean(options.includeCodexOnlyProjects);
432
+ for (const session of sessions) {
433
+ const cwd = normalizePath(session.cwd);
434
+ if (!cwd) {
435
+ continue;
436
+ }
437
+ const matchedPath =
438
+ [...knownPaths].find((projectPath) => isWithinRoot(projectPath, cwd)) ||
439
+ (includeCodexOnlyProjects ? cwd : null);
440
+ if (!matchedPath) {
441
+ continue;
442
+ }
443
+ const current = rowsByPath.get(matchedPath);
444
+ if (!current || latestSessionMs(session) > latestSessionMs(current)) {
445
+ rowsByPath.set(matchedPath, session);
446
+ }
447
+ }
448
+
449
+ return [...rowsByPath.entries()].map(([projectPath, session]) =>
450
+ stripUndefined({
451
+ title: titleFromPath(projectPath),
452
+ path: projectPath,
453
+ ...buildResumeFields({
454
+ resumeTool: "codex",
455
+ resumeSessionId: session.sessionId,
456
+ }),
457
+ lastActivityAt: newestIso(session.timestamp, isoFromMs(session.updatedMs)) || undefined,
458
+ syncSource: "codex-session-meta",
459
+ }),
460
+ );
461
+ }
462
+
463
+ async function collectCodexRows(manifest, existingRows, roots, options = {}) {
464
+ const sinceDays = Number.isFinite(options.codexScanDays) ? options.codexScanDays : DEFAULT_CODEX_SCAN_DAYS;
465
+ const sinceMs = Number.isFinite(options.codexSinceMs)
466
+ ? options.codexSinceMs
467
+ : sinceDays > 0
468
+ ? Date.now() - sinceDays * 24 * 60 * 60 * 1000
469
+ : 0;
470
+ let sessions = [];
471
+ try {
472
+ sessions = await scanCodexSessions({
473
+ ...options,
474
+ sinceMs,
475
+ });
476
+ } catch {
477
+ return [];
478
+ }
479
+ const rootSessions = sessions.filter((session) => roots.some((root) => isWithinRoot(root, session.cwd)));
480
+ return latestCodexSessionRows(rootSessions, collectKnownProjectPaths(manifest, existingRows), options);
481
+ }
482
+
483
+ function tabSortMs(tab) {
484
+ const value = normalizeOptionalString(tab?.lastActivityAt ?? tab?.last_activity_at_utc);
485
+ const ms = value ? Date.parse(value) : 0;
486
+ return Number.isFinite(ms) ? ms : 0;
487
+ }
488
+
489
+ function mergeTabFields(existing, candidate) {
490
+ const merged = {
491
+ ...existing,
492
+ ...candidate,
493
+ title: normalizeOptionalString(candidate.title) || normalizeOptionalString(existing?.title) || deriveBaseTitle(candidate),
494
+ remoteUrl: normalizeOptionalString(candidate.remoteUrl) || normalizeOptionalString(existing?.remoteUrl) || undefined,
495
+ remoteBranch: normalizeOptionalString(candidate.remoteBranch) || normalizeOptionalString(existing?.remoteBranch) || undefined,
496
+ bootstrapCommand:
497
+ normalizeOptionalString(candidate.bootstrapCommand) || normalizeOptionalString(existing?.bootstrapCommand) || undefined,
498
+ linkedIdeaId:
499
+ normalizeOptionalString(candidate.linkedIdeaId ?? candidate.linked_idea_id) ||
500
+ normalizeOptionalString(existing?.linkedIdeaId ?? existing?.linked_idea_id) ||
501
+ undefined,
502
+ linkedFeatureId:
503
+ normalizeOptionalString(candidate.linkedFeatureId ?? candidate.linked_feature_id) ||
504
+ normalizeOptionalString(existing?.linkedFeatureId ?? existing?.linked_feature_id) ||
505
+ undefined,
506
+ plan:
507
+ candidate.plan && isObject(candidate.plan)
508
+ ? candidate.plan
509
+ : existing?.plan && isObject(existing.plan)
510
+ ? existing.plan
511
+ : undefined,
512
+ tasks:
513
+ Array.isArray(candidate.tasks) && candidate.tasks.length > 0
514
+ ? candidate.tasks
515
+ : Array.isArray(existing?.tasks) && existing.tasks.length > 0
516
+ ? existing.tasks
517
+ : undefined,
518
+ lastActivityAt:
519
+ newestIso(candidate.lastActivityAt, candidate.last_activity_at_utc, existing?.lastActivityAt, existing?.last_activity_at_utc) ||
520
+ undefined,
521
+ syncSource: normalizeOptionalString(candidate.syncSource) || normalizeOptionalString(existing?.syncSource) || undefined,
522
+ };
523
+ const resume = resolveResumeMetadata(merged);
524
+ return stripUndefined({
525
+ ...merged,
526
+ resumeCommand: resume.resumeCommand || undefined,
527
+ resumeTool: resume.resumeTool || undefined,
528
+ resumeSessionId: resume.resumeSessionId || undefined,
529
+ codexSessionId: resume.resumeTool === "codex" ? resume.resumeSessionId || undefined : undefined,
530
+ claudeSessionId: resume.resumeTool === "claude" ? resume.resumeSessionId || undefined : undefined,
531
+ });
532
+ }
533
+
534
+ function dedupeRowsByKey(rows) {
535
+ const byKey = new Map();
536
+ for (const row of rows) {
537
+ const key = tabKey(row);
538
+ const current = byKey.get(key);
539
+ if (!current || tabSortMs(row) >= tabSortMs(current)) {
540
+ byKey.set(key, row);
541
+ }
542
+ }
543
+ return [...byKey.values()];
544
+ }
545
+
546
+ function sourceRank(row) {
547
+ const source = normalizeOptionalString(row?.syncSource);
548
+ if (source === "codex-session-meta") {
549
+ return 40;
550
+ }
551
+ if (source === "orp-project-startup") {
552
+ return 30;
553
+ }
554
+ if (source === "clawdad") {
555
+ return 35;
556
+ }
557
+ return 10;
558
+ }
559
+
560
+ function selectCanonicalInventoryRows(rows) {
561
+ const rowsByPath = new Map();
562
+ for (const row of dedupeRowsByKey(rows)) {
563
+ const projectPath = normalizePath(row.path);
564
+ if (!projectPath) {
565
+ continue;
566
+ }
567
+ if (!rowsByPath.has(projectPath)) {
568
+ rowsByPath.set(projectPath, []);
569
+ }
570
+ rowsByPath.get(projectPath).push(row);
571
+ }
572
+
573
+ const selected = [];
574
+ for (const rowsForPath of rowsByPath.values()) {
575
+ const sorted = [...rowsForPath].sort((left, right) => {
576
+ const rankDelta = sourceRank(right) - sourceRank(left);
577
+ if (rankDelta !== 0) {
578
+ return rankDelta;
579
+ }
580
+ return tabSortMs(right) - tabSortMs(left);
581
+ });
582
+ selected.push(sorted[0]);
583
+ }
584
+
585
+ return selected.sort((left, right) => tabSortMs(right) - tabSortMs(left));
586
+ }
587
+
588
+ function mergeInventoryRowsIntoManifest(manifest, inventoryRows, options = {}) {
589
+ const existingTabs = Array.isArray(manifest?.tabs) ? manifest.tabs : [];
590
+ const inventoryByPath = new Map();
591
+ for (const row of selectCanonicalInventoryRows(inventoryRows)) {
592
+ const projectPath = normalizePath(row.path);
593
+ if (!projectPath) {
594
+ continue;
595
+ }
596
+ if (!inventoryByPath.has(projectPath)) {
597
+ inventoryByPath.set(projectPath, []);
598
+ }
599
+ inventoryByPath.get(projectPath).push(row);
600
+ }
601
+
602
+ const usedInventoryPaths = new Set();
603
+ const seenExistingPaths = new Set();
604
+ const nextTabs = [];
605
+ for (const tab of existingTabs) {
606
+ const projectPath = normalizePath(tab.path);
607
+ if (projectPath && seenExistingPaths.has(projectPath)) {
608
+ if (!inventoryByPath.has(projectPath)) {
609
+ nextTabs.push(tab);
610
+ }
611
+ continue;
612
+ }
613
+ if (projectPath) {
614
+ seenExistingPaths.add(projectPath);
615
+ }
616
+ const candidates = projectPath ? inventoryByPath.get(projectPath) || [] : [];
617
+ if (candidates.length === 0) {
618
+ nextTabs.push(tab);
619
+ continue;
620
+ }
621
+ usedInventoryPaths.add(projectPath);
622
+ for (const candidate of candidates) {
623
+ const matchingExisting =
624
+ existingTabs.find((existing) => tabKey(existing) === tabKey(candidate)) ||
625
+ existingTabs.find((existing) => normalizePath(existing.path) === projectPath) ||
626
+ tab;
627
+ nextTabs.push(mergeTabFields(matchingExisting, candidate));
628
+ }
629
+ }
630
+
631
+ const newRows = [];
632
+ for (const [projectPath, rows] of inventoryByPath.entries()) {
633
+ if (usedInventoryPaths.has(projectPath)) {
634
+ continue;
635
+ }
636
+ newRows.push(...rows.map((row) => mergeTabFields({}, row)));
637
+ }
638
+ newRows.sort((left, right) => tabSortMs(right) - tabSortMs(left));
639
+ nextTabs.push(...newRows);
640
+
641
+ const tabLimit = Number.isInteger(options.maxInventoryTabs) && options.maxInventoryTabs > 0 ? options.maxInventoryTabs : null;
642
+ return {
643
+ ...manifest,
644
+ tabs: tabLimit ? nextTabs.slice(0, tabLimit) : nextTabs,
645
+ };
646
+ }
647
+
648
+ export async function buildLocalProjectInventory(manifest, options = {}) {
649
+ const roots = inferLocalProjectRoots(manifest, options);
650
+ const inventoryOptions = {
651
+ ...options,
652
+ workspaceId: normalizeOptionalString(options.workspaceId) || normalizeOptionalString(manifest?.workspaceId),
653
+ };
654
+ const orpRows = await collectOrpStartupRows(roots, inventoryOptions);
655
+ const knownProjectPaths = collectKnownProjectPaths(manifest, orpRows);
656
+ const clawdadRows = await collectClawdadRows(roots, {
657
+ ...inventoryOptions,
658
+ knownProjectPaths,
659
+ });
660
+ const codexRows = await collectCodexRows(manifest, [...orpRows, ...clawdadRows], roots, options);
661
+ const rows = selectCanonicalInventoryRows([...orpRows, ...clawdadRows, ...codexRows]);
662
+ return {
663
+ contract: {
664
+ source_of_truth: "orp-workspace-ledger",
665
+ reconciliation_sources: ["orp-project-startup", "clawdad", "codex-session-meta"],
666
+ codex_sessions_scope: options.includeCodexOnlyProjects ? "known-and-codex-only-projects" : "known-projects-only",
667
+ },
668
+ roots,
669
+ rowCount: rows.length,
670
+ projectCount: new Set(rows.map((row) => normalizePath(row.path)).filter(Boolean)).size,
671
+ rows,
672
+ };
673
+ }
674
+
675
+ export async function mergeLocalProjectInventoryIntoManifest(manifest, options = {}) {
676
+ const inventory = await buildLocalProjectInventory(manifest, options);
677
+ return {
678
+ manifest: mergeInventoryRowsIntoManifest(manifest, inventory.rows, options),
679
+ inventory,
680
+ };
681
+ }