@openpome/local-gateway 0.16.0-alpha.0

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/dist/index.js ADDED
@@ -0,0 +1,2004 @@
1
+ import { createHash, randomBytes, randomUUID } from "node:crypto";
2
+ import { exec, execFile } from "node:child_process";
3
+ import { existsSync } from "node:fs";
4
+ import { createServer } from "node:http";
5
+ import { mkdir, opendir, readFile, rm, writeFile } from "node:fs/promises";
6
+ import { homedir } from "node:os";
7
+ import { basename, delimiter, dirname, isAbsolute, join, resolve } from "node:path";
8
+ import { promisify } from "node:util";
9
+ import { defaultConfig } from "@openpome/configuration";
10
+ import { createCredentialStore, getJsonCredential, setJsonCredential } from "@openpome/credentials";
11
+ import { groupWorkItemsByType } from "@openpome/work-items";
12
+ import { buildPlanningPrompt } from "@openpome/prompt-engine";
13
+ import { rankWorkspaceCandidates } from "@openpome/workspaces";
14
+ import { createDefaultWorkItemSourceRegistry, createJiraCloudOAuthLogin, exchangeJiraCloudOAuthCode, refreshJiraCloudOAuthToken } from "./connectors/work-item-registry.js";
15
+ const execAsync = promisify(exec);
16
+ const execFileAsync = promisify(execFile);
17
+ const jiraOAuthCredentialAccount = "jira-cloud/oauth";
18
+ const workspaceIndexFileName = "workspace-index.json";
19
+ const workspaceLinksFileName = "workspace-links.json";
20
+ const activeTaskSessionFileName = "active-task-session.json";
21
+ const taskSessionHistoryFileName = "task-session-history.json";
22
+ const workItemSourceRegistry = createDefaultWorkItemSourceRegistry();
23
+ const skippedWorkspaceDirectoryNames = new Set([
24
+ ".git",
25
+ ".next",
26
+ ".pnpm",
27
+ ".turbo",
28
+ ".venv",
29
+ "build",
30
+ "coverage",
31
+ "dist",
32
+ "node_modules",
33
+ "target"
34
+ ]);
35
+ const defaultWorkspaceScanDepth = 4;
36
+ const maxWorkspaceScanRepositories = 200;
37
+ export function getGatewayHealth() {
38
+ return {
39
+ status: "ok",
40
+ version: "0.16.0-alpha.0"
41
+ };
42
+ }
43
+ export async function initOpenPome() {
44
+ const paths = getOpenPomePaths();
45
+ await mkdir(paths.homeDirectory, { recursive: true });
46
+ const existingConfig = await readConfigIfPresent(paths.configFile);
47
+ if (existingConfig) {
48
+ return {
49
+ created: false,
50
+ ...paths
51
+ };
52
+ }
53
+ await writeConfig(paths.configFile, defaultConfig);
54
+ return {
55
+ created: true,
56
+ ...paths
57
+ };
58
+ }
59
+ export async function getConfigPaths() {
60
+ const paths = getOpenPomePaths();
61
+ return {
62
+ ...paths,
63
+ workspaceIndexFile: getWorkspaceIndexFile(paths.homeDirectory),
64
+ workspaceLinksFile: getWorkspaceLinksFile(paths.homeDirectory),
65
+ activeTaskSessionFile: getActiveTaskSessionFile(paths.homeDirectory),
66
+ taskSessionHistoryFile: getTaskSessionHistoryFile(paths.homeDirectory)
67
+ };
68
+ }
69
+ export async function showOpenPomeConfig() {
70
+ const paths = getOpenPomePaths();
71
+ const config = await readConfigIfPresent(paths.configFile);
72
+ return {
73
+ exists: Boolean(config),
74
+ configFile: paths.configFile,
75
+ config: config ?? defaultConfig
76
+ };
77
+ }
78
+ export async function resetOpenPomeConfig() {
79
+ const paths = getOpenPomePaths();
80
+ const resetAt = new Date().toISOString();
81
+ await writeConfig(paths.configFile, defaultConfig);
82
+ return {
83
+ configFile: paths.configFile,
84
+ config: defaultConfig,
85
+ resetAt
86
+ };
87
+ }
88
+ export async function runDoctor(env = process.env) {
89
+ const paths = getOpenPomePaths();
90
+ const config = await readConfigIfPresent(paths.configFile);
91
+ const jiraSource = await createJiraSource(env);
92
+ const authStatus = jiraSource.getAuthStatus();
93
+ const reachability = await jiraSource.checkReachability();
94
+ const credentialStore = createCredentialStore();
95
+ const checks = [
96
+ {
97
+ name: "Local data directory",
98
+ status: "ok",
99
+ detail: paths.homeDirectory
100
+ },
101
+ {
102
+ name: "Configuration",
103
+ status: config ? "ok" : "attention",
104
+ detail: config ? paths.configFile : "Run `pome init` to create local configuration."
105
+ },
106
+ {
107
+ name: "Credential store",
108
+ status: credentialStore.isAvailable() ? "ok" : "attention",
109
+ detail: credentialStore.isAvailable()
110
+ ? `${credentialStore.backend} is available.`
111
+ : `${credentialStore.backend} is not available; OAuth token storage will not work.`
112
+ },
113
+ {
114
+ name: "Work item source",
115
+ status: authStatus.configured ? "ok" : "attention",
116
+ detail: authStatus.detail
117
+ },
118
+ {
119
+ name: "Work item scope",
120
+ status: config?.activeWorkItemScope ? "ok" : "attention",
121
+ detail: config?.activeWorkItemScope
122
+ ? `${config.activeWorkItemScope.displayName} (${config.activeWorkItemScope.kind})`
123
+ : "Run `pome work-item scopes` and `pome work-item scope use <SCOPE_ID>` to select a work item scope."
124
+ },
125
+ {
126
+ name: "Jira reachability",
127
+ status: reachability.status === "reachable" ? "ok" : "attention",
128
+ detail: reachability.detail
129
+ },
130
+ {
131
+ name: "Network mode",
132
+ status: "ok",
133
+ detail: "Supports public internet, VPN, and mixed VPN/non-VPN connectors. Reachability checks arrive with live connector commands."
134
+ },
135
+ {
136
+ name: "Model provider",
137
+ status: "ok",
138
+ detail: "manual-copy"
139
+ }
140
+ ];
141
+ return {
142
+ status: checks.every((check) => check.status === "ok") ? "ok" : "attention",
143
+ checks
144
+ };
145
+ }
146
+ export async function listAssignedWork(env = process.env) {
147
+ const source = await createJiraSource(env);
148
+ const config = await readConfigIfPresent(getOpenPomePaths().configFile);
149
+ const activeScope = getActiveJiraBoardScope(config);
150
+ const items = await source.listAssigned(activeScope);
151
+ return {
152
+ sourceId: source.id,
153
+ sourceDisplayName: source.displayName,
154
+ sourceMode: source.getMode(),
155
+ activeScope,
156
+ groups: groupWorkItemsByType(items)
157
+ };
158
+ }
159
+ export async function showWorkItem(key, env = process.env) {
160
+ const source = await createJiraSource(env);
161
+ return source.getWorkItem(key);
162
+ }
163
+ export async function listWorkItemScopes(env = process.env) {
164
+ const source = await createJiraSource(env);
165
+ const config = await readConfigIfPresent(getOpenPomePaths().configFile);
166
+ return {
167
+ sourceId: source.id,
168
+ sourceDisplayName: source.displayName,
169
+ sourceMode: source.getMode(),
170
+ activeScope: getActiveJiraBoardScope(config),
171
+ scopes: await source.listScopes()
172
+ };
173
+ }
174
+ export async function useWorkItemScope(scopeId, env = process.env) {
175
+ const normalizedScopeId = scopeId.trim();
176
+ if (!normalizedScopeId) {
177
+ throw new Error("Work item scope id is required.");
178
+ }
179
+ const source = await createJiraSource(env);
180
+ const scope = (await source.listScopes()).find((candidate) => candidate.scopeId === normalizedScopeId);
181
+ if (!scope) {
182
+ return undefined;
183
+ }
184
+ const paths = getOpenPomePaths();
185
+ const existingConfig = await readConfigIfPresent(paths.configFile);
186
+ const config = {
187
+ ...defaultConfig,
188
+ ...existingConfig,
189
+ activeWorkItemSource: source.id,
190
+ activeWorkItemScope: scope
191
+ };
192
+ await writeConfig(paths.configFile, config);
193
+ return {
194
+ sourceId: source.id,
195
+ sourceDisplayName: source.displayName,
196
+ activeScope: scope,
197
+ configFile: paths.configFile
198
+ };
199
+ }
200
+ export async function listJiraBoards(env = process.env) {
201
+ const result = await listWorkItemScopes(env);
202
+ return {
203
+ provider: "jira-cloud",
204
+ sourceMode: result.sourceMode,
205
+ activeScope: result.activeScope,
206
+ boards: result.scopes.filter((scope) => scope.providerId === "jira-cloud" && scope.kind === "board")
207
+ };
208
+ }
209
+ export async function useJiraBoard(boardId, env = process.env) {
210
+ const result = await useWorkItemScope(boardId, env);
211
+ if (!result || result.activeScope.providerId !== "jira-cloud" || result.activeScope.kind !== "board") {
212
+ return undefined;
213
+ }
214
+ return {
215
+ provider: "jira-cloud",
216
+ activeScope: result.activeScope,
217
+ configFile: result.configFile
218
+ };
219
+ }
220
+ export async function scanWorkspaces(env = process.env) {
221
+ const paths = getOpenPomePaths();
222
+ const config = await readConfigIfPresent(paths.configFile);
223
+ const scanPaths = getWorkspaceScanPaths(config, env);
224
+ const scannedAt = new Date().toISOString();
225
+ const workspaces = await findGitWorkspaces(scanPaths, scannedAt);
226
+ const index = {
227
+ indexVersion: 1,
228
+ scannedAt,
229
+ scanPaths,
230
+ workspaces
231
+ };
232
+ await mkdir(paths.homeDirectory, { recursive: true });
233
+ await writeFile(getWorkspaceIndexFile(paths.homeDirectory), `${JSON.stringify(index, null, 2)}\n`, "utf8");
234
+ return {
235
+ indexFile: getWorkspaceIndexFile(paths.homeDirectory),
236
+ scannedAt,
237
+ scanPaths,
238
+ workspaces
239
+ };
240
+ }
241
+ export async function listWorkspaces() {
242
+ const paths = getOpenPomePaths();
243
+ const index = await readWorkspaceIndexIfPresent(paths.homeDirectory);
244
+ return {
245
+ indexFile: getWorkspaceIndexFile(paths.homeDirectory),
246
+ scannedAt: index?.scannedAt,
247
+ workspaces: index?.workspaces ?? []
248
+ };
249
+ }
250
+ export async function resolveWorkspaceForWorkItem(key, env = process.env) {
251
+ const workItem = await showWorkItem(key, env);
252
+ if (!workItem) {
253
+ return undefined;
254
+ }
255
+ const paths = getOpenPomePaths();
256
+ const existingIndex = await readWorkspaceIndexIfPresent(paths.homeDirectory);
257
+ const linkIndex = await readWorkspaceLinkIndexIfPresent(paths.homeDirectory);
258
+ const index = existingIndex ?? (await scanWorkspaces(env));
259
+ const candidates = rankWorkspaceCandidates({
260
+ workItemKey: workItem.key,
261
+ workItemTitle: workItem.title,
262
+ labels: workItem.labels,
263
+ components: workItem.components,
264
+ linkedCodeUrls: workItem.links?.filter((link) => link.kind === "code").map((link) => link.url),
265
+ workspaces: index.workspaces,
266
+ learnedLinks: linkIndex?.links
267
+ });
268
+ return {
269
+ workItem,
270
+ indexFile: getWorkspaceIndexFile(paths.homeDirectory),
271
+ candidates
272
+ };
273
+ }
274
+ export async function linkWorkspaceToWorkItem(key, workspacePath, env = process.env) {
275
+ const workItem = await showWorkItem(key, env);
276
+ if (!workItem) {
277
+ return undefined;
278
+ }
279
+ const paths = getOpenPomePaths();
280
+ const now = new Date().toISOString();
281
+ const resolvedWorkspacePath = resolveWorkspacePath(workspacePath, env);
282
+ if (!existsSync(join(resolvedWorkspacePath, ".git"))) {
283
+ throw new Error(`Workspace path is not a Git repository: ${resolvedWorkspacePath}`);
284
+ }
285
+ const workspace = await readGitWorkspace(resolvedWorkspacePath, now);
286
+ const existingIndex = await readWorkspaceIndexIfPresent(paths.homeDirectory);
287
+ const workspaces = upsertWorkspace(existingIndex?.workspaces ?? [], workspace);
288
+ const index = {
289
+ indexVersion: 1,
290
+ scannedAt: existingIndex?.scannedAt ?? now,
291
+ scanPaths: existingIndex?.scanPaths ?? [],
292
+ workspaces
293
+ };
294
+ const existingLinkIndex = await readWorkspaceLinkIndexIfPresent(paths.homeDirectory);
295
+ const link = {
296
+ source: "developer_confirmation",
297
+ workItemPattern: workItem.key.toUpperCase(),
298
+ workspaceId: workspace.id,
299
+ confidence: 0.95,
300
+ lastUsedAt: now
301
+ };
302
+ const linkIndex = {
303
+ indexVersion: 1,
304
+ updatedAt: now,
305
+ links: upsertWorkspaceLink(existingLinkIndex?.links ?? [], link)
306
+ };
307
+ await mkdir(paths.homeDirectory, { recursive: true });
308
+ await writeFile(getWorkspaceIndexFile(paths.homeDirectory), `${JSON.stringify(index, null, 2)}\n`, "utf8");
309
+ await writeFile(getWorkspaceLinksFile(paths.homeDirectory), `${JSON.stringify(linkIndex, null, 2)}\n`, "utf8");
310
+ return {
311
+ workItemKey: workItem.key,
312
+ workspace,
313
+ link,
314
+ indexFile: getWorkspaceIndexFile(paths.homeDirectory),
315
+ linksFile: getWorkspaceLinksFile(paths.homeDirectory)
316
+ };
317
+ }
318
+ export async function startTaskSession(key, env = process.env) {
319
+ const resolution = await resolveWorkspaceForWorkItem(key, env);
320
+ if (!resolution) {
321
+ return undefined;
322
+ }
323
+ const paths = getOpenPomePaths();
324
+ const now = new Date().toISOString();
325
+ const workspaceCandidate = resolution.candidates[0];
326
+ const session = {
327
+ id: `task_${randomUUID()}`,
328
+ workItemKey: resolution.workItem.key,
329
+ status: "planning",
330
+ automationLevel: 1,
331
+ workspaceId: workspaceCandidate?.workspace.id,
332
+ branchName: workspaceCandidate?.workspace.currentBranch,
333
+ createdAt: now,
334
+ updatedAt: now
335
+ };
336
+ const events = createSessionStartEvents(session, resolution.workItem, workspaceCandidate, now);
337
+ await writeActiveTaskSession(paths.homeDirectory, {
338
+ version: 1,
339
+ session,
340
+ workItem: resolution.workItem,
341
+ workspaceCandidate,
342
+ events,
343
+ approvalHistory: []
344
+ });
345
+ return {
346
+ session,
347
+ workItem: resolution.workItem,
348
+ workspaceCandidate,
349
+ sessionFile: getActiveTaskSessionFile(paths.homeDirectory)
350
+ };
351
+ }
352
+ export async function getTaskSessionStatus() {
353
+ const paths = getOpenPomePaths();
354
+ const persisted = await readActiveTaskSessionIfPresent(paths.homeDirectory);
355
+ if (!persisted) {
356
+ return {
357
+ active: false,
358
+ sessionFile: getActiveTaskSessionFile(paths.homeDirectory)
359
+ };
360
+ }
361
+ return {
362
+ active: true,
363
+ sessionFile: getActiveTaskSessionFile(paths.homeDirectory),
364
+ session: persisted.session,
365
+ workItem: persisted.workItem,
366
+ workspaceCandidate: persisted.workspaceCandidate,
367
+ plan: persisted.plan,
368
+ planApproval: persisted.planApproval,
369
+ events: persisted.events ?? [],
370
+ approvalHistory: persisted.approvalHistory ?? []
371
+ };
372
+ }
373
+ export async function getTaskSessionTimeline() {
374
+ const paths = getOpenPomePaths();
375
+ const persisted = await readActiveTaskSessionIfPresent(paths.homeDirectory);
376
+ return {
377
+ active: Boolean(persisted),
378
+ sessionFile: getActiveTaskSessionFile(paths.homeDirectory),
379
+ session: persisted?.session,
380
+ events: persisted?.events ?? []
381
+ };
382
+ }
383
+ export async function getTaskSessionApprovalHistory() {
384
+ const paths = getOpenPomePaths();
385
+ const persisted = await readActiveTaskSessionIfPresent(paths.homeDirectory);
386
+ return {
387
+ active: Boolean(persisted),
388
+ sessionFile: getActiveTaskSessionFile(paths.homeDirectory),
389
+ session: persisted?.session,
390
+ approvals: persisted?.approvalHistory ?? []
391
+ };
392
+ }
393
+ export async function stopTaskSession() {
394
+ const paths = getOpenPomePaths();
395
+ const persisted = await readActiveTaskSessionIfPresent(paths.homeDirectory);
396
+ if (!persisted) {
397
+ return {
398
+ active: false,
399
+ sessionFile: getActiveTaskSessionFile(paths.homeDirectory),
400
+ historyFile: getTaskSessionHistoryFile(paths.homeDirectory),
401
+ message: "No active task session to stop."
402
+ };
403
+ }
404
+ const now = new Date().toISOString();
405
+ const session = {
406
+ ...persisted.session,
407
+ status: "completed",
408
+ updatedAt: now
409
+ };
410
+ const stopped = {
411
+ ...persisted,
412
+ session,
413
+ events: appendSessionEvents(persisted.events, [
414
+ createSessionEvent(session, persisted.workItem.key, "session_status_changed", "Session stopped", now, [
415
+ "The active task session was closed by the developer."
416
+ ], {
417
+ status: session.status
418
+ })
419
+ ])
420
+ };
421
+ await archiveTaskSession(paths.homeDirectory, stopped);
422
+ await removeActiveTaskSession(paths.homeDirectory);
423
+ return {
424
+ active: false,
425
+ sessionFile: getActiveTaskSessionFile(paths.homeDirectory),
426
+ historyFile: getTaskSessionHistoryFile(paths.homeDirectory),
427
+ session,
428
+ message: "Stopped active task session and archived it locally."
429
+ };
430
+ }
431
+ export async function resumeTaskSession(sessionId) {
432
+ const paths = getOpenPomePaths();
433
+ const active = await readActiveTaskSessionIfPresent(paths.homeDirectory);
434
+ if (active) {
435
+ return {
436
+ active: true,
437
+ sessionFile: getActiveTaskSessionFile(paths.homeDirectory),
438
+ historyFile: getTaskSessionHistoryFile(paths.homeDirectory),
439
+ session: active.session,
440
+ message: "Active task session is already available."
441
+ };
442
+ }
443
+ const history = await readTaskSessionHistoryIfPresent(paths.homeDirectory);
444
+ const archived = selectArchivedTaskSession(history?.sessions ?? [], sessionId);
445
+ if (!archived) {
446
+ return {
447
+ active: false,
448
+ sessionFile: getActiveTaskSessionFile(paths.homeDirectory),
449
+ historyFile: getTaskSessionHistoryFile(paths.homeDirectory),
450
+ message: sessionId ? `No archived task session found: ${sessionId}` : "No archived task session is available to resume."
451
+ };
452
+ }
453
+ const now = new Date().toISOString();
454
+ const session = {
455
+ ...archived.session,
456
+ status: archived.session.status === "completed" ? "planning" : archived.session.status,
457
+ updatedAt: now
458
+ };
459
+ const resumed = {
460
+ ...archived,
461
+ session,
462
+ events: appendSessionEvents(archived.events, [
463
+ createSessionEvent(session, archived.workItem.key, "session_status_changed", "Session resumed", now, [
464
+ "The archived task session was restored as the active session."
465
+ ], {
466
+ status: session.status
467
+ })
468
+ ])
469
+ };
470
+ await writeActiveTaskSession(paths.homeDirectory, resumed);
471
+ return {
472
+ active: true,
473
+ sessionFile: getActiveTaskSessionFile(paths.homeDirectory),
474
+ historyFile: getTaskSessionHistoryFile(paths.homeDirectory),
475
+ session,
476
+ message: "Resumed archived task session."
477
+ };
478
+ }
479
+ export async function resetTaskSession() {
480
+ const paths = getOpenPomePaths();
481
+ const persisted = await readActiveTaskSessionIfPresent(paths.homeDirectory);
482
+ if (!persisted) {
483
+ return {
484
+ active: false,
485
+ sessionFile: getActiveTaskSessionFile(paths.homeDirectory),
486
+ historyFile: getTaskSessionHistoryFile(paths.homeDirectory),
487
+ message: "No active task session to reset."
488
+ };
489
+ }
490
+ const now = new Date().toISOString();
491
+ const session = {
492
+ ...persisted.session,
493
+ status: "blocked",
494
+ updatedAt: now
495
+ };
496
+ const reset = {
497
+ ...persisted,
498
+ session,
499
+ events: appendSessionEvents(persisted.events, [
500
+ createSessionEvent(session, persisted.workItem.key, "session_status_changed", "Session reset", now, [
501
+ "The active task session was reset and archived for recovery."
502
+ ], {
503
+ status: session.status
504
+ })
505
+ ])
506
+ };
507
+ await archiveTaskSession(paths.homeDirectory, reset);
508
+ await removeActiveTaskSession(paths.homeDirectory);
509
+ return {
510
+ active: false,
511
+ sessionFile: getActiveTaskSessionFile(paths.homeDirectory),
512
+ historyFile: getTaskSessionHistoryFile(paths.homeDirectory),
513
+ session,
514
+ message: "Reset active task session and archived it locally."
515
+ };
516
+ }
517
+ export async function createTaskSessionPlan() {
518
+ const paths = getOpenPomePaths();
519
+ const persisted = await readActiveTaskSessionIfPresent(paths.homeDirectory);
520
+ if (!persisted) {
521
+ return undefined;
522
+ }
523
+ const prompt = buildPlanningPrompt({
524
+ title: `${persisted.workItem.key} ${persisted.workItem.title}`,
525
+ context: buildPlanningContext(persisted)
526
+ });
527
+ const plan = buildInitialImplementationPlan(persisted.workItem, persisted.workspaceCandidate);
528
+ const now = new Date().toISOString();
529
+ const session = {
530
+ ...persisted.session,
531
+ status: "awaiting_approval",
532
+ updatedAt: now
533
+ };
534
+ const approval = createPlanApproval(persisted, "pending", now, "Developer approval is required before implementation begins.");
535
+ await writeActiveTaskSession(paths.homeDirectory, {
536
+ ...persisted,
537
+ session,
538
+ plan,
539
+ planningPrompt: prompt,
540
+ planApproval: approval,
541
+ approvalHistory: appendApprovalHistory(persisted.approvalHistory, approval),
542
+ events: appendSessionEvents(persisted.events, [
543
+ createSessionEvent(session, persisted.workItem.key, "plan_created", "Implementation plan created", now, [
544
+ `Plan summary: ${plan.summary}`,
545
+ `Commands proposed: ${plan.commandsToRun.join(", ")}`
546
+ ]),
547
+ createSessionEvent(session, persisted.workItem.key, "approval_requested", "Plan approval requested", now, [
548
+ approval.reason,
549
+ ...approval.details
550
+ ], {
551
+ approvalId: approval.id,
552
+ approvalType: approval.type
553
+ })
554
+ ])
555
+ });
556
+ return {
557
+ session,
558
+ workItem: persisted.workItem,
559
+ workspaceCandidate: persisted.workspaceCandidate,
560
+ plan,
561
+ prompt,
562
+ sessionFile: getActiveTaskSessionFile(paths.homeDirectory)
563
+ };
564
+ }
565
+ export async function approveTaskSessionPlan() {
566
+ const paths = getOpenPomePaths();
567
+ const persisted = await readActiveTaskSessionIfPresent(paths.homeDirectory);
568
+ if (!persisted) {
569
+ return undefined;
570
+ }
571
+ if (!persisted.plan) {
572
+ throw new Error("No plan is available to approve. Run `pome plan` first.");
573
+ }
574
+ const now = new Date().toISOString();
575
+ const approval = createPlanApproval(persisted, "approved", now);
576
+ const session = {
577
+ ...persisted.session,
578
+ status: "implementing",
579
+ updatedAt: now
580
+ };
581
+ await writeActiveTaskSession(paths.homeDirectory, {
582
+ ...persisted,
583
+ session,
584
+ planApproval: approval,
585
+ approvalHistory: appendApprovalHistory(persisted.approvalHistory, approval),
586
+ events: appendSessionEvents(persisted.events, [
587
+ createSessionEvent(session, persisted.workItem.key, "approval_approved", "Plan approved", now, [
588
+ approval.reason,
589
+ ...approval.details
590
+ ], {
591
+ approvalId: approval.id,
592
+ approvalType: approval.type
593
+ }),
594
+ createSessionEvent(session, persisted.workItem.key, "session_status_changed", "Session moved to implementing", now, [
595
+ "The plan is approved. Later implementation actions still need their own checkpoints."
596
+ ], {
597
+ status: session.status
598
+ })
599
+ ])
600
+ });
601
+ return {
602
+ session,
603
+ workItem: persisted.workItem,
604
+ approval,
605
+ sessionFile: getActiveTaskSessionFile(paths.homeDirectory),
606
+ nextStep: "Implementation can begin. File edits, commands, branches, pushes, PRs, and work item updates still require explicit checkpoints."
607
+ };
608
+ }
609
+ export async function rejectTaskSessionPlan(reason = "Plan rejected by developer.") {
610
+ const paths = getOpenPomePaths();
611
+ const persisted = await readActiveTaskSessionIfPresent(paths.homeDirectory);
612
+ if (!persisted) {
613
+ return undefined;
614
+ }
615
+ if (!persisted.plan) {
616
+ throw new Error("No plan is available to reject. Run `pome plan` first.");
617
+ }
618
+ const now = new Date().toISOString();
619
+ const approval = createPlanApproval(persisted, "rejected", now, reason);
620
+ const session = {
621
+ ...persisted.session,
622
+ status: "blocked",
623
+ updatedAt: now
624
+ };
625
+ await writeActiveTaskSession(paths.homeDirectory, {
626
+ ...persisted,
627
+ session,
628
+ planApproval: approval,
629
+ approvalHistory: appendApprovalHistory(persisted.approvalHistory, approval),
630
+ events: appendSessionEvents(persisted.events, [
631
+ createSessionEvent(session, persisted.workItem.key, "approval_rejected", "Plan rejected", now, [
632
+ approval.reason,
633
+ ...approval.details
634
+ ], {
635
+ approvalId: approval.id,
636
+ approvalType: approval.type
637
+ }),
638
+ createSessionEvent(session, persisted.workItem.key, "session_status_changed", "Session blocked", now, [
639
+ "The plan needs revision before implementation can continue."
640
+ ], {
641
+ status: session.status
642
+ })
643
+ ])
644
+ });
645
+ return {
646
+ session,
647
+ workItem: persisted.workItem,
648
+ approval,
649
+ sessionFile: getActiveTaskSessionFile(paths.homeDirectory),
650
+ nextStep: "Revise the work item context or workspace link, then run `pome plan` again."
651
+ };
652
+ }
653
+ export async function discoverTestCommands() {
654
+ const paths = getOpenPomePaths();
655
+ const persisted = await readActiveTaskSessionIfPresent(paths.homeDirectory);
656
+ if (!persisted) {
657
+ return {
658
+ active: false,
659
+ sessionFile: getActiveTaskSessionFile(paths.homeDirectory),
660
+ candidates: [],
661
+ nextStep: "Run `pome start <KEY>` first."
662
+ };
663
+ }
664
+ const workspace = persisted.workspaceCandidate?.workspace;
665
+ const discoveredAt = new Date().toISOString();
666
+ const candidates = workspace?.path ? await discoverTestCommandCandidates(workspace.path) : getFallbackTestCommandCandidates();
667
+ await writeActiveTaskSession(paths.homeDirectory, {
668
+ ...persisted,
669
+ testCommandCandidates: candidates,
670
+ events: appendSessionEvents(persisted.events, [
671
+ createSessionEvent(persisted.session, persisted.workItem.key, "approval_requested", "Test command candidates discovered", discoveredAt, [
672
+ `Candidates: ${candidates.map((candidate) => candidate.command).join(", ")}`,
673
+ "Approve a command before running it in a later execution phase."
674
+ ])
675
+ ])
676
+ });
677
+ return {
678
+ active: true,
679
+ sessionFile: getActiveTaskSessionFile(paths.homeDirectory),
680
+ session: persisted.session,
681
+ workspace,
682
+ candidates,
683
+ discoveredAt,
684
+ nextStep: "Review commands, then run `pome approve command [COMMAND]` to record approval evidence."
685
+ };
686
+ }
687
+ export async function approveTestCommand(command) {
688
+ const paths = getOpenPomePaths();
689
+ const persisted = await readActiveTaskSessionIfPresent(paths.homeDirectory);
690
+ if (!persisted) {
691
+ return undefined;
692
+ }
693
+ const candidates = persisted.testCommandCandidates?.length
694
+ ? persisted.testCommandCandidates
695
+ : persisted.workspaceCandidate?.workspace.path
696
+ ? await discoverTestCommandCandidates(persisted.workspaceCandidate.workspace.path)
697
+ : getFallbackTestCommandCandidates();
698
+ const selected = selectTestCommandCandidate(candidates, command);
699
+ if (!selected) {
700
+ throw new Error(command ? `Test command was not discovered: ${command}` : "No test command candidate is available.");
701
+ }
702
+ const now = new Date().toISOString();
703
+ const approval = createCommandApproval(persisted, selected, now);
704
+ const evidence = {
705
+ id: `evidence_${createHash("sha256").update(`${persisted.session.id}:${selected.command}:${now}`).digest("hex").slice(0, 12)}`,
706
+ command: selected.command,
707
+ cwd: selected.cwd,
708
+ approvedAt: now,
709
+ approval
710
+ };
711
+ await writeActiveTaskSession(paths.homeDirectory, {
712
+ ...persisted,
713
+ testCommandCandidates: candidates,
714
+ commandApprovalEvidence: [...(persisted.commandApprovalEvidence ?? []), evidence],
715
+ approvalHistory: appendApprovalHistory(persisted.approvalHistory, approval),
716
+ events: appendSessionEvents(persisted.events, [
717
+ createSessionEvent(persisted.session, persisted.workItem.key, "approval_approved", "Command approved", now, [
718
+ `Command: ${selected.command}`,
719
+ selected.cwd ? `Working directory: ${selected.cwd}` : "Working directory: unresolved",
720
+ "This records approval evidence only; command execution is a later explicit step."
721
+ ], {
722
+ approvalId: approval.id,
723
+ approvalType: approval.type,
724
+ command: selected.command
725
+ })
726
+ ])
727
+ });
728
+ return evidence;
729
+ }
730
+ export async function getTestCommandHistory() {
731
+ const paths = getOpenPomePaths();
732
+ const persisted = await readActiveTaskSessionIfPresent(paths.homeDirectory);
733
+ return {
734
+ active: Boolean(persisted),
735
+ sessionFile: getActiveTaskSessionFile(paths.homeDirectory),
736
+ session: persisted?.session,
737
+ evidence: persisted?.commandApprovalEvidence ?? [],
738
+ runs: persisted?.testRunEvidence ?? []
739
+ };
740
+ }
741
+ export async function runApprovedTestCommand(command) {
742
+ const paths = getOpenPomePaths();
743
+ const persisted = await readActiveTaskSessionIfPresent(paths.homeDirectory);
744
+ if (!persisted) {
745
+ return undefined;
746
+ }
747
+ const approvalEvidence = selectCommandApprovalEvidence(persisted.commandApprovalEvidence ?? [], command);
748
+ if (!approvalEvidence) {
749
+ throw new Error("No approved command evidence found. Run `pome test discover` and `pome approve command [COMMAND]` first.");
750
+ }
751
+ const startedAt = new Date().toISOString();
752
+ const result = await executeApprovedCommand(approvalEvidence.command, approvalEvidence.cwd);
753
+ const finishedAt = new Date().toISOString();
754
+ const run = {
755
+ id: `testrun_${createHash("sha256").update(`${persisted.session.id}:${approvalEvidence.command}:${startedAt}`).digest("hex").slice(0, 12)}`,
756
+ command: approvalEvidence.command,
757
+ cwd: approvalEvidence.cwd,
758
+ startedAt,
759
+ finishedAt,
760
+ exitCode: result.exitCode,
761
+ status: result.exitCode === 0 ? "passed" : "failed",
762
+ stdoutSummary: summarizeCommandOutput(result.stdout),
763
+ stderrSummary: summarizeCommandOutput(result.stderr),
764
+ approvalId: approvalEvidence.approval.id
765
+ };
766
+ await writeActiveTaskSession(paths.homeDirectory, {
767
+ ...persisted,
768
+ testRunEvidence: [...(persisted.testRunEvidence ?? []), run],
769
+ events: appendSessionEvents(persisted.events, [
770
+ createSessionEvent(persisted.session, persisted.workItem.key, "session_status_changed", "Approved test command completed", finishedAt, [
771
+ `Command: ${run.command}`,
772
+ `Exit code: ${run.exitCode}`,
773
+ `Status: ${run.status}`
774
+ ], {
775
+ command: run.command,
776
+ exitCode: String(run.exitCode),
777
+ approvalId: run.approvalId
778
+ })
779
+ ])
780
+ });
781
+ return run;
782
+ }
783
+ export async function createManualCopyAIContext() {
784
+ const paths = getOpenPomePaths();
785
+ const persisted = await readActiveTaskSessionIfPresent(paths.homeDirectory);
786
+ if (!persisted) {
787
+ return {
788
+ active: false,
789
+ sessionFile: getActiveTaskSessionFile(paths.homeDirectory)
790
+ };
791
+ }
792
+ const createdAt = new Date().toISOString();
793
+ const context = {
794
+ createdAt,
795
+ provider: "manual-copy",
796
+ includesSourceCode: false,
797
+ includesFullDiff: false,
798
+ text: buildManualCopyAIContextText(persisted, createdAt)
799
+ };
800
+ await writeActiveTaskSession(paths.homeDirectory, {
801
+ ...persisted,
802
+ aiContext: context,
803
+ events: appendSessionEvents(persisted.events, [
804
+ createSessionEvent(persisted.session, persisted.workItem.key, "session_status_changed", "Manual-copy AI context prepared", createdAt, [
805
+ "Context excludes source code, secrets, and full diffs.",
806
+ "Developer must review before copying into an external AI provider."
807
+ ])
808
+ ])
809
+ });
810
+ return {
811
+ active: true,
812
+ sessionFile: getActiveTaskSessionFile(paths.homeDirectory),
813
+ session: persisted.session,
814
+ context
815
+ };
816
+ }
817
+ export async function createManualCopyAIPrompt() {
818
+ const paths = getOpenPomePaths();
819
+ const persisted = await readActiveTaskSessionIfPresent(paths.homeDirectory);
820
+ if (!persisted) {
821
+ return {
822
+ active: false,
823
+ sessionFile: getActiveTaskSessionFile(paths.homeDirectory)
824
+ };
825
+ }
826
+ const createdAt = new Date().toISOString();
827
+ const context = buildManualCopyAIContextText(persisted, createdAt);
828
+ const prompt = [
829
+ "You are helping with an OpenPome task session.",
830
+ "Use the work item, workspace, plan, approval, diff summary, and validation evidence below.",
831
+ "Do not assume access to full source code unless the developer explicitly provides it.",
832
+ "Return a concise implementation approach, risks, and the next safest command or file inspection to perform.",
833
+ "",
834
+ context
835
+ ].join("\n");
836
+ await writeActiveTaskSession(paths.homeDirectory, {
837
+ ...persisted,
838
+ aiPrompt: prompt,
839
+ events: appendSessionEvents(persisted.events, [
840
+ createSessionEvent(persisted.session, persisted.workItem.key, "session_status_changed", "Manual-copy AI prompt prepared", createdAt, [
841
+ "Prompt excludes source code, secrets, and full diffs.",
842
+ "Developer must review before copying into an external AI provider."
843
+ ])
844
+ ])
845
+ });
846
+ return {
847
+ active: true,
848
+ sessionFile: getActiveTaskSessionFile(paths.homeDirectory),
849
+ session: persisted.session,
850
+ prompt
851
+ };
852
+ }
853
+ export async function getDiffSummary() {
854
+ const paths = getOpenPomePaths();
855
+ const persisted = await readActiveTaskSessionIfPresent(paths.homeDirectory);
856
+ if (!persisted) {
857
+ return {
858
+ active: false,
859
+ sessionFile: getActiveTaskSessionFile(paths.homeDirectory)
860
+ };
861
+ }
862
+ const workspace = persisted.workspaceCandidate?.workspace;
863
+ if (!workspace?.path) {
864
+ throw new Error("No workspace path is available for the active task session.");
865
+ }
866
+ const createdAt = new Date().toISOString();
867
+ const summary = await buildDiffSummary(workspace.path, createdAt);
868
+ await writeActiveTaskSession(paths.homeDirectory, {
869
+ ...persisted,
870
+ diffSummary: summary,
871
+ events: appendSessionEvents(persisted.events, [
872
+ createSessionEvent(persisted.session, persisted.workItem.key, "session_status_changed", "Diff summary captured", createdAt, [
873
+ `Files changed: ${summary.files.length}`,
874
+ "Summary excludes full diff contents."
875
+ ])
876
+ ])
877
+ });
878
+ return {
879
+ active: true,
880
+ sessionFile: getActiveTaskSessionFile(paths.homeDirectory),
881
+ session: persisted.session,
882
+ summary
883
+ };
884
+ }
885
+ export async function getGitHubAuthStatus() {
886
+ try {
887
+ await execFileAsync("gh", ["--version"]);
888
+ }
889
+ catch {
890
+ return {
891
+ provider: "github",
892
+ cliAvailable: false,
893
+ authenticated: false,
894
+ detail: "GitHub CLI is not installed or is not on PATH."
895
+ };
896
+ }
897
+ try {
898
+ await execFileAsync("gh", ["auth", "status", "-h", "github.com"]);
899
+ return {
900
+ provider: "github",
901
+ cliAvailable: true,
902
+ authenticated: true,
903
+ detail: "GitHub CLI is authenticated for github.com."
904
+ };
905
+ }
906
+ catch (error) {
907
+ return {
908
+ provider: "github",
909
+ cliAvailable: true,
910
+ authenticated: false,
911
+ detail: summarizeExecError(error) || "GitHub CLI is installed but not authenticated for github.com."
912
+ };
913
+ }
914
+ }
915
+ export async function createPullRequestExternalGuard() {
916
+ return createExternalActionGuard("create_pr");
917
+ }
918
+ export async function postWorkItemUpdateExternalGuard() {
919
+ return createExternalActionGuard("update_work_item");
920
+ }
921
+ export async function createPullRequestDraft() {
922
+ const paths = getOpenPomePaths();
923
+ const persisted = await readActiveTaskSessionIfPresent(paths.homeDirectory);
924
+ if (!persisted) {
925
+ return {
926
+ active: false,
927
+ sessionFile: getActiveTaskSessionFile(paths.homeDirectory)
928
+ };
929
+ }
930
+ const now = new Date().toISOString();
931
+ const draft = buildPullRequestDraft(persisted, now);
932
+ await writeActiveTaskSession(paths.homeDirectory, {
933
+ ...persisted,
934
+ prDraft: draft,
935
+ events: appendSessionEvents(persisted.events, [
936
+ createSessionEvent(persisted.session, persisted.workItem.key, "session_status_changed", "PR draft prepared", now, [
937
+ `Title: ${draft.title}`,
938
+ `Head branch: ${draft.headBranch}`,
939
+ `Base branch: ${draft.baseBranch}`
940
+ ])
941
+ ])
942
+ });
943
+ return {
944
+ active: true,
945
+ sessionFile: getActiveTaskSessionFile(paths.homeDirectory),
946
+ session: persisted.session,
947
+ draft
948
+ };
949
+ }
950
+ export async function createWorkItemUpdateDraft() {
951
+ const paths = getOpenPomePaths();
952
+ const persisted = await readActiveTaskSessionIfPresent(paths.homeDirectory);
953
+ if (!persisted) {
954
+ return {
955
+ active: false,
956
+ sessionFile: getActiveTaskSessionFile(paths.homeDirectory)
957
+ };
958
+ }
959
+ const now = new Date().toISOString();
960
+ const draft = buildWorkItemUpdateDraft(persisted, now);
961
+ await writeActiveTaskSession(paths.homeDirectory, {
962
+ ...persisted,
963
+ workItemUpdateDraft: draft,
964
+ events: appendSessionEvents(persisted.events, [
965
+ createSessionEvent(persisted.session, persisted.workItem.key, "session_status_changed", "Work item update draft prepared", now, [
966
+ `Work item: ${persisted.workItem.key}`,
967
+ "Draft is local only and has not been posted."
968
+ ])
969
+ ])
970
+ });
971
+ return {
972
+ active: true,
973
+ sessionFile: getActiveTaskSessionFile(paths.homeDirectory),
974
+ session: persisted.session,
975
+ workItem: persisted.workItem,
976
+ draft
977
+ };
978
+ }
979
+ export async function getJiraAuthStatus(env = process.env) {
980
+ const source = await createJiraSource(env);
981
+ const status = source.getAuthStatus();
982
+ return {
983
+ provider: "jira-cloud",
984
+ ...status
985
+ };
986
+ }
987
+ export function createJiraOAuthLogin(env = process.env) {
988
+ const clientId = env["OPENPOME_JIRA_OAUTH_CLIENT_ID"];
989
+ const redirectUri = env["OPENPOME_JIRA_OAUTH_REDIRECT_URI"] ?? "http://127.0.0.1:48731/auth/jira/callback";
990
+ if (!clientId) {
991
+ throw new Error("OPENPOME_JIRA_OAUTH_CLIENT_ID is required for Jira OAuth login.");
992
+ }
993
+ const login = createJiraCloudOAuthLogin({
994
+ clientId,
995
+ redirectUri,
996
+ state: randomBytes(24).toString("hex")
997
+ });
998
+ return {
999
+ provider: "jira-cloud",
1000
+ ...login,
1001
+ nextStep: "Open the authorization URL in a browser, approve access, then run `pome auth jira callback <CODE>` if you are using manual mode."
1002
+ };
1003
+ }
1004
+ export async function completeJiraOAuthCode(code, env = process.env) {
1005
+ const clientId = env["OPENPOME_JIRA_OAUTH_CLIENT_ID"];
1006
+ const clientSecret = env["OPENPOME_JIRA_OAUTH_CLIENT_SECRET"];
1007
+ const redirectUri = env["OPENPOME_JIRA_OAUTH_REDIRECT_URI"] ?? "http://127.0.0.1:48731/auth/jira/callback";
1008
+ if (!clientId || !clientSecret) {
1009
+ throw new Error("OPENPOME_JIRA_OAUTH_CLIENT_ID and OPENPOME_JIRA_OAUTH_CLIENT_SECRET are required to complete Jira OAuth.");
1010
+ }
1011
+ const tokenSet = await exchangeJiraCloudOAuthCode({
1012
+ code,
1013
+ clientId,
1014
+ clientSecret,
1015
+ redirectUri
1016
+ });
1017
+ if (!tokenSet.cloudId) {
1018
+ throw new Error("Jira OAuth completed, but no accessible Jira site was returned.");
1019
+ }
1020
+ const store = createCredentialStore();
1021
+ if (!store.isAvailable()) {
1022
+ throw new Error(`Credential store is unavailable: ${store.backend}`);
1023
+ }
1024
+ await setJsonCredential(store, jiraOAuthCredentialAccount, tokenSet);
1025
+ return {
1026
+ provider: "jira-cloud",
1027
+ stored: true,
1028
+ mode: "oauth-3lo",
1029
+ cloudId: tokenSet.cloudId,
1030
+ siteUrl: tokenSet.siteUrl,
1031
+ detail: "Jira OAuth token stored in OS keychain."
1032
+ };
1033
+ }
1034
+ export async function listenForJiraOAuthCallback(env = process.env) {
1035
+ const login = createJiraOAuthLogin(env);
1036
+ const redirectUri = new URL(login.redirectUri);
1037
+ if (redirectUri.hostname !== "127.0.0.1" && redirectUri.hostname !== "localhost") {
1038
+ throw new Error("OAuth callback listener only supports localhost redirect URIs.");
1039
+ }
1040
+ const port = Number(redirectUri.port || "80");
1041
+ const pathname = redirectUri.pathname;
1042
+ console.log("Open this URL in your browser:");
1043
+ console.log(login.authorizationUrl);
1044
+ console.log("");
1045
+ console.log(`Waiting for Jira OAuth callback on ${login.redirectUri}`);
1046
+ return new Promise((resolve, reject) => {
1047
+ const timeout = setTimeout(() => {
1048
+ server.close();
1049
+ reject(new Error("Timed out waiting for Jira OAuth callback."));
1050
+ }, 5 * 60 * 1000);
1051
+ const server = createServer((request, response) => {
1052
+ void (async () => {
1053
+ try {
1054
+ const requestUrl = new URL(request.url ?? "/", login.redirectUri);
1055
+ if (requestUrl.pathname !== pathname) {
1056
+ response.writeHead(404);
1057
+ response.end("Not found");
1058
+ return;
1059
+ }
1060
+ const state = requestUrl.searchParams.get("state");
1061
+ const code = requestUrl.searchParams.get("code");
1062
+ const error = requestUrl.searchParams.get("error");
1063
+ if (error) {
1064
+ throw new Error(`Jira OAuth failed: ${error}`);
1065
+ }
1066
+ if (state !== login.state) {
1067
+ throw new Error("Jira OAuth state mismatch.");
1068
+ }
1069
+ if (!code) {
1070
+ throw new Error("Jira OAuth callback did not include a code.");
1071
+ }
1072
+ const completion = await completeJiraOAuthCode(code, env);
1073
+ response.writeHead(200, { "Content-Type": "text/plain" });
1074
+ response.end("OpenPome Jira login complete. You can close this browser tab.");
1075
+ clearTimeout(timeout);
1076
+ server.close();
1077
+ resolve(completion);
1078
+ }
1079
+ catch (error) {
1080
+ response.writeHead(400, { "Content-Type": "text/plain" });
1081
+ response.end(error instanceof Error ? error.message : String(error));
1082
+ clearTimeout(timeout);
1083
+ server.close();
1084
+ reject(error);
1085
+ }
1086
+ })();
1087
+ });
1088
+ server.listen(port, "127.0.0.1");
1089
+ });
1090
+ }
1091
+ async function createJiraSource(env) {
1092
+ const paths = getOpenPomePaths();
1093
+ const localConfig = await readConfigIfPresent(paths.configFile);
1094
+ const selectedBoardScope = getActiveJiraBoardScope(localConfig);
1095
+ const storedOAuth = await refreshStoredJiraOAuthIfNeeded(await readStoredJiraOAuth(), env);
1096
+ return workItemSourceRegistry.getActiveSource(env, {
1097
+ activeScope: selectedBoardScope,
1098
+ connectorCredentials: storedOAuth ? { [jiraOAuthCredentialAccount]: storedOAuth } : undefined
1099
+ });
1100
+ }
1101
+ function getActiveJiraBoardScope(config) {
1102
+ if (config?.activeWorkItemScope?.providerId !== "jira-cloud" || config.activeWorkItemScope.kind !== "board") {
1103
+ return undefined;
1104
+ }
1105
+ return config.activeWorkItemScope;
1106
+ }
1107
+ async function readStoredJiraOAuth() {
1108
+ const store = createCredentialStore();
1109
+ if (!store.isAvailable()) {
1110
+ return undefined;
1111
+ }
1112
+ return getJsonCredential(store, jiraOAuthCredentialAccount);
1113
+ }
1114
+ async function refreshStoredJiraOAuthIfNeeded(tokenSet, env) {
1115
+ if (!tokenSet || !shouldRefreshOAuthToken(tokenSet)) {
1116
+ return tokenSet;
1117
+ }
1118
+ const clientId = env["OPENPOME_JIRA_OAUTH_CLIENT_ID"];
1119
+ const clientSecret = env["OPENPOME_JIRA_OAUTH_CLIENT_SECRET"];
1120
+ if (!tokenSet.refreshToken || !clientId || !clientSecret) {
1121
+ return tokenSet;
1122
+ }
1123
+ const refreshed = await refreshJiraCloudOAuthToken({
1124
+ refreshToken: tokenSet.refreshToken,
1125
+ clientId,
1126
+ clientSecret
1127
+ });
1128
+ const merged = {
1129
+ ...refreshed,
1130
+ cloudId: refreshed.cloudId ?? tokenSet.cloudId,
1131
+ siteUrl: refreshed.siteUrl ?? tokenSet.siteUrl
1132
+ };
1133
+ const store = createCredentialStore();
1134
+ if (store.isAvailable()) {
1135
+ await setJsonCredential(store, jiraOAuthCredentialAccount, merged);
1136
+ }
1137
+ return merged;
1138
+ }
1139
+ function shouldRefreshOAuthToken(tokenSet, now = new Date()) {
1140
+ if (!tokenSet.expiresAt) {
1141
+ return false;
1142
+ }
1143
+ const refreshWindowMs = 5 * 60 * 1000;
1144
+ return new Date(tokenSet.expiresAt).getTime() - now.getTime() <= refreshWindowMs;
1145
+ }
1146
+ function getOpenPomePaths() {
1147
+ const homeDirectory = process.env["OPENPOME_HOME"] ?? join(homedir(), ".openpome");
1148
+ return {
1149
+ homeDirectory,
1150
+ configFile: join(homeDirectory, "config.json")
1151
+ };
1152
+ }
1153
+ async function readConfigIfPresent(configFile) {
1154
+ try {
1155
+ const content = await readFile(configFile, "utf8");
1156
+ return JSON.parse(content);
1157
+ }
1158
+ catch (error) {
1159
+ if (error instanceof Error && "code" in error && error.code === "ENOENT") {
1160
+ return undefined;
1161
+ }
1162
+ throw error;
1163
+ }
1164
+ }
1165
+ async function writeConfig(configFile, config) {
1166
+ await mkdir(dirname(configFile), { recursive: true });
1167
+ await writeFile(configFile, `${JSON.stringify(config, null, 2)}\n`, "utf8");
1168
+ }
1169
+ function getWorkspaceIndexFile(homeDirectory) {
1170
+ return join(homeDirectory, workspaceIndexFileName);
1171
+ }
1172
+ function getWorkspaceLinksFile(homeDirectory) {
1173
+ return join(homeDirectory, workspaceLinksFileName);
1174
+ }
1175
+ function getActiveTaskSessionFile(homeDirectory) {
1176
+ return join(homeDirectory, activeTaskSessionFileName);
1177
+ }
1178
+ function getTaskSessionHistoryFile(homeDirectory) {
1179
+ return join(homeDirectory, taskSessionHistoryFileName);
1180
+ }
1181
+ function getWorkspaceScanPaths(config, env) {
1182
+ const envScanPaths = env["OPENPOME_WORKSPACE_SCAN_PATHS"]
1183
+ ?.split(delimiter)
1184
+ .map((path) => path.trim())
1185
+ .filter(Boolean);
1186
+ const configuredPaths = config?.workspaceScanPaths.filter(Boolean) ?? [];
1187
+ const scanPaths = envScanPaths?.length ? envScanPaths : configuredPaths;
1188
+ if (scanPaths.length > 0) {
1189
+ return uniqueResolvedPaths(scanPaths);
1190
+ }
1191
+ return uniqueResolvedPaths([env["INIT_CWD"] ?? process.cwd()]);
1192
+ }
1193
+ async function findGitWorkspaces(scanPaths, scannedAt) {
1194
+ const workspaces = [];
1195
+ const seenPaths = new Set();
1196
+ for (const scanPath of scanPaths) {
1197
+ await collectGitWorkspaces(resolve(scanPath), 0, scannedAt, seenPaths, workspaces);
1198
+ if (workspaces.length >= maxWorkspaceScanRepositories) {
1199
+ break;
1200
+ }
1201
+ }
1202
+ return workspaces.sort((left, right) => (left.path ?? left.name).localeCompare(right.path ?? right.name));
1203
+ }
1204
+ async function collectGitWorkspaces(directory, depth, scannedAt, seenPaths, workspaces) {
1205
+ if (workspaces.length >= maxWorkspaceScanRepositories || depth > defaultWorkspaceScanDepth || !existsSync(directory)) {
1206
+ return;
1207
+ }
1208
+ if (seenPaths.has(directory)) {
1209
+ return;
1210
+ }
1211
+ seenPaths.add(directory);
1212
+ if (existsSync(join(directory, ".git"))) {
1213
+ workspaces.push(await readGitWorkspace(directory, scannedAt));
1214
+ return;
1215
+ }
1216
+ let entries;
1217
+ try {
1218
+ const dir = await opendir(directory);
1219
+ entries = dir;
1220
+ }
1221
+ catch (error) {
1222
+ if (error instanceof Error && "code" in error && (error.code === "ENOENT" || error.code === "EACCES")) {
1223
+ return;
1224
+ }
1225
+ throw error;
1226
+ }
1227
+ for await (const entry of entries) {
1228
+ if (!entry.isDirectory() || skippedWorkspaceDirectoryNames.has(entry.name)) {
1229
+ continue;
1230
+ }
1231
+ await collectGitWorkspaces(join(directory, entry.name), depth + 1, scannedAt, seenPaths, workspaces);
1232
+ if (workspaces.length >= maxWorkspaceScanRepositories) {
1233
+ return;
1234
+ }
1235
+ }
1236
+ }
1237
+ async function readGitWorkspace(directory, scannedAt) {
1238
+ const gitDirectory = await resolveGitDirectory(directory);
1239
+ const [currentBranch, remoteUrls, packageNames, readmeKeywords, codeownersKeywords, recentBranches, recentCommitRefs] = await Promise.all([
1240
+ readCurrentGitBranch(gitDirectory),
1241
+ readGitRemoteUrls(gitDirectory),
1242
+ readWorkspacePackageNames(directory),
1243
+ readWorkspaceReadmeKeywords(directory),
1244
+ readWorkspaceCodeownersKeywords(directory),
1245
+ readRecentGitBranches(gitDirectory),
1246
+ readRecentGitCommitRefs(gitDirectory)
1247
+ ]);
1248
+ return {
1249
+ id: createWorkspaceId(directory),
1250
+ name: basename(directory),
1251
+ path: directory,
1252
+ remoteUrls,
1253
+ currentBranch,
1254
+ packageNames,
1255
+ readmeKeywords,
1256
+ codeownersKeywords,
1257
+ recentBranches,
1258
+ recentCommitRefs,
1259
+ lastScannedAt: scannedAt
1260
+ };
1261
+ }
1262
+ async function resolveGitDirectory(directory) {
1263
+ const dotGitPath = join(directory, ".git");
1264
+ try {
1265
+ const content = await readFile(dotGitPath, "utf8");
1266
+ const gitDir = content.match(/^gitdir:\s*(.+)$/u)?.[1]?.trim();
1267
+ if (gitDir) {
1268
+ return resolve(directory, gitDir);
1269
+ }
1270
+ }
1271
+ catch {
1272
+ return dotGitPath;
1273
+ }
1274
+ return dotGitPath;
1275
+ }
1276
+ async function readCurrentGitBranch(gitDirectory) {
1277
+ try {
1278
+ const head = (await readFile(join(gitDirectory, "HEAD"), "utf8")).trim();
1279
+ const branchPrefix = "ref: refs/heads/";
1280
+ return head.startsWith(branchPrefix) ? head.slice(branchPrefix.length) : undefined;
1281
+ }
1282
+ catch {
1283
+ return undefined;
1284
+ }
1285
+ }
1286
+ async function readGitRemoteUrls(gitDirectory) {
1287
+ try {
1288
+ const config = await readFile(join(gitDirectory, "config"), "utf8");
1289
+ const urls = [...config.matchAll(/^\s*url\s*=\s*(.+)$/gmu)].map((match) => match[1]?.trim()).filter(Boolean);
1290
+ return [...new Set(urls)];
1291
+ }
1292
+ catch {
1293
+ return [];
1294
+ }
1295
+ }
1296
+ async function readWorkspacePackageNames(directory) {
1297
+ const packageFiles = [
1298
+ join(directory, "package.json"),
1299
+ join(directory, "packages"),
1300
+ join(directory, "apps"),
1301
+ join(directory, "services")
1302
+ ];
1303
+ const names = [];
1304
+ const rootName = await readPackageName(join(directory, "package.json"));
1305
+ if (rootName) {
1306
+ names.push(rootName);
1307
+ }
1308
+ for (const childDirectory of packageFiles.slice(1)) {
1309
+ names.push(...(await readPackageNamesFromChildren(childDirectory)));
1310
+ }
1311
+ return uniqueStrings(names).slice(0, 40);
1312
+ }
1313
+ async function readPackageNamesFromChildren(directory) {
1314
+ try {
1315
+ const dir = await opendir(directory);
1316
+ const names = [];
1317
+ for await (const entry of dir) {
1318
+ if (!entry.isDirectory() || skippedWorkspaceDirectoryNames.has(entry.name)) {
1319
+ continue;
1320
+ }
1321
+ const packageName = await readPackageName(join(directory, entry.name, "package.json"));
1322
+ if (packageName) {
1323
+ names.push(packageName);
1324
+ }
1325
+ }
1326
+ return names;
1327
+ }
1328
+ catch (error) {
1329
+ if (error instanceof Error && "code" in error && (error.code === "ENOENT" || error.code === "EACCES")) {
1330
+ return [];
1331
+ }
1332
+ throw error;
1333
+ }
1334
+ }
1335
+ async function readPackageName(packageFile) {
1336
+ try {
1337
+ const content = await readFile(packageFile, "utf8");
1338
+ const packageJson = JSON.parse(content);
1339
+ return typeof packageJson.name === "string" ? packageJson.name : undefined;
1340
+ }
1341
+ catch (error) {
1342
+ if (error instanceof Error && "code" in error && error.code === "ENOENT") {
1343
+ return undefined;
1344
+ }
1345
+ if (error instanceof SyntaxError) {
1346
+ return undefined;
1347
+ }
1348
+ throw error;
1349
+ }
1350
+ }
1351
+ async function readPackageScripts(packageFile) {
1352
+ try {
1353
+ const content = await readFile(packageFile, "utf8");
1354
+ const packageJson = JSON.parse(content);
1355
+ if (!packageJson.scripts || typeof packageJson.scripts !== "object") {
1356
+ return {};
1357
+ }
1358
+ return Object.fromEntries(Object.entries(packageJson.scripts).filter((entry) => typeof entry[1] === "string"));
1359
+ }
1360
+ catch (error) {
1361
+ if (error instanceof Error && "code" in error && error.code === "ENOENT") {
1362
+ return {};
1363
+ }
1364
+ if (error instanceof SyntaxError) {
1365
+ return {};
1366
+ }
1367
+ throw error;
1368
+ }
1369
+ }
1370
+ async function readWorkspaceReadmeKeywords(directory) {
1371
+ const readmeFiles = ["README.md", "README.txt", "readme.md"];
1372
+ const keywords = [];
1373
+ for (const fileName of readmeFiles) {
1374
+ const content = await readOptionalTextFile(join(directory, fileName), 16_000);
1375
+ if (content) {
1376
+ keywords.push(...tokenizeForWorkspaceMetadata(content));
1377
+ break;
1378
+ }
1379
+ }
1380
+ return uniqueStrings(keywords).slice(0, 80);
1381
+ }
1382
+ async function readWorkspaceCodeownersKeywords(directory) {
1383
+ const codeownersFiles = [join(directory, ".github", "CODEOWNERS"), join(directory, "CODEOWNERS"), join(directory, "docs", "CODEOWNERS")];
1384
+ const keywords = [];
1385
+ for (const file of codeownersFiles) {
1386
+ const content = await readOptionalTextFile(file, 16_000);
1387
+ if (content) {
1388
+ keywords.push(...tokenizeForWorkspaceMetadata(content));
1389
+ }
1390
+ }
1391
+ return uniqueStrings(keywords).slice(0, 80);
1392
+ }
1393
+ async function readOptionalTextFile(file, maxCharacters) {
1394
+ try {
1395
+ return (await readFile(file, "utf8")).slice(0, maxCharacters);
1396
+ }
1397
+ catch (error) {
1398
+ if (error instanceof Error && "code" in error && (error.code === "ENOENT" || error.code === "EACCES")) {
1399
+ return undefined;
1400
+ }
1401
+ throw error;
1402
+ }
1403
+ }
1404
+ async function readRecentGitBranches(gitDirectory) {
1405
+ const refsDirectory = join(gitDirectory, "refs", "heads");
1406
+ const branches = [];
1407
+ await collectGitBranchRefs(refsDirectory, "", branches);
1408
+ return uniqueStrings(branches).slice(0, 50);
1409
+ }
1410
+ async function collectGitBranchRefs(directory, prefix, branches) {
1411
+ try {
1412
+ const dir = await opendir(directory);
1413
+ for await (const entry of dir) {
1414
+ const branchName = prefix ? `${prefix}/${entry.name}` : entry.name;
1415
+ if (entry.isDirectory()) {
1416
+ await collectGitBranchRefs(join(directory, entry.name), branchName, branches);
1417
+ }
1418
+ else if (entry.isFile()) {
1419
+ branches.push(branchName);
1420
+ }
1421
+ }
1422
+ }
1423
+ catch (error) {
1424
+ if (error instanceof Error && "code" in error && (error.code === "ENOENT" || error.code === "EACCES")) {
1425
+ return;
1426
+ }
1427
+ throw error;
1428
+ }
1429
+ }
1430
+ async function readRecentGitCommitRefs(gitDirectory) {
1431
+ const content = await readOptionalTextFile(join(gitDirectory, "logs", "HEAD"), 64_000);
1432
+ if (!content) {
1433
+ return [];
1434
+ }
1435
+ const issueRefs = [...content.matchAll(/\b[A-Z][A-Z0-9]+-\d+\b/gu)].map((match) => match[0]);
1436
+ return uniqueStrings(issueRefs).slice(0, 50);
1437
+ }
1438
+ function tokenizeForWorkspaceMetadata(value) {
1439
+ return value
1440
+ .toLowerCase()
1441
+ .split(/[^a-z0-9@/_-]+/u)
1442
+ .map((token) => token.trim().replace(/^@/u, ""))
1443
+ .filter((token) => token.length >= 3);
1444
+ }
1445
+ function uniqueStrings(values) {
1446
+ return [...new Set(values.filter(Boolean))];
1447
+ }
1448
+ function createSessionStartEvents(session, workItem, workspaceCandidate, now) {
1449
+ const events = [
1450
+ createSessionEvent(session, workItem.key, "session_started", "Task session started", now, [
1451
+ `${workItem.key} ${workItem.title}`,
1452
+ `Automation level: ${session.automationLevel}`
1453
+ ], {
1454
+ status: session.status
1455
+ })
1456
+ ];
1457
+ if (!workspaceCandidate) {
1458
+ return [
1459
+ ...events,
1460
+ createSessionEvent(session, workItem.key, "workspace_unresolved", "Workspace unresolved", now, [
1461
+ "No workspace candidate was selected for this task session."
1462
+ ])
1463
+ ];
1464
+ }
1465
+ return [
1466
+ ...events,
1467
+ createSessionEvent(session, workItem.key, "workspace_resolved", "Workspace candidate selected", now, [
1468
+ `Workspace: ${workspaceCandidate.workspace.name}`,
1469
+ `Confidence: ${Math.round(workspaceCandidate.confidence * 100)}%`,
1470
+ ...workspaceCandidate.reasons
1471
+ ], {
1472
+ workspaceId: workspaceCandidate.workspace.id,
1473
+ confidence: String(workspaceCandidate.confidence)
1474
+ })
1475
+ ];
1476
+ }
1477
+ function createSessionEvent(session, workItemKey, type, title, createdAt, details, metadata) {
1478
+ const hash = createHash("sha256")
1479
+ .update(`${session.id}:${type}:${createdAt}:${title}`)
1480
+ .digest("hex")
1481
+ .slice(0, 12);
1482
+ return {
1483
+ id: `event_${hash}`,
1484
+ sessionId: session.id,
1485
+ workItemKey,
1486
+ type,
1487
+ title,
1488
+ details,
1489
+ createdAt,
1490
+ metadata
1491
+ };
1492
+ }
1493
+ function appendSessionEvents(existing, events) {
1494
+ return [...(existing ?? []), ...events];
1495
+ }
1496
+ function appendApprovalHistory(existing, approval) {
1497
+ return [...(existing ?? []), approval];
1498
+ }
1499
+ function createWorkspaceId(path) {
1500
+ const hash = createHash("sha256").update(path).digest("hex").slice(0, 12);
1501
+ return `${basename(path).toLowerCase().replace(/[^a-z0-9]+/gu, "-").replace(/^-|-$/gu, "")}-${hash}`;
1502
+ }
1503
+ async function readWorkspaceIndexIfPresent(homeDirectory) {
1504
+ try {
1505
+ const content = await readFile(getWorkspaceIndexFile(homeDirectory), "utf8");
1506
+ return JSON.parse(content);
1507
+ }
1508
+ catch (error) {
1509
+ if (error instanceof Error && "code" in error && error.code === "ENOENT") {
1510
+ return undefined;
1511
+ }
1512
+ throw error;
1513
+ }
1514
+ }
1515
+ function uniqueResolvedPaths(paths) {
1516
+ return [...new Set(paths.map((path) => resolve(path)))];
1517
+ }
1518
+ function resolveWorkspacePath(workspacePath, env) {
1519
+ if (isAbsolute(workspacePath)) {
1520
+ return resolve(workspacePath);
1521
+ }
1522
+ return resolve(env["INIT_CWD"] ?? process.cwd(), workspacePath);
1523
+ }
1524
+ async function readWorkspaceLinkIndexIfPresent(homeDirectory) {
1525
+ try {
1526
+ const content = await readFile(getWorkspaceLinksFile(homeDirectory), "utf8");
1527
+ return JSON.parse(content);
1528
+ }
1529
+ catch (error) {
1530
+ if (error instanceof Error && "code" in error && error.code === "ENOENT") {
1531
+ return undefined;
1532
+ }
1533
+ throw error;
1534
+ }
1535
+ }
1536
+ function upsertWorkspace(workspaces, workspace) {
1537
+ const filtered = workspaces.filter((candidate) => candidate.id !== workspace.id);
1538
+ return [...filtered, workspace].sort((left, right) => (left.path ?? left.name).localeCompare(right.path ?? right.name));
1539
+ }
1540
+ function upsertWorkspaceLink(links, link) {
1541
+ const filtered = links.filter((candidate) => candidate.workItemPattern.toUpperCase() !== link.workItemPattern.toUpperCase());
1542
+ return [...filtered, link].sort((left, right) => left.workItemPattern.localeCompare(right.workItemPattern));
1543
+ }
1544
+ async function readActiveTaskSessionIfPresent(homeDirectory) {
1545
+ try {
1546
+ const content = await readFile(getActiveTaskSessionFile(homeDirectory), "utf8");
1547
+ return JSON.parse(content);
1548
+ }
1549
+ catch (error) {
1550
+ if (error instanceof Error && "code" in error && error.code === "ENOENT") {
1551
+ return undefined;
1552
+ }
1553
+ throw error;
1554
+ }
1555
+ }
1556
+ async function writeActiveTaskSession(homeDirectory, session) {
1557
+ await mkdir(homeDirectory, { recursive: true });
1558
+ await writeFile(getActiveTaskSessionFile(homeDirectory), `${JSON.stringify(session, null, 2)}\n`, "utf8");
1559
+ }
1560
+ async function removeActiveTaskSession(homeDirectory) {
1561
+ await rm(getActiveTaskSessionFile(homeDirectory), { force: true });
1562
+ }
1563
+ async function readTaskSessionHistoryIfPresent(homeDirectory) {
1564
+ try {
1565
+ const content = await readFile(getTaskSessionHistoryFile(homeDirectory), "utf8");
1566
+ return JSON.parse(content);
1567
+ }
1568
+ catch (error) {
1569
+ if (error instanceof Error && "code" in error && error.code === "ENOENT") {
1570
+ return undefined;
1571
+ }
1572
+ throw error;
1573
+ }
1574
+ }
1575
+ async function archiveTaskSession(homeDirectory, session) {
1576
+ const existing = await readTaskSessionHistoryIfPresent(homeDirectory);
1577
+ const updatedAt = new Date().toISOString();
1578
+ const sessions = [session, ...(existing?.sessions.filter((candidate) => candidate.session.id !== session.session.id) ?? [])].slice(0, 25);
1579
+ const history = {
1580
+ indexVersion: 1,
1581
+ updatedAt,
1582
+ sessions
1583
+ };
1584
+ await mkdir(homeDirectory, { recursive: true });
1585
+ await writeFile(getTaskSessionHistoryFile(homeDirectory), `${JSON.stringify(history, null, 2)}\n`, "utf8");
1586
+ }
1587
+ function selectArchivedTaskSession(sessions, sessionId) {
1588
+ if (sessionId) {
1589
+ return sessions.find((session) => session.session.id === sessionId);
1590
+ }
1591
+ return sessions[0];
1592
+ }
1593
+ function buildPlanningContext(session) {
1594
+ const workspace = session.workspaceCandidate?.workspace;
1595
+ const context = [
1596
+ `Work item type: ${session.workItem.type}`,
1597
+ `Status: ${session.workItem.status}`,
1598
+ session.workItem.priority ? `Priority: ${session.workItem.priority}` : undefined,
1599
+ session.workItem.labels?.length ? `Labels: ${session.workItem.labels.join(", ")}` : undefined,
1600
+ session.workItem.components?.length ? `Components: ${session.workItem.components.join(", ")}` : undefined,
1601
+ workspace ? `Workspace: ${workspace.name}` : "Workspace: unresolved",
1602
+ workspace?.path ? `Workspace path: ${workspace.path}` : undefined,
1603
+ session.workspaceCandidate ? `Workspace confidence: ${Math.round(session.workspaceCandidate.confidence * 100)}%` : undefined,
1604
+ session.workspaceCandidate?.reasons.length ? `Workspace reasons: ${session.workspaceCandidate.reasons.join("; ")}` : undefined
1605
+ ];
1606
+ return context.filter((item) => Boolean(item));
1607
+ }
1608
+ function buildInitialImplementationPlan(workItem, workspaceCandidate) {
1609
+ const workspace = workspaceCandidate?.workspace;
1610
+ const hasWorkspace = Boolean(workspace?.path);
1611
+ return {
1612
+ summary: `Prepare implementation for ${workItem.key}: ${workItem.title}`,
1613
+ assumptions: [
1614
+ "Use the selected work item as the source of truth for scope.",
1615
+ hasWorkspace
1616
+ ? `Use workspace ${workspace?.name} as the initial code context.`
1617
+ : "Workspace is unresolved, so confirm or link a workspace before implementation.",
1618
+ "Require explicit approval before editing files, running mutating commands, creating branches, pushing, or opening PRs."
1619
+ ],
1620
+ steps: [
1621
+ {
1622
+ id: "understand",
1623
+ title: "Review work item context",
1624
+ detail: "Read the title, description, labels, components, parent, subtasks, and linked references."
1625
+ },
1626
+ {
1627
+ id: "inspect-workspace",
1628
+ title: hasWorkspace ? "Inspect selected workspace" : "Resolve workspace",
1629
+ detail: hasWorkspace
1630
+ ? `Inspect ${workspace?.path} for relevant modules, tests, ownership files, and contribution rules.`
1631
+ : "Run workspace scan/resolve or link the correct workspace manually."
1632
+ },
1633
+ {
1634
+ id: "draft-change",
1635
+ title: "Draft implementation approach",
1636
+ detail: "Identify the smallest safe change set and the tests needed to validate it."
1637
+ },
1638
+ {
1639
+ id: "approval",
1640
+ title: "Request approval checkpoint",
1641
+ detail: "Ask the developer to approve the plan before implementation begins."
1642
+ }
1643
+ ],
1644
+ filesLikelyChanged: hasWorkspace ? [workspace?.path ?? ""] : [],
1645
+ commandsToRun: ["pome approve plan", "pnpm validate"],
1646
+ risks: [
1647
+ "Workspace resolution may be incomplete until real GitHub and historical session signals are added.",
1648
+ "The first plan is deterministic; model-provider assisted planning will be added later."
1649
+ ],
1650
+ missingInfo: hasWorkspace ? [] : ["No workspace candidate is selected yet."]
1651
+ };
1652
+ }
1653
+ async function discoverTestCommandCandidates(workspacePath) {
1654
+ const scripts = await readPackageScripts(join(workspacePath, "package.json"));
1655
+ const packageManager = detectPackageManager(workspacePath);
1656
+ const candidates = [];
1657
+ for (const scriptName of ["validate", "test", "typecheck", "lint"]) {
1658
+ if (!scripts[scriptName]) {
1659
+ continue;
1660
+ }
1661
+ candidates.push({
1662
+ id: `script_${scriptName}`,
1663
+ command: buildPackageScriptCommand(packageManager, scriptName),
1664
+ source: "package_json",
1665
+ reason: `Detected package.json script "${scriptName}".`,
1666
+ cwd: workspacePath
1667
+ });
1668
+ }
1669
+ if (candidates.length > 0) {
1670
+ return candidates;
1671
+ }
1672
+ if (Object.keys(scripts).length > 0) {
1673
+ return [
1674
+ {
1675
+ id: "script_first_available",
1676
+ command: buildPackageScriptCommand(packageManager, Object.keys(scripts)[0] ?? "test"),
1677
+ source: "package_json",
1678
+ reason: "No standard test script was found; using the first available package.json script.",
1679
+ cwd: workspacePath
1680
+ }
1681
+ ];
1682
+ }
1683
+ return getFallbackTestCommandCandidates(workspacePath);
1684
+ }
1685
+ function detectPackageManager(workspacePath) {
1686
+ if (existsSync(join(workspacePath, "pnpm-lock.yaml"))) {
1687
+ return "pnpm";
1688
+ }
1689
+ if (existsSync(join(workspacePath, "yarn.lock"))) {
1690
+ return "yarn";
1691
+ }
1692
+ if (existsSync(join(workspacePath, "bun.lockb")) || existsSync(join(workspacePath, "bun.lock"))) {
1693
+ return "bun";
1694
+ }
1695
+ return "npm";
1696
+ }
1697
+ function buildPackageScriptCommand(packageManager, scriptName) {
1698
+ if (packageManager === "npm") {
1699
+ return scriptName === "test" ? "npm test" : `npm run ${scriptName}`;
1700
+ }
1701
+ if (packageManager === "yarn") {
1702
+ return `yarn ${scriptName}`;
1703
+ }
1704
+ if (packageManager === "bun") {
1705
+ return `bun run ${scriptName}`;
1706
+ }
1707
+ return `pnpm ${scriptName}`;
1708
+ }
1709
+ function getFallbackTestCommandCandidates(cwd) {
1710
+ return [
1711
+ {
1712
+ id: "fallback_validate",
1713
+ command: "pnpm validate",
1714
+ source: "fallback",
1715
+ reason: "Fallback command for OpenPome-style TypeScript workspaces.",
1716
+ cwd
1717
+ }
1718
+ ];
1719
+ }
1720
+ function selectTestCommandCandidate(candidates, command) {
1721
+ if (!command) {
1722
+ return candidates[0];
1723
+ }
1724
+ const normalized = command.trim();
1725
+ return candidates.find((candidate) => candidate.command === normalized || candidate.id === normalized);
1726
+ }
1727
+ function createCommandApproval(session, candidate, now) {
1728
+ return {
1729
+ id: `approval_${createHash("sha256").update(`${session.session.id}:run_command:${candidate.command}:${now}`).digest("hex").slice(0, 12)}`,
1730
+ type: "run_command",
1731
+ title: `Command approval for ${session.workItem.key}`,
1732
+ reason: "Developer approved this command candidate as test evidence for the task session.",
1733
+ details: [
1734
+ `Session: ${session.session.id}`,
1735
+ `Work item: ${session.workItem.key}`,
1736
+ `Command: ${candidate.command}`,
1737
+ `Working directory: ${candidate.cwd ?? "unresolved"}`,
1738
+ `Recorded at: ${now}`
1739
+ ],
1740
+ status: "approved"
1741
+ };
1742
+ }
1743
+ function buildPullRequestDraft(session, createdAt) {
1744
+ const workItem = session.workItem;
1745
+ const workspace = session.workspaceCandidate?.workspace;
1746
+ const title = `${workItem.key}: ${workItem.title}`;
1747
+ const testEvidence = session.commandApprovalEvidence?.map((evidence) => `- Approved command: \`${evidence.command}\``) ?? [];
1748
+ const body = [
1749
+ `## Summary`,
1750
+ `- ${session.plan?.summary ?? `Prepare implementation for ${workItem.key}`}`,
1751
+ `- Work item: ${workItem.key}`,
1752
+ workspace ? `- Workspace: ${workspace.name}` : "- Workspace: unresolved",
1753
+ "",
1754
+ "## Plan",
1755
+ ...(session.plan?.steps.map((step) => `- ${step.title}${step.detail ? `: ${step.detail}` : ""}`) ?? ["- No plan generated yet."]),
1756
+ "",
1757
+ "## Validation",
1758
+ ...(testEvidence.length ? testEvidence : ["- No approved test command evidence recorded yet."]),
1759
+ "",
1760
+ "## Approval",
1761
+ `- Plan approval: ${session.planApproval?.status ?? "not recorded"}`,
1762
+ "- Creating or publishing this PR still requires an explicit approval checkpoint."
1763
+ ].join("\n");
1764
+ return {
1765
+ title,
1766
+ body,
1767
+ baseBranch: "main",
1768
+ headBranch: session.session.branchName ?? `openpome/${workItem.key.toLowerCase()}`,
1769
+ remoteUrl: workspace?.remoteUrls[0],
1770
+ createdAt
1771
+ };
1772
+ }
1773
+ function buildWorkItemUpdateDraft(session, createdAt) {
1774
+ const lines = [
1775
+ `OpenPome update for ${session.workItem.key}`,
1776
+ "",
1777
+ `Status: ${session.session.status}`,
1778
+ session.workspaceCandidate?.workspace.name ? `Workspace: ${session.workspaceCandidate.workspace.name}` : "Workspace: unresolved",
1779
+ session.plan?.summary ? `Plan: ${session.plan.summary}` : "Plan: not generated",
1780
+ session.planApproval ? `Plan approval: ${session.planApproval.status}` : "Plan approval: not recorded",
1781
+ "",
1782
+ "Validation evidence:",
1783
+ ...(session.commandApprovalEvidence?.length
1784
+ ? session.commandApprovalEvidence.map((evidence) => `- Approved command: ${evidence.command}`)
1785
+ : ["- No approved test command evidence recorded yet."]),
1786
+ "",
1787
+ `Drafted locally at ${createdAt}. This update has not been posted.`
1788
+ ];
1789
+ return {
1790
+ body: lines.join("\n"),
1791
+ createdAt
1792
+ };
1793
+ }
1794
+ function selectCommandApprovalEvidence(approvals, command) {
1795
+ if (!command) {
1796
+ return approvals[approvals.length - 1];
1797
+ }
1798
+ const normalized = command.trim();
1799
+ return approvals.find((approval) => approval.command === normalized || approval.id === normalized || approval.approval.id === normalized);
1800
+ }
1801
+ async function executeApprovedCommand(command, cwd) {
1802
+ try {
1803
+ const result = await execAsync(command, {
1804
+ cwd,
1805
+ timeout: 2 * 60 * 1000,
1806
+ maxBuffer: 1024 * 1024,
1807
+ windowsHide: true
1808
+ });
1809
+ return {
1810
+ exitCode: 0,
1811
+ stdout: result.stdout,
1812
+ stderr: result.stderr
1813
+ };
1814
+ }
1815
+ catch (error) {
1816
+ const maybeError = error;
1817
+ return {
1818
+ exitCode: typeof maybeError.code === "number" ? maybeError.code : 1,
1819
+ stdout: typeof maybeError.stdout === "string" ? maybeError.stdout : "",
1820
+ stderr: typeof maybeError.stderr === "string" ? maybeError.stderr : error instanceof Error ? error.message : String(error)
1821
+ };
1822
+ }
1823
+ }
1824
+ function summarizeCommandOutput(output) {
1825
+ return output
1826
+ .split(/\r?\n/u)
1827
+ .map((line) => line.trim())
1828
+ .filter(Boolean)
1829
+ .slice(-20);
1830
+ }
1831
+ function buildManualCopyAIContextText(session, createdAt) {
1832
+ const workspace = session.workspaceCandidate?.workspace;
1833
+ const lines = [
1834
+ `OpenPome manual-copy AI context`,
1835
+ `Created: ${createdAt}`,
1836
+ "",
1837
+ "Safety:",
1838
+ "- This context excludes source code, secrets, and full diffs.",
1839
+ "- Ask before requesting full files, full diffs, external network calls, or mutating commands.",
1840
+ "",
1841
+ "Work item:",
1842
+ `- Key: ${session.workItem.key}`,
1843
+ `- Type: ${session.workItem.type}`,
1844
+ `- Status: ${session.workItem.status}`,
1845
+ `- Title: ${session.workItem.title}`,
1846
+ session.workItem.priority ? `- Priority: ${session.workItem.priority}` : undefined,
1847
+ session.workItem.labels?.length ? `- Labels: ${session.workItem.labels.join(", ")}` : undefined,
1848
+ session.workItem.components?.length ? `- Components: ${session.workItem.components.join(", ")}` : undefined,
1849
+ "",
1850
+ "Workspace:",
1851
+ workspace ? `- Name: ${workspace.name}` : "- Name: unresolved",
1852
+ workspace?.path ? `- Path: ${workspace.path}` : undefined,
1853
+ session.workspaceCandidate ? `- Confidence: ${Math.round(session.workspaceCandidate.confidence * 100)}%` : undefined,
1854
+ session.workspaceCandidate?.reasons.length ? `- Reasons: ${session.workspaceCandidate.reasons.join("; ")}` : undefined,
1855
+ "",
1856
+ "Session:",
1857
+ `- Id: ${session.session.id}`,
1858
+ `- Status: ${session.session.status}`,
1859
+ `- Automation level: ${session.session.automationLevel}`,
1860
+ "",
1861
+ "Plan:",
1862
+ session.plan?.summary ? `- Summary: ${session.plan.summary}` : "- Not generated",
1863
+ ...(session.plan?.steps.map((step) => `- ${step.id}: ${step.title}${step.detail ? ` - ${step.detail}` : ""}`) ?? []),
1864
+ "",
1865
+ "Approvals:",
1866
+ session.planApproval ? `- Plan approval: ${session.planApproval.status}` : "- Plan approval: not recorded",
1867
+ ...(session.commandApprovalEvidence?.map((evidence) => `- Command approved: ${evidence.command}`) ?? []),
1868
+ "",
1869
+ "Validation:",
1870
+ ...(session.testRunEvidence?.map((run) => `- ${run.command}: ${run.status} (exit ${run.exitCode})`) ?? [
1871
+ "- No test run evidence recorded yet."
1872
+ ]),
1873
+ "",
1874
+ "Diff summary:",
1875
+ ...(session.diffSummary?.files.map((file) => `- ${file.status} ${file.path} +${file.added ?? 0} -${file.deleted ?? 0}`) ?? [
1876
+ "- No diff summary captured yet."
1877
+ ])
1878
+ ];
1879
+ return lines.filter((line) => Boolean(line)).join("\n");
1880
+ }
1881
+ async function buildDiffSummary(workspacePath, createdAt) {
1882
+ const [branch, status, nameStatus, numstat] = await Promise.all([
1883
+ runGit(workspacePath, ["branch", "--show-current"]),
1884
+ runGit(workspacePath, ["status", "--short"]),
1885
+ runGit(workspacePath, ["diff", "--name-status", "HEAD"]),
1886
+ runGit(workspacePath, ["diff", "--numstat", "HEAD"])
1887
+ ]);
1888
+ const files = mergeDiffFiles(parseNameStatus(nameStatus), parseNumstat(numstat));
1889
+ return {
1890
+ createdAt,
1891
+ workspacePath,
1892
+ branch: branch.trim() || undefined,
1893
+ files,
1894
+ statusLines: status.split(/\r?\n/u).map((line) => line.trim()).filter(Boolean),
1895
+ includesFullDiff: false
1896
+ };
1897
+ }
1898
+ async function runGit(cwd, args) {
1899
+ try {
1900
+ const result = await execFileAsync("git", args, {
1901
+ cwd,
1902
+ timeout: 30_000,
1903
+ maxBuffer: 512 * 1024,
1904
+ windowsHide: true
1905
+ });
1906
+ return result.stdout;
1907
+ }
1908
+ catch {
1909
+ return "";
1910
+ }
1911
+ }
1912
+ function parseNameStatus(output) {
1913
+ return output
1914
+ .split(/\r?\n/u)
1915
+ .map((line) => line.trim())
1916
+ .filter(Boolean)
1917
+ .map((line) => {
1918
+ const [status = "?", ...pathParts] = line.split(/\s+/u);
1919
+ return {
1920
+ status,
1921
+ path: pathParts.join(" ")
1922
+ };
1923
+ })
1924
+ .filter((file) => file.path);
1925
+ }
1926
+ function parseNumstat(output) {
1927
+ const entries = output
1928
+ .split(/\r?\n/u)
1929
+ .map((line) => line.trim())
1930
+ .filter(Boolean)
1931
+ .map((line) => {
1932
+ const [added, deleted, ...pathParts] = line.split(/\s+/u);
1933
+ const path = pathParts.join(" ");
1934
+ if (!path) {
1935
+ return undefined;
1936
+ }
1937
+ return [
1938
+ path,
1939
+ {
1940
+ added: Number.isFinite(Number(added)) ? Number(added) : undefined,
1941
+ deleted: Number.isFinite(Number(deleted)) ? Number(deleted) : undefined
1942
+ }
1943
+ ];
1944
+ })
1945
+ .filter((entry) => Boolean(entry));
1946
+ return new Map(entries);
1947
+ }
1948
+ function mergeDiffFiles(nameStatus, numstat) {
1949
+ const files = nameStatus.map((file) => ({
1950
+ ...file,
1951
+ ...numstat.get(file.path)
1952
+ }));
1953
+ const seen = new Set(files.map((file) => file.path));
1954
+ for (const [path, counts] of numstat.entries()) {
1955
+ if (!seen.has(path)) {
1956
+ files.push({
1957
+ path,
1958
+ status: "M",
1959
+ ...counts
1960
+ });
1961
+ }
1962
+ }
1963
+ return files.sort((left, right) => left.path.localeCompare(right.path));
1964
+ }
1965
+ function summarizeExecError(error) {
1966
+ const maybeError = error;
1967
+ const stderr = typeof maybeError.stderr === "string" ? summarizeCommandOutput(maybeError.stderr).join(" ") : "";
1968
+ const stdout = typeof maybeError.stdout === "string" ? summarizeCommandOutput(maybeError.stdout).join(" ") : "";
1969
+ const message = typeof maybeError.message === "string" ? maybeError.message : "";
1970
+ return stderr || stdout || message || undefined;
1971
+ }
1972
+ async function createExternalActionGuard(action) {
1973
+ const paths = getOpenPomePaths();
1974
+ const persisted = await readActiveTaskSessionIfPresent(paths.homeDirectory);
1975
+ return {
1976
+ active: Boolean(persisted),
1977
+ sessionFile: getActiveTaskSessionFile(paths.homeDirectory),
1978
+ session: persisted?.session,
1979
+ action,
1980
+ allowed: false,
1981
+ detail: action === "create_pr"
1982
+ ? "PR creation is not enabled in this alpha. Use `pome pr draft` and create the PR manually."
1983
+ : "Work item update posting is not enabled in this alpha. Use `pome work-item update-draft` and post manually.",
1984
+ nextStep: action === "create_pr"
1985
+ ? "Run `pome pr draft`, review the body, then create the PR yourself."
1986
+ : "Run `pome work-item update-draft`, review the body, then post the comment yourself."
1987
+ };
1988
+ }
1989
+ function createPlanApproval(session, status, now, reason = "Developer reviewed the implementation plan.") {
1990
+ return {
1991
+ id: `approval_${createHash("sha256").update(`${session.session.id}:approve_plan`).digest("hex").slice(0, 12)}`,
1992
+ type: "approve_plan",
1993
+ title: `Plan approval for ${session.workItem.key}`,
1994
+ reason,
1995
+ details: [
1996
+ `Session: ${session.session.id}`,
1997
+ `Work item: ${session.workItem.key}`,
1998
+ `Workspace: ${session.workspaceCandidate?.workspace.name ?? "unresolved"}`,
1999
+ `Recorded at: ${now}`
2000
+ ],
2001
+ status
2002
+ };
2003
+ }
2004
+ //# sourceMappingURL=index.js.map