@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/LICENSE +158 -0
- package/dist/connectors/work-item-registry.d.ts +36 -0
- package/dist/connectors/work-item-registry.d.ts.map +1 -0
- package/dist/connectors/work-item-registry.js +85 -0
- package/dist/connectors/work-item-registry.js.map +1 -0
- package/dist/index.d.ts +335 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +2004 -0
- package/dist/index.js.map +1 -0
- package/package.json +40 -0
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
|