@jskit-ai/jskit-cli 0.2.78 → 0.2.80
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/package.json +5 -5
- package/src/server/appBlueprint.js +126 -0
- package/src/server/cliRuntime/mutationWhen.js +16 -0
- package/src/server/cliRuntime/packageInstallFlow.js +43 -5
- package/src/server/commandHandlers/blueprint.js +151 -0
- package/src/server/commandHandlers/session.js +237 -0
- package/src/server/commandHandlers/show/renderPackageText.js +11 -2
- package/src/server/core/argParser.js +2 -2
- package/src/server/core/commandCatalog.js +83 -0
- package/src/server/core/createCommandHandlers.js +7 -1
- package/src/server/index.js +2 -0
- package/src/server/sessionRuntime/constants.js +296 -0
- package/src/server/sessionRuntime/io.js +97 -0
- package/src/server/sessionRuntime/paths.js +165 -0
- package/src/server/sessionRuntime/preconditions.js +372 -0
- package/src/server/sessionRuntime/promptRenderer.js +41 -0
- package/src/server/sessionRuntime/prompts/app_blueprint.md +28 -0
- package/src/server/sessionRuntime/prompts/doctor_failure.md +15 -0
- package/src/server/sessionRuntime/prompts/final_comment.md +8 -0
- package/src/server/sessionRuntime/prompts/implement_issue.md +25 -0
- package/src/server/sessionRuntime/prompts/new_issue.md +13 -0
- package/src/server/sessionRuntime/prompts/pr_failure.md +15 -0
- package/src/server/sessionRuntime/prompts/review_changes.md +22 -0
- package/src/server/sessionRuntime/prompts/user_check.md +9 -0
- package/src/server/sessionRuntime/responses.js +315 -0
- package/src/server/sessionRuntime.js +927 -0
|
@@ -0,0 +1,927 @@
|
|
|
1
|
+
import {
|
|
2
|
+
mkdir,
|
|
3
|
+
readFile,
|
|
4
|
+
readdir
|
|
5
|
+
} from "node:fs/promises";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
import {
|
|
8
|
+
SESSION_STATUS,
|
|
9
|
+
STEP_DEFINITIONS,
|
|
10
|
+
STEP_IDS,
|
|
11
|
+
STEP_PRECONDITION_NAMES
|
|
12
|
+
} from "./sessionRuntime/constants.js";
|
|
13
|
+
import {
|
|
14
|
+
normalizeText,
|
|
15
|
+
readTextIfExists,
|
|
16
|
+
readTrimmedFile,
|
|
17
|
+
runCommand,
|
|
18
|
+
runGit,
|
|
19
|
+
runGitInWorktree,
|
|
20
|
+
timestampForReceipt,
|
|
21
|
+
writeTextFile
|
|
22
|
+
} from "./sessionRuntime/io.js";
|
|
23
|
+
import {
|
|
24
|
+
archiveSession,
|
|
25
|
+
createAvailableSessionId,
|
|
26
|
+
createSessionId,
|
|
27
|
+
isValidSessionId,
|
|
28
|
+
resolveExistingSessionRoot,
|
|
29
|
+
resolveSessionPaths,
|
|
30
|
+
pathsForExistingSession
|
|
31
|
+
} from "./sessionRuntime/paths.js";
|
|
32
|
+
import {
|
|
33
|
+
buildSessionErrorResponse,
|
|
34
|
+
buildSessionResponse,
|
|
35
|
+
buildStepDefinitions,
|
|
36
|
+
createError,
|
|
37
|
+
failSession,
|
|
38
|
+
markCurrentStep,
|
|
39
|
+
markStatus,
|
|
40
|
+
readReceiptSteps,
|
|
41
|
+
readSessionArtifacts,
|
|
42
|
+
writeReceipt
|
|
43
|
+
} from "./sessionRuntime/responses.js";
|
|
44
|
+
import {
|
|
45
|
+
applyPreconditions,
|
|
46
|
+
assertGhAuth,
|
|
47
|
+
assertGitCurrentBranch,
|
|
48
|
+
assertGitRepository,
|
|
49
|
+
assertGithubOrigin,
|
|
50
|
+
assertIssueArtifacts,
|
|
51
|
+
assertIssueTextExists,
|
|
52
|
+
assertPrUrlExists,
|
|
53
|
+
assertSessionExists,
|
|
54
|
+
assertTargetRootWritable,
|
|
55
|
+
assertWorktreeExists,
|
|
56
|
+
ensureStudioGitExclude,
|
|
57
|
+
hasWorktree
|
|
58
|
+
} from "./sessionRuntime/preconditions.js";
|
|
59
|
+
import {
|
|
60
|
+
renderPrompt,
|
|
61
|
+
renderTemplate
|
|
62
|
+
} from "./sessionRuntime/promptRenderer.js";
|
|
63
|
+
|
|
64
|
+
function invalidSessionIdError(sessionId = "") {
|
|
65
|
+
return createError({
|
|
66
|
+
code: "invalid_session_id",
|
|
67
|
+
message: `Invalid session id "${sessionId}". Expected YYYY-MM-DD_HH-MM-SS.`
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function invalidSessionIdResponse({
|
|
72
|
+
targetRoot,
|
|
73
|
+
sessionId
|
|
74
|
+
}) {
|
|
75
|
+
return buildSessionErrorResponse({
|
|
76
|
+
targetRoot,
|
|
77
|
+
sessionId,
|
|
78
|
+
errors: [invalidSessionIdError(sessionId)]
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async function existingSessionContext({
|
|
83
|
+
targetRoot = process.cwd(),
|
|
84
|
+
sessionId
|
|
85
|
+
} = {}) {
|
|
86
|
+
if (!isValidSessionId(sessionId)) {
|
|
87
|
+
return {
|
|
88
|
+
ok: false,
|
|
89
|
+
response: invalidSessionIdResponse({ targetRoot, sessionId })
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const paths = await pathsForExistingSession(resolveSessionPaths({ targetRoot, sessionId }));
|
|
94
|
+
const preconditions = await applyPreconditions(paths, [
|
|
95
|
+
() => assertSessionExists(paths)
|
|
96
|
+
]);
|
|
97
|
+
if (!preconditions.ok) {
|
|
98
|
+
return {
|
|
99
|
+
ok: false,
|
|
100
|
+
response: await failSession(paths, {
|
|
101
|
+
...preconditions.error,
|
|
102
|
+
preconditions: preconditions.preconditions
|
|
103
|
+
})
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return {
|
|
108
|
+
ok: true,
|
|
109
|
+
paths,
|
|
110
|
+
preconditions: preconditions.preconditions
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async function withExistingSession(input, handler) {
|
|
115
|
+
const context = await existingSessionContext(input);
|
|
116
|
+
if (!context.ok) {
|
|
117
|
+
return context.response;
|
|
118
|
+
}
|
|
119
|
+
return handler(context.paths, {
|
|
120
|
+
preconditions: context.preconditions
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function extractIssueText(value = "") {
|
|
125
|
+
const text = normalizeText(value);
|
|
126
|
+
const match = /\[issue_text\]([\s\S]*?)\[\/issue_text\]/u.exec(text);
|
|
127
|
+
return normalizeText(match ? match[1] : text);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
async function createSession({
|
|
131
|
+
targetRoot = process.cwd(),
|
|
132
|
+
sessionId = "",
|
|
133
|
+
now = new Date()
|
|
134
|
+
} = {}) {
|
|
135
|
+
if (sessionId && !isValidSessionId(sessionId)) {
|
|
136
|
+
return invalidSessionIdResponse({ targetRoot, sessionId });
|
|
137
|
+
}
|
|
138
|
+
const initialPaths = resolveSessionPaths({
|
|
139
|
+
targetRoot,
|
|
140
|
+
sessionId: sessionId || await createAvailableSessionId(targetRoot, now)
|
|
141
|
+
});
|
|
142
|
+
const existingSession = await resolveExistingSessionRoot(initialPaths);
|
|
143
|
+
if (existingSession.root) {
|
|
144
|
+
return failSession(initialPaths, {
|
|
145
|
+
code: "session_exists",
|
|
146
|
+
message: `Session already exists: ${initialPaths.sessionId}`,
|
|
147
|
+
status: SESSION_STATUS.BLOCKED
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const preconditions = await applyPreconditions(initialPaths, [
|
|
152
|
+
() => assertTargetRootWritable(initialPaths.targetRoot),
|
|
153
|
+
() => assertGitRepository(initialPaths.targetRoot)
|
|
154
|
+
]);
|
|
155
|
+
if (!preconditions.ok) {
|
|
156
|
+
return failSession(initialPaths, {
|
|
157
|
+
...preconditions.error,
|
|
158
|
+
preconditions: preconditions.preconditions
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
await ensureStudioGitExclude(initialPaths.targetRoot);
|
|
163
|
+
await mkdir(initialPaths.sessionRoot, { recursive: true });
|
|
164
|
+
await mkdir(initialPaths.worktreesRoot, { recursive: true });
|
|
165
|
+
await writeTextFile(path.join(initialPaths.sessionRoot, "transcript.log"), "");
|
|
166
|
+
await markStatus(initialPaths, SESSION_STATUS.PENDING);
|
|
167
|
+
await writeReceipt(initialPaths, "session_created", `Created JSKIT Studio issue session ${initialPaths.sessionId}.`);
|
|
168
|
+
|
|
169
|
+
return buildSessionResponse(initialPaths, {
|
|
170
|
+
ok: true,
|
|
171
|
+
preconditions: preconditions.preconditions
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
async function listSessions({ targetRoot = process.cwd() } = {}) {
|
|
176
|
+
const paths = resolveSessionPaths({ targetRoot });
|
|
177
|
+
const sessions = [];
|
|
178
|
+
const roots = [
|
|
179
|
+
{ archive: "active", root: paths.sessionsRoot },
|
|
180
|
+
{ archive: "completed", root: paths.completedSessionsRoot },
|
|
181
|
+
{ archive: "abandoned", root: paths.abandonedSessionsRoot }
|
|
182
|
+
];
|
|
183
|
+
|
|
184
|
+
for (const rootInfo of roots) {
|
|
185
|
+
let entries = [];
|
|
186
|
+
try {
|
|
187
|
+
entries = await readdir(rootInfo.root, { withFileTypes: true });
|
|
188
|
+
} catch {
|
|
189
|
+
entries = [];
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
for (const entry of entries) {
|
|
193
|
+
if (!entry.isDirectory() || !isValidSessionId(entry.name)) {
|
|
194
|
+
continue;
|
|
195
|
+
}
|
|
196
|
+
const sessionPaths = resolveSessionPaths({
|
|
197
|
+
targetRoot,
|
|
198
|
+
sessionId: entry.name
|
|
199
|
+
});
|
|
200
|
+
const response = await buildSessionResponse({
|
|
201
|
+
...sessionPaths,
|
|
202
|
+
archive: rootInfo.archive,
|
|
203
|
+
sessionRoot: path.join(rootInfo.root, entry.name)
|
|
204
|
+
});
|
|
205
|
+
sessions.push(response);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
sessions.sort((left, right) => right.sessionId.localeCompare(left.sessionId));
|
|
209
|
+
return {
|
|
210
|
+
ok: true,
|
|
211
|
+
stepDefinitions: buildStepDefinitions(),
|
|
212
|
+
sessions
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
async function inspectSession({
|
|
217
|
+
targetRoot = process.cwd(),
|
|
218
|
+
sessionId
|
|
219
|
+
} = {}) {
|
|
220
|
+
return withExistingSession({ targetRoot, sessionId }, (paths, context) => {
|
|
221
|
+
return buildSessionResponse(paths, {
|
|
222
|
+
preconditions: context.preconditions
|
|
223
|
+
});
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function emptySessionDetails(response) {
|
|
228
|
+
return {
|
|
229
|
+
...response,
|
|
230
|
+
issueText: "",
|
|
231
|
+
receipts: [],
|
|
232
|
+
transcriptLog: ""
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
async function inspectSessionDetails({
|
|
237
|
+
targetRoot = process.cwd(),
|
|
238
|
+
sessionId
|
|
239
|
+
} = {}) {
|
|
240
|
+
const context = await existingSessionContext({ targetRoot, sessionId });
|
|
241
|
+
if (!context.ok) {
|
|
242
|
+
return emptySessionDetails(context.response);
|
|
243
|
+
}
|
|
244
|
+
const { paths, preconditions } = context;
|
|
245
|
+
const response = await buildSessionResponse(paths, { preconditions });
|
|
246
|
+
|
|
247
|
+
const [issueText, receipts, transcriptLog] = await Promise.all([
|
|
248
|
+
readTextIfExists(path.join(paths.sessionRoot, "issue.md")),
|
|
249
|
+
readReceiptSteps(paths),
|
|
250
|
+
readTextIfExists(path.join(paths.sessionRoot, "transcript.log"))
|
|
251
|
+
]);
|
|
252
|
+
|
|
253
|
+
return {
|
|
254
|
+
...response,
|
|
255
|
+
issueText: issueText.trim(),
|
|
256
|
+
receipts,
|
|
257
|
+
transcriptLog
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
async function createWorktree(paths, _options = {}, context = {}) {
|
|
262
|
+
const preconditions = context.preconditions || [];
|
|
263
|
+
if (await hasWorktree(paths)) {
|
|
264
|
+
await writeReceipt(paths, "worktree_created", `Reused existing worktree ${paths.worktree}.`);
|
|
265
|
+
await markStatus(paths, SESSION_STATUS.RUNNING);
|
|
266
|
+
return buildSessionResponse(paths, {
|
|
267
|
+
preconditions
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
await mkdir(paths.worktreesRoot, { recursive: true });
|
|
272
|
+
const result = await runGit(paths.targetRoot, ["worktree", "add", "-b", paths.branch, paths.worktree, "HEAD"], {
|
|
273
|
+
timeout: 30000
|
|
274
|
+
});
|
|
275
|
+
if (!result.ok) {
|
|
276
|
+
return failSession(paths, {
|
|
277
|
+
code: "worktree_create_failed",
|
|
278
|
+
message: result.output || `Failed to create worktree ${paths.worktree}.`,
|
|
279
|
+
repairCommand: `git worktree add -b ${paths.branch} ${paths.worktree} HEAD`,
|
|
280
|
+
preconditions
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
await writeReceipt(paths, "worktree_created", `Created worktree ${paths.worktree} on branch ${paths.branch}.`);
|
|
284
|
+
await markStatus(paths, SESSION_STATUS.RUNNING);
|
|
285
|
+
return buildSessionResponse(paths, {
|
|
286
|
+
preconditions
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
async function renderIssuePrompt(paths, options = {}) {
|
|
291
|
+
const userInput = normalizeText(options.prompt);
|
|
292
|
+
if (!userInput) {
|
|
293
|
+
return failSession(paths, {
|
|
294
|
+
code: "prompt_required",
|
|
295
|
+
message: "The issue prompt step requires --prompt.",
|
|
296
|
+
repairCommand: `jskit session ${paths.sessionId} step --prompt "<what should change>"`
|
|
297
|
+
});
|
|
298
|
+
}
|
|
299
|
+
const prompt = await renderPrompt(paths, "new_issue.md", {
|
|
300
|
+
user_input: userInput
|
|
301
|
+
});
|
|
302
|
+
await writeTextFile(path.join(paths.sessionRoot, "prompt.md"), prompt);
|
|
303
|
+
await writeReceipt(paths, "issue_prompt_rendered", "Rendered the issue drafting prompt.");
|
|
304
|
+
await markStatus(paths, SESSION_STATUS.WAITING_FOR_USER);
|
|
305
|
+
return buildSessionResponse(paths, {
|
|
306
|
+
ok: true,
|
|
307
|
+
prompt,
|
|
308
|
+
status: SESSION_STATUS.WAITING_FOR_USER
|
|
309
|
+
});
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
async function draftIssue(paths, options = {}) {
|
|
313
|
+
const issueText = extractIssueText(options.issue);
|
|
314
|
+
if (!issueText) {
|
|
315
|
+
return failSession(paths, {
|
|
316
|
+
code: "issue_required",
|
|
317
|
+
message: "The issue drafting step requires --issue, --issue-file, or --issue -.",
|
|
318
|
+
repairCommand: `jskit session ${paths.sessionId} step --issue -`
|
|
319
|
+
});
|
|
320
|
+
}
|
|
321
|
+
await writeTextFile(path.join(paths.sessionRoot, "issue.md"), issueText);
|
|
322
|
+
await writeReceipt(paths, "issue_drafted", "Saved approved issue text.");
|
|
323
|
+
await markStatus(paths, SESSION_STATUS.RUNNING);
|
|
324
|
+
return buildSessionResponse(paths);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
function titleFromIssue(issueText) {
|
|
328
|
+
const firstMeaningfulLine = String(issueText || "")
|
|
329
|
+
.split(/\r?\n/u)
|
|
330
|
+
.map((line) => line.replace(/^#+\s*/u, "").trim())
|
|
331
|
+
.find(Boolean);
|
|
332
|
+
return (firstMeaningfulLine || "JSKIT Studio issue").slice(0, 120);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
async function createIssue(paths, _options = {}, context = {}) {
|
|
336
|
+
const preconditions = context.preconditions || [];
|
|
337
|
+
const issueText = await readTrimmedFile(path.join(paths.sessionRoot, "issue.md"));
|
|
338
|
+
const result = await runCommand("gh", [
|
|
339
|
+
"issue",
|
|
340
|
+
"create",
|
|
341
|
+
"--title",
|
|
342
|
+
titleFromIssue(issueText),
|
|
343
|
+
"--body-file",
|
|
344
|
+
path.join(paths.sessionRoot, "issue.md")
|
|
345
|
+
], {
|
|
346
|
+
cwd: paths.targetRoot,
|
|
347
|
+
timeout: 30000
|
|
348
|
+
});
|
|
349
|
+
if (!result.ok || !result.stdout) {
|
|
350
|
+
return failSession(paths, {
|
|
351
|
+
code: "issue_create_failed",
|
|
352
|
+
message: result.output || "GitHub issue creation failed.",
|
|
353
|
+
repairCommand: "gh issue create",
|
|
354
|
+
preconditions
|
|
355
|
+
});
|
|
356
|
+
}
|
|
357
|
+
const issueUrl = result.stdout.split(/\r?\n/u).map((line) => line.trim()).find(Boolean) || result.stdout;
|
|
358
|
+
await writeTextFile(path.join(paths.sessionRoot, "issue_url"), issueUrl);
|
|
359
|
+
await writeReceipt(paths, "issue_created", `Created GitHub issue ${issueUrl}.`);
|
|
360
|
+
await markStatus(paths, SESSION_STATUS.RUNNING);
|
|
361
|
+
return buildSessionResponse(paths, {
|
|
362
|
+
preconditions
|
|
363
|
+
});
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
async function renderImplementationPrompt(paths) {
|
|
367
|
+
const issueText = await readTrimmedFile(path.join(paths.sessionRoot, "issue.md"));
|
|
368
|
+
const issueUrl = await readTrimmedFile(path.join(paths.sessionRoot, "issue_url"));
|
|
369
|
+
const prompt = await renderPrompt(paths, "implement_issue.md", {
|
|
370
|
+
issue_text: issueText,
|
|
371
|
+
issue_url: issueUrl
|
|
372
|
+
});
|
|
373
|
+
await writeTextFile(path.join(paths.sessionRoot, "prompt.md"), prompt);
|
|
374
|
+
await writeReceipt(paths, "implementation_prompt_rendered", "Rendered the implementation prompt.");
|
|
375
|
+
await markStatus(paths, SESSION_STATUS.WAITING_FOR_USER);
|
|
376
|
+
return buildSessionResponse(paths, {
|
|
377
|
+
prompt,
|
|
378
|
+
status: SESSION_STATUS.WAITING_FOR_USER
|
|
379
|
+
});
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
async function worktreeStatus(worktree) {
|
|
383
|
+
const result = await runGitInWorktree(worktree, ["status", "--porcelain=v1"]);
|
|
384
|
+
if (!result.ok) {
|
|
385
|
+
return {
|
|
386
|
+
ok: false,
|
|
387
|
+
changedFiles: [],
|
|
388
|
+
output: result.output
|
|
389
|
+
};
|
|
390
|
+
}
|
|
391
|
+
const changedFiles = result.stdout.split(/\r?\n/u).map((line) => line.trim()).filter(Boolean);
|
|
392
|
+
return {
|
|
393
|
+
ok: true,
|
|
394
|
+
changedFiles,
|
|
395
|
+
output: result.stdout
|
|
396
|
+
};
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
async function detectChanges(paths) {
|
|
400
|
+
const status = await worktreeStatus(paths.worktree);
|
|
401
|
+
if (!status.ok) {
|
|
402
|
+
return failSession(paths, {
|
|
403
|
+
code: "git_status_failed",
|
|
404
|
+
message: status.output || "Failed to inspect worktree changes.",
|
|
405
|
+
repairCommand: `git -C ${paths.worktree} status --short`
|
|
406
|
+
});
|
|
407
|
+
}
|
|
408
|
+
if (status.changedFiles.length < 1) {
|
|
409
|
+
return failSession(paths, {
|
|
410
|
+
code: "changes_missing",
|
|
411
|
+
message: "No worktree changes found. Paste the implementation prompt into Codex and retry after changes exist.",
|
|
412
|
+
repairCommand: `jskit session ${paths.sessionId} step`
|
|
413
|
+
});
|
|
414
|
+
}
|
|
415
|
+
await writeReceipt(paths, "implementation_changes_detected", `Detected ${status.changedFiles.length} changed file entries.`);
|
|
416
|
+
await markStatus(paths, SESSION_STATUS.RUNNING);
|
|
417
|
+
return buildSessionResponse(paths);
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
async function commitWorktree(paths, {
|
|
421
|
+
message,
|
|
422
|
+
allowNoChanges = false
|
|
423
|
+
} = {}) {
|
|
424
|
+
const status = await worktreeStatus(paths.worktree);
|
|
425
|
+
if (!status.ok) {
|
|
426
|
+
return {
|
|
427
|
+
ok: false,
|
|
428
|
+
output: status.output
|
|
429
|
+
};
|
|
430
|
+
}
|
|
431
|
+
if (status.changedFiles.length < 1) {
|
|
432
|
+
return {
|
|
433
|
+
changedFiles: [],
|
|
434
|
+
ok: allowNoChanges,
|
|
435
|
+
output: allowNoChanges ? "No changes to commit." : "No changes found."
|
|
436
|
+
};
|
|
437
|
+
}
|
|
438
|
+
const addResult = await runGitInWorktree(paths.worktree, ["add", "."]);
|
|
439
|
+
if (!addResult.ok) {
|
|
440
|
+
return {
|
|
441
|
+
ok: false,
|
|
442
|
+
output: addResult.output
|
|
443
|
+
};
|
|
444
|
+
}
|
|
445
|
+
const commitResult = await runGitInWorktree(paths.worktree, ["commit", "-m", message], {
|
|
446
|
+
timeout: 30000
|
|
447
|
+
});
|
|
448
|
+
if (!commitResult.ok) {
|
|
449
|
+
return {
|
|
450
|
+
ok: false,
|
|
451
|
+
output: commitResult.output
|
|
452
|
+
};
|
|
453
|
+
}
|
|
454
|
+
return {
|
|
455
|
+
changedFiles: status.changedFiles,
|
|
456
|
+
ok: true,
|
|
457
|
+
output: commitResult.output
|
|
458
|
+
};
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
async function commitImplementation(paths) {
|
|
462
|
+
const result = await commitWorktree(paths, {
|
|
463
|
+
message: `Implement JSKIT session ${paths.sessionId}`
|
|
464
|
+
});
|
|
465
|
+
if (!result.ok) {
|
|
466
|
+
return failSession(paths, {
|
|
467
|
+
code: "commit_failed",
|
|
468
|
+
message: result.output || "Failed to commit implementation changes.",
|
|
469
|
+
repairCommand: `git -C ${paths.worktree} status --short`
|
|
470
|
+
});
|
|
471
|
+
}
|
|
472
|
+
await writeReceipt(paths, "implementation_changes_committed", `Committed implementation changes for ${paths.sessionId}.`);
|
|
473
|
+
await markStatus(paths, SESSION_STATUS.RUNNING);
|
|
474
|
+
return buildSessionResponse(paths);
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
async function changedFilesFromLastCommit(paths) {
|
|
478
|
+
const result = await runGitInWorktree(paths.worktree, ["show", "--name-only", "--format=", "HEAD"]);
|
|
479
|
+
if (!result.ok) {
|
|
480
|
+
return "";
|
|
481
|
+
}
|
|
482
|
+
return result.stdout.split(/\r?\n/u).map((line) => line.trim()).filter(Boolean).join("\n");
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
function reviewPromptStepId(passNumber) {
|
|
486
|
+
return passNumber === 1
|
|
487
|
+
? "initial_review_prompt_rendered"
|
|
488
|
+
: passNumber === 2
|
|
489
|
+
? "followup_review_prompt_rendered"
|
|
490
|
+
: "final_review_prompt_rendered";
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
async function renderReviewPrompt(paths, passNumber) {
|
|
494
|
+
const prompt = await renderPrompt(paths, "review_changes.md", {
|
|
495
|
+
changed_files: await changedFilesFromLastCommit(paths),
|
|
496
|
+
review_pass: String(passNumber),
|
|
497
|
+
review_pass_note: passNumber >= 3 ? "This is the final review pass before doctor and PR steps." : ""
|
|
498
|
+
});
|
|
499
|
+
await writeTextFile(path.join(paths.sessionRoot, "prompt.md"), prompt);
|
|
500
|
+
await writeReceipt(paths, reviewPromptStepId(passNumber), `Rendered review prompt pass ${passNumber}.`);
|
|
501
|
+
await markStatus(paths, SESSION_STATUS.WAITING_FOR_USER);
|
|
502
|
+
return buildSessionResponse(paths, {
|
|
503
|
+
prompt,
|
|
504
|
+
status: SESSION_STATUS.WAITING_FOR_USER
|
|
505
|
+
});
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
function reviewChangesStepId(passNumber) {
|
|
509
|
+
return passNumber === 1
|
|
510
|
+
? "initial_review_changes_detected"
|
|
511
|
+
: passNumber === 2
|
|
512
|
+
? "followup_review_changes_detected"
|
|
513
|
+
: "final_review_changes_detected";
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
async function detectAndCommitReviewChanges(paths, passNumber) {
|
|
517
|
+
const result = await commitWorktree(paths, {
|
|
518
|
+
allowNoChanges: true,
|
|
519
|
+
message: `Apply review pass ${passNumber} for ${paths.sessionId}`
|
|
520
|
+
});
|
|
521
|
+
if (!result.ok) {
|
|
522
|
+
return failSession(paths, {
|
|
523
|
+
code: "review_commit_failed",
|
|
524
|
+
message: result.output || `Failed to commit review pass ${passNumber}.`,
|
|
525
|
+
repairCommand: `git -C ${paths.worktree} status --short`
|
|
526
|
+
});
|
|
527
|
+
}
|
|
528
|
+
const message = result.changedFiles?.length
|
|
529
|
+
? `Committed review pass ${passNumber} changes.`
|
|
530
|
+
: `No review pass ${passNumber} changes detected.`;
|
|
531
|
+
await writeReceipt(paths, reviewChangesStepId(passNumber), message);
|
|
532
|
+
await markStatus(paths, SESSION_STATUS.RUNNING);
|
|
533
|
+
return buildSessionResponse(paths);
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
function userCheckStepId(passNumber) {
|
|
537
|
+
return passNumber === 1
|
|
538
|
+
? "initial_user_check_completed"
|
|
539
|
+
: passNumber === 2
|
|
540
|
+
? "followup_user_check_completed"
|
|
541
|
+
: "final_user_check_completed";
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
async function userCheck(paths, passNumber, options = {}) {
|
|
545
|
+
const result = normalizeText(options.userCheck || options["user-check"]).toLowerCase();
|
|
546
|
+
if (result === "passed" || result === "pass" || result === "ok" || result === "yes") {
|
|
547
|
+
await writeReceipt(paths, userCheckStepId(passNumber), `User confirmed check ${passNumber} passed.`);
|
|
548
|
+
await markStatus(paths, SESSION_STATUS.RUNNING);
|
|
549
|
+
return buildSessionResponse(paths);
|
|
550
|
+
}
|
|
551
|
+
if (result === "failed" || result === "fail" || result === "no") {
|
|
552
|
+
return failSession(paths, {
|
|
553
|
+
code: "user_check_failed",
|
|
554
|
+
message: `User check ${passNumber} was reported as failed. Continue in Codex, then retry this step with --user-check passed.`,
|
|
555
|
+
repairCommand: `jskit session ${paths.sessionId} step --user-check passed`
|
|
556
|
+
});
|
|
557
|
+
}
|
|
558
|
+
const prompt = await renderPrompt(paths, "user_check.md", {
|
|
559
|
+
review_pass: String(passNumber)
|
|
560
|
+
});
|
|
561
|
+
await writeTextFile(path.join(paths.sessionRoot, "prompt.md"), prompt);
|
|
562
|
+
await markStatus(paths, SESSION_STATUS.WAITING_FOR_USER);
|
|
563
|
+
return buildSessionResponse(paths, {
|
|
564
|
+
prompt,
|
|
565
|
+
status: SESSION_STATUS.WAITING_FOR_USER
|
|
566
|
+
});
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
async function readPackageJson(root) {
|
|
570
|
+
try {
|
|
571
|
+
return JSON.parse(await readFile(path.join(root, "package.json"), "utf8"));
|
|
572
|
+
} catch {
|
|
573
|
+
return {};
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
async function doctorCommandForWorktree(worktree) {
|
|
578
|
+
const packageJson = await readPackageJson(worktree);
|
|
579
|
+
const scripts = packageJson && typeof packageJson.scripts === "object" ? packageJson.scripts : {};
|
|
580
|
+
if (scripts["verify:local"]) {
|
|
581
|
+
return ["npm", ["run", "verify:local"]];
|
|
582
|
+
}
|
|
583
|
+
if (scripts.verify) {
|
|
584
|
+
return ["npm", ["run", "verify"]];
|
|
585
|
+
}
|
|
586
|
+
return ["npx", ["jskit", "app", "verify"]];
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
async function runDoctor(paths) {
|
|
590
|
+
const [command, args] = await doctorCommandForWorktree(paths.worktree);
|
|
591
|
+
const result = await runCommand(command, args, {
|
|
592
|
+
cwd: paths.worktree,
|
|
593
|
+
timeout: 1000 * 60 * 15
|
|
594
|
+
});
|
|
595
|
+
await writeTextFile(path.join(paths.sessionRoot, "doctor.log"), result.output);
|
|
596
|
+
if (!result.ok) {
|
|
597
|
+
const prompt = await renderPrompt(paths, "doctor_failure.md", {
|
|
598
|
+
doctor_output: result.output
|
|
599
|
+
});
|
|
600
|
+
await writeTextFile(path.join(paths.sessionRoot, "prompt.md"), prompt);
|
|
601
|
+
return failSession(paths, {
|
|
602
|
+
code: "doctor_failed",
|
|
603
|
+
message: "Doctor/verification command failed. Paste the failure prompt into Codex, then rerun this step.",
|
|
604
|
+
repairCommand: `${command} ${args.join(" ")}`,
|
|
605
|
+
prompt
|
|
606
|
+
});
|
|
607
|
+
}
|
|
608
|
+
await writeReceipt(paths, "doctor_run", `Doctor command passed: ${command} ${args.join(" ")}.`);
|
|
609
|
+
await markStatus(paths, SESSION_STATUS.RUNNING);
|
|
610
|
+
return buildSessionResponse(paths);
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
async function pushBranch(paths) {
|
|
614
|
+
const result = await runGitInWorktree(paths.worktree, ["push", "-u", "origin", "HEAD"], {
|
|
615
|
+
timeout: 1000 * 60 * 5
|
|
616
|
+
});
|
|
617
|
+
if (!result.ok) {
|
|
618
|
+
return failSession(paths, {
|
|
619
|
+
code: "branch_push_failed",
|
|
620
|
+
message: result.output || "Failed to push session branch.",
|
|
621
|
+
repairCommand: `git -C ${paths.worktree} push -u origin HEAD`
|
|
622
|
+
});
|
|
623
|
+
}
|
|
624
|
+
await writeReceipt(paths, "branch_pushed", `Pushed branch ${paths.branch}.`);
|
|
625
|
+
await markStatus(paths, SESSION_STATUS.RUNNING);
|
|
626
|
+
return buildSessionResponse(paths);
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
function issueNumberFromUrl(issueUrl) {
|
|
630
|
+
const match = /\/issues\/(\d+)(?:\b|$)/u.exec(String(issueUrl || ""));
|
|
631
|
+
return match ? match[1] : "";
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
async function createPr(paths) {
|
|
635
|
+
const issueUrl = await readTrimmedFile(path.join(paths.sessionRoot, "issue_url"));
|
|
636
|
+
const issueText = await readTrimmedFile(path.join(paths.sessionRoot, "issue.md"));
|
|
637
|
+
const issueNumber = issueNumberFromUrl(issueUrl);
|
|
638
|
+
const body = [
|
|
639
|
+
issueNumber ? `Closes #${issueNumber}` : "",
|
|
640
|
+
"",
|
|
641
|
+
issueText
|
|
642
|
+
].join("\n").trim();
|
|
643
|
+
const bodyPath = path.join(paths.sessionRoot, "pr_body.md");
|
|
644
|
+
await writeTextFile(bodyPath, body);
|
|
645
|
+
const result = await runCommand("gh", [
|
|
646
|
+
"pr",
|
|
647
|
+
"create",
|
|
648
|
+
"--title",
|
|
649
|
+
titleFromIssue(issueText),
|
|
650
|
+
"--body-file",
|
|
651
|
+
bodyPath
|
|
652
|
+
], {
|
|
653
|
+
cwd: paths.worktree,
|
|
654
|
+
timeout: 1000 * 60
|
|
655
|
+
});
|
|
656
|
+
if (!result.ok || !result.stdout) {
|
|
657
|
+
const prompt = await renderPrompt(paths, "pr_failure.md", {
|
|
658
|
+
doctor_output: result.output
|
|
659
|
+
});
|
|
660
|
+
await writeTextFile(path.join(paths.sessionRoot, "prompt.md"), prompt);
|
|
661
|
+
return failSession(paths, {
|
|
662
|
+
code: "pr_create_failed",
|
|
663
|
+
message: result.output || "Failed to create PR.",
|
|
664
|
+
repairCommand: "gh pr create",
|
|
665
|
+
prompt
|
|
666
|
+
});
|
|
667
|
+
}
|
|
668
|
+
const prUrl = result.stdout.split(/\r?\n/u).map((line) => line.trim()).find(Boolean) || result.stdout;
|
|
669
|
+
await writeTextFile(path.join(paths.sessionRoot, "pr_url"), prUrl);
|
|
670
|
+
await writeReceipt(paths, "pr_created", `Created PR ${prUrl}.`);
|
|
671
|
+
await markStatus(paths, SESSION_STATUS.RUNNING);
|
|
672
|
+
return buildSessionResponse(paths);
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
async function mergePr(paths) {
|
|
676
|
+
const prUrl = await readTrimmedFile(path.join(paths.sessionRoot, "pr_url"));
|
|
677
|
+
const mergeResult = await runCommand("gh", ["pr", "merge", prUrl, "--merge", "--delete-branch"], {
|
|
678
|
+
cwd: paths.worktree,
|
|
679
|
+
timeout: 1000 * 60 * 5
|
|
680
|
+
});
|
|
681
|
+
if (!mergeResult.ok) {
|
|
682
|
+
const prompt = await renderPrompt(paths, "pr_failure.md", {
|
|
683
|
+
doctor_output: mergeResult.output
|
|
684
|
+
});
|
|
685
|
+
await writeTextFile(path.join(paths.sessionRoot, "prompt.md"), prompt);
|
|
686
|
+
return failSession(paths, {
|
|
687
|
+
code: "pr_merge_failed",
|
|
688
|
+
message: mergeResult.output || "Failed to merge PR.",
|
|
689
|
+
repairCommand: `gh pr merge ${prUrl} --merge --delete-branch`,
|
|
690
|
+
prompt
|
|
691
|
+
});
|
|
692
|
+
}
|
|
693
|
+
const issueUrl = await readTrimmedFile(path.join(paths.sessionRoot, "issue_url"));
|
|
694
|
+
if (issueUrl) {
|
|
695
|
+
await runCommand("gh", ["issue", "close", issueUrl, "--comment", `Merged PR ${prUrl}.`], {
|
|
696
|
+
cwd: paths.worktree,
|
|
697
|
+
timeout: 1000 * 60
|
|
698
|
+
});
|
|
699
|
+
}
|
|
700
|
+
await writeReceipt(paths, "pr_merged", `Merged PR ${prUrl}.`);
|
|
701
|
+
await markStatus(paths, SESSION_STATUS.RUNNING);
|
|
702
|
+
return buildSessionResponse(paths);
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
async function removeWorktree(paths) {
|
|
706
|
+
if (await hasWorktree(paths)) {
|
|
707
|
+
const result = await runGit(paths.targetRoot, ["worktree", "remove", paths.worktree], {
|
|
708
|
+
timeout: 1000 * 60
|
|
709
|
+
});
|
|
710
|
+
if (!result.ok) {
|
|
711
|
+
return failSession(paths, {
|
|
712
|
+
code: "worktree_remove_failed",
|
|
713
|
+
message: result.output || "Failed to remove worktree.",
|
|
714
|
+
repairCommand: `git worktree remove ${paths.worktree}`
|
|
715
|
+
});
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
await writeReceipt(paths, "worktree_removed", `Removed worktree ${paths.worktree}.`);
|
|
719
|
+
await markStatus(paths, SESSION_STATUS.RUNNING);
|
|
720
|
+
return buildSessionResponse(paths);
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
async function finishSession(paths) {
|
|
724
|
+
const prUrl = await readTrimmedFile(path.join(paths.sessionRoot, "pr_url"));
|
|
725
|
+
const issueUrl = await readTrimmedFile(path.join(paths.sessionRoot, "issue_url"));
|
|
726
|
+
const codexThreadId = await readTrimmedFile(path.join(paths.sessionRoot, "codex_thread_id"));
|
|
727
|
+
const prompt = await renderPrompt(paths, "final_comment.md", {
|
|
728
|
+
codex_thread_id: codexThreadId,
|
|
729
|
+
issue_url: issueUrl,
|
|
730
|
+
pr_url: prUrl,
|
|
731
|
+
transcript_log: path.join(paths.completedSessionRoot, "transcript.log")
|
|
732
|
+
});
|
|
733
|
+
await writeTextFile(path.join(paths.sessionRoot, "final_comment.md"), prompt);
|
|
734
|
+
if (issueUrl) {
|
|
735
|
+
await runCommand("gh", ["issue", "comment", issueUrl, "--body-file", path.join(paths.sessionRoot, "final_comment.md")], {
|
|
736
|
+
cwd: paths.targetRoot,
|
|
737
|
+
timeout: 1000 * 60
|
|
738
|
+
});
|
|
739
|
+
}
|
|
740
|
+
await writeReceipt(paths, "session_finished", `Finished session ${paths.sessionId}.`);
|
|
741
|
+
await markStatus(paths, SESSION_STATUS.FINISHED);
|
|
742
|
+
await markCurrentStep(paths, "");
|
|
743
|
+
const archivedPaths = await archiveSession(paths, "completed");
|
|
744
|
+
return buildSessionResponse(archivedPaths, {
|
|
745
|
+
status: SESSION_STATUS.FINISHED
|
|
746
|
+
});
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
const STEP_RUNNERS = Object.freeze({
|
|
750
|
+
worktree_created: createWorktree,
|
|
751
|
+
issue_prompt_rendered: renderIssuePrompt,
|
|
752
|
+
issue_drafted: draftIssue,
|
|
753
|
+
issue_created: createIssue,
|
|
754
|
+
implementation_prompt_rendered: renderImplementationPrompt,
|
|
755
|
+
implementation_changes_detected: detectChanges,
|
|
756
|
+
implementation_changes_committed: commitImplementation,
|
|
757
|
+
initial_review_prompt_rendered: (paths) => renderReviewPrompt(paths, 1),
|
|
758
|
+
initial_review_changes_detected: (paths) => detectAndCommitReviewChanges(paths, 1),
|
|
759
|
+
initial_user_check_completed: (paths, options) => userCheck(paths, 1, options),
|
|
760
|
+
followup_review_prompt_rendered: (paths) => renderReviewPrompt(paths, 2),
|
|
761
|
+
followup_review_changes_detected: (paths) => detectAndCommitReviewChanges(paths, 2),
|
|
762
|
+
followup_user_check_completed: (paths, options) => userCheck(paths, 2, options),
|
|
763
|
+
final_review_prompt_rendered: (paths) => renderReviewPrompt(paths, 3),
|
|
764
|
+
final_review_changes_detected: (paths) => detectAndCommitReviewChanges(paths, 3),
|
|
765
|
+
final_user_check_completed: (paths, options) => userCheck(paths, 3, options),
|
|
766
|
+
doctor_run: runDoctor,
|
|
767
|
+
branch_pushed: pushBranch,
|
|
768
|
+
pr_created: createPr,
|
|
769
|
+
pr_merged: mergePr,
|
|
770
|
+
worktree_removed: removeWorktree,
|
|
771
|
+
session_finished: finishSession
|
|
772
|
+
});
|
|
773
|
+
|
|
774
|
+
const PRECONDITION_RUNNERS = Object.freeze({
|
|
775
|
+
git_current_branch: (paths) => assertGitCurrentBranch(paths.targetRoot),
|
|
776
|
+
git_repository: (paths) => assertGitRepository(paths.targetRoot),
|
|
777
|
+
github_auth: (paths) => assertGhAuth(paths.targetRoot),
|
|
778
|
+
github_origin: (paths) => assertGithubOrigin(paths.targetRoot),
|
|
779
|
+
issue_artifacts: assertIssueArtifacts,
|
|
780
|
+
issue_text_exists: assertIssueTextExists,
|
|
781
|
+
pr_url_exists: assertPrUrlExists,
|
|
782
|
+
session_exists: assertSessionExists,
|
|
783
|
+
worktree_exists: assertWorktreeExists
|
|
784
|
+
});
|
|
785
|
+
|
|
786
|
+
async function runNamedPreconditions(paths, names = []) {
|
|
787
|
+
return applyPreconditions(
|
|
788
|
+
paths,
|
|
789
|
+
names.map((name) => {
|
|
790
|
+
return async () => PRECONDITION_RUNNERS[name](paths);
|
|
791
|
+
})
|
|
792
|
+
);
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
async function runSessionStep({
|
|
796
|
+
targetRoot = process.cwd(),
|
|
797
|
+
sessionId,
|
|
798
|
+
options = {}
|
|
799
|
+
} = {}) {
|
|
800
|
+
return withExistingSession({ targetRoot, sessionId }, async (paths) => {
|
|
801
|
+
const artifacts = await readSessionArtifacts(paths);
|
|
802
|
+
if (artifacts.status === SESSION_STATUS.FINISHED || artifacts.status === SESSION_STATUS.ABANDONED) {
|
|
803
|
+
return buildSessionResponse(paths, {
|
|
804
|
+
ok: true,
|
|
805
|
+
status: artifacts.status
|
|
806
|
+
});
|
|
807
|
+
}
|
|
808
|
+
const nextStep = artifacts.nextStep;
|
|
809
|
+
if (!nextStep) {
|
|
810
|
+
return finishSession(paths);
|
|
811
|
+
}
|
|
812
|
+
if (nextStep === "session_created") {
|
|
813
|
+
return failSession(paths, {
|
|
814
|
+
code: "session_not_initialized",
|
|
815
|
+
message: "Session exists but is missing its creation receipt.",
|
|
816
|
+
repairCommand: "jskit session create"
|
|
817
|
+
});
|
|
818
|
+
}
|
|
819
|
+
const runner = STEP_RUNNERS[nextStep];
|
|
820
|
+
if (typeof runner !== "function") {
|
|
821
|
+
return failSession(paths, {
|
|
822
|
+
code: "step_not_implemented",
|
|
823
|
+
message: `No runner exists for step ${nextStep}.`,
|
|
824
|
+
status: SESSION_STATUS.FAILED
|
|
825
|
+
});
|
|
826
|
+
}
|
|
827
|
+
const stepPreconditions = await runNamedPreconditions(paths, STEP_PRECONDITION_NAMES[nextStep] || ["session_exists"]);
|
|
828
|
+
if (!stepPreconditions.ok) {
|
|
829
|
+
return failSession(paths, {
|
|
830
|
+
...stepPreconditions.error,
|
|
831
|
+
preconditions: stepPreconditions.preconditions
|
|
832
|
+
});
|
|
833
|
+
}
|
|
834
|
+
return runner(paths, options, {
|
|
835
|
+
preconditions: stepPreconditions.preconditions
|
|
836
|
+
});
|
|
837
|
+
});
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
async function abandonSession({
|
|
841
|
+
targetRoot = process.cwd(),
|
|
842
|
+
sessionId
|
|
843
|
+
} = {}) {
|
|
844
|
+
return withExistingSession({ targetRoot, sessionId }, async (paths) => {
|
|
845
|
+
const artifacts = await readSessionArtifacts(paths);
|
|
846
|
+
if (artifacts.status === SESSION_STATUS.FINISHED || artifacts.status === SESSION_STATUS.ABANDONED) {
|
|
847
|
+
return buildSessionResponse(paths, {
|
|
848
|
+
status: artifacts.status
|
|
849
|
+
});
|
|
850
|
+
}
|
|
851
|
+
const issueUrl = await readTrimmedFile(path.join(paths.sessionRoot, "issue_url"));
|
|
852
|
+
if (issueUrl) {
|
|
853
|
+
await runCommand("gh", ["issue", "close", issueUrl, "--comment", `Abandoned JSKIT Studio session ${paths.sessionId}.`], {
|
|
854
|
+
cwd: paths.targetRoot,
|
|
855
|
+
timeout: 1000 * 60
|
|
856
|
+
});
|
|
857
|
+
}
|
|
858
|
+
if (await hasWorktree(paths)) {
|
|
859
|
+
await runGit(paths.targetRoot, ["worktree", "remove", "--force", paths.worktree], {
|
|
860
|
+
timeout: 1000 * 60
|
|
861
|
+
});
|
|
862
|
+
}
|
|
863
|
+
await writeTextFile(
|
|
864
|
+
path.join(paths.sessionRoot, "steps", "abandoned"),
|
|
865
|
+
`${timestampForReceipt()}\nAbandoned session ${paths.sessionId}.`
|
|
866
|
+
);
|
|
867
|
+
await markStatus(paths, SESSION_STATUS.ABANDONED);
|
|
868
|
+
await markCurrentStep(paths, "");
|
|
869
|
+
const archivedPaths = await archiveSession(paths, "abandoned");
|
|
870
|
+
return buildSessionResponse(archivedPaths, {
|
|
871
|
+
status: SESSION_STATUS.ABANDONED
|
|
872
|
+
});
|
|
873
|
+
});
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
async function adoptCodexThreadId({
|
|
877
|
+
targetRoot = process.cwd(),
|
|
878
|
+
sessionId,
|
|
879
|
+
codexThreadId
|
|
880
|
+
} = {}) {
|
|
881
|
+
if (!isValidSessionId(sessionId)) {
|
|
882
|
+
return invalidSessionIdResponse({ targetRoot, sessionId });
|
|
883
|
+
}
|
|
884
|
+
const normalizedThreadId = normalizeText(codexThreadId);
|
|
885
|
+
if (!normalizedThreadId) {
|
|
886
|
+
return failSession(resolveSessionPaths({ targetRoot, sessionId }), {
|
|
887
|
+
code: "codex_thread_id_required",
|
|
888
|
+
message: "Codex thread id is required."
|
|
889
|
+
});
|
|
890
|
+
}
|
|
891
|
+
return withExistingSession({ targetRoot, sessionId }, async (paths) => {
|
|
892
|
+
if (paths.archive && paths.archive !== "active") {
|
|
893
|
+
return buildSessionResponse(paths, {
|
|
894
|
+
ok: false,
|
|
895
|
+
errors: [
|
|
896
|
+
createError({
|
|
897
|
+
code: "session_archived_read_only",
|
|
898
|
+
message: `Session ${paths.sessionId} is archived and cannot be mutated.`
|
|
899
|
+
})
|
|
900
|
+
]
|
|
901
|
+
});
|
|
902
|
+
}
|
|
903
|
+
await writeTextFile(path.join(paths.sessionRoot, "codex_thread_id"), normalizedThreadId);
|
|
904
|
+
return buildSessionResponse(paths);
|
|
905
|
+
});
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
export {
|
|
909
|
+
SESSION_STATUS,
|
|
910
|
+
STEP_DEFINITIONS,
|
|
911
|
+
STEP_IDS,
|
|
912
|
+
STEP_PRECONDITION_NAMES,
|
|
913
|
+
abandonSession,
|
|
914
|
+
adoptCodexThreadId,
|
|
915
|
+
buildSessionResponse,
|
|
916
|
+
buildSessionErrorResponse,
|
|
917
|
+
createSession,
|
|
918
|
+
createSessionId,
|
|
919
|
+
extractIssueText,
|
|
920
|
+
inspectSession,
|
|
921
|
+
inspectSessionDetails,
|
|
922
|
+
isValidSessionId,
|
|
923
|
+
listSessions,
|
|
924
|
+
renderTemplate,
|
|
925
|
+
resolveSessionPaths,
|
|
926
|
+
runSessionStep
|
|
927
|
+
};
|