@jskit-ai/jskit-cli 0.2.79 → 0.2.81
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 +4 -4
- package/src/server/appBlueprint.js +126 -0
- package/src/server/commandHandlers/blueprint.js +151 -0
- package/src/server/commandHandlers/session.js +305 -0
- package/src/server/core/argParser.js +14 -2
- package/src/server/core/commandCatalog.js +91 -1
- package/src/server/core/createCommandHandlers.js +7 -1
- package/src/server/index.js +2 -0
- package/src/server/sessionRuntime/constants.js +320 -0
- package/src/server/sessionRuntime/io.js +97 -0
- package/src/server/sessionRuntime/paths.js +163 -0
- package/src/server/sessionRuntime/preconditions.js +362 -0
- package/src/server/sessionRuntime/promptRenderer.js +41 -0
- package/src/server/sessionRuntime/prompts/app_blueprint.md +52 -0
- package/src/server/sessionRuntime/prompts/doctor_failure.md +26 -0
- package/src/server/sessionRuntime/prompts/execute_plan.md +35 -0
- package/src/server/sessionRuntime/prompts/final_comment.md +8 -0
- package/src/server/sessionRuntime/prompts/new_issue.md +31 -0
- package/src/server/sessionRuntime/prompts/plan_issue.md +50 -0
- package/src/server/sessionRuntime/prompts/pr_failure.md +28 -0
- package/src/server/sessionRuntime/prompts/review_changes.md +43 -0
- package/src/server/sessionRuntime/prompts/user_check.md +13 -0
- package/src/server/sessionRuntime/responses.js +442 -0
- package/src/server/sessionRuntime/worktrees.js +31 -0
- package/src/server/sessionRuntime.js +1218 -0
|
@@ -0,0 +1,1218 @@
|
|
|
1
|
+
import {
|
|
2
|
+
mkdir,
|
|
3
|
+
readFile,
|
|
4
|
+
readdir,
|
|
5
|
+
rmdir
|
|
6
|
+
} from "node:fs/promises";
|
|
7
|
+
import path from "node:path";
|
|
8
|
+
import {
|
|
9
|
+
PLAN_EXECUTION_CODEX_HANDOFF,
|
|
10
|
+
REVIEW_EXECUTION_CODEX_HANDOFF,
|
|
11
|
+
SESSION_STATUS,
|
|
12
|
+
STEP_DEFINITIONS,
|
|
13
|
+
STEP_IDS,
|
|
14
|
+
STEP_PRECONDITION_NAMES
|
|
15
|
+
} from "./sessionRuntime/constants.js";
|
|
16
|
+
import {
|
|
17
|
+
normalizeText,
|
|
18
|
+
readTextIfExists,
|
|
19
|
+
readTrimmedFile,
|
|
20
|
+
runCommand,
|
|
21
|
+
runGit,
|
|
22
|
+
runGitInWorktree,
|
|
23
|
+
timestampForReceipt,
|
|
24
|
+
writeTextFile
|
|
25
|
+
} from "./sessionRuntime/io.js";
|
|
26
|
+
import {
|
|
27
|
+
archiveSession,
|
|
28
|
+
createAvailableSessionId,
|
|
29
|
+
createSessionId,
|
|
30
|
+
isValidSessionId,
|
|
31
|
+
resolveExistingSessionRoot,
|
|
32
|
+
resolveSessionPaths,
|
|
33
|
+
pathsForExistingSession
|
|
34
|
+
} from "./sessionRuntime/paths.js";
|
|
35
|
+
import {
|
|
36
|
+
buildSessionErrorResponse,
|
|
37
|
+
buildSessionResponse,
|
|
38
|
+
buildStepDefinitions,
|
|
39
|
+
createError,
|
|
40
|
+
failSession,
|
|
41
|
+
markCurrentStep,
|
|
42
|
+
markStatus,
|
|
43
|
+
readReceiptSteps,
|
|
44
|
+
readSessionArtifacts,
|
|
45
|
+
writeReceipt
|
|
46
|
+
} from "./sessionRuntime/responses.js";
|
|
47
|
+
import {
|
|
48
|
+
applyPreconditions,
|
|
49
|
+
assertGhAuth,
|
|
50
|
+
assertGitCurrentBranch,
|
|
51
|
+
assertGitRepository,
|
|
52
|
+
assertGithubOrigin,
|
|
53
|
+
assertIssueTextExists,
|
|
54
|
+
assertIssueUrlExists,
|
|
55
|
+
assertPrUrlExists,
|
|
56
|
+
assertSessionExists,
|
|
57
|
+
assertTargetRootWritable,
|
|
58
|
+
assertWorktreeExists,
|
|
59
|
+
ensureStudioGitExclude,
|
|
60
|
+
hasWorktree
|
|
61
|
+
} from "./sessionRuntime/preconditions.js";
|
|
62
|
+
import {
|
|
63
|
+
renderPrompt,
|
|
64
|
+
renderTemplate
|
|
65
|
+
} from "./sessionRuntime/promptRenderer.js";
|
|
66
|
+
|
|
67
|
+
function invalidSessionIdError(sessionId = "") {
|
|
68
|
+
return createError({
|
|
69
|
+
code: "invalid_session_id",
|
|
70
|
+
message: `Invalid session id "${sessionId}". Expected YYYY-MM-DD_HH-MM-SS.`
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function invalidSessionIdResponse({
|
|
75
|
+
targetRoot,
|
|
76
|
+
sessionId
|
|
77
|
+
}) {
|
|
78
|
+
return buildSessionErrorResponse({
|
|
79
|
+
targetRoot,
|
|
80
|
+
sessionId,
|
|
81
|
+
errors: [invalidSessionIdError(sessionId)]
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async function existingSessionContext({
|
|
86
|
+
targetRoot = process.cwd(),
|
|
87
|
+
sessionId
|
|
88
|
+
} = {}) {
|
|
89
|
+
if (!isValidSessionId(sessionId)) {
|
|
90
|
+
return {
|
|
91
|
+
ok: false,
|
|
92
|
+
response: invalidSessionIdResponse({ targetRoot, sessionId })
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const paths = await pathsForExistingSession(resolveSessionPaths({ targetRoot, sessionId }));
|
|
97
|
+
const preconditions = await applyPreconditions(paths, [
|
|
98
|
+
() => assertSessionExists(paths)
|
|
99
|
+
]);
|
|
100
|
+
if (!preconditions.ok) {
|
|
101
|
+
return {
|
|
102
|
+
ok: false,
|
|
103
|
+
response: await failSession(paths, {
|
|
104
|
+
...preconditions.error,
|
|
105
|
+
preconditions: preconditions.preconditions
|
|
106
|
+
})
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return {
|
|
111
|
+
ok: true,
|
|
112
|
+
paths,
|
|
113
|
+
preconditions: preconditions.preconditions
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
async function withExistingSession(input, handler) {
|
|
118
|
+
const context = await existingSessionContext(input);
|
|
119
|
+
if (!context.ok) {
|
|
120
|
+
return context.response;
|
|
121
|
+
}
|
|
122
|
+
return handler(context.paths, {
|
|
123
|
+
preconditions: context.preconditions
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function extractMarkedText(value = "", marker = "") {
|
|
128
|
+
const text = normalizeText(value);
|
|
129
|
+
const normalizedMarker = normalizeText(marker);
|
|
130
|
+
if (!normalizedMarker) {
|
|
131
|
+
return "";
|
|
132
|
+
}
|
|
133
|
+
const pattern = new RegExp(`\\[${normalizedMarker}\\]([\\s\\S]*?)\\[/${normalizedMarker}\\]`, "u");
|
|
134
|
+
const match = pattern.exec(text);
|
|
135
|
+
return normalizeText(match ? match[1] : "");
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function extractIssueTitle(value = "") {
|
|
139
|
+
return extractMarkedText(value, "issue_title");
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function extractIssueText(value = "") {
|
|
143
|
+
return extractMarkedText(value, "issue_text") || normalizeText(value);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function extractPlanText(value = "") {
|
|
147
|
+
return extractMarkedText(value, "plan") || normalizeText(value);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
async function writePromptArtifact(paths, fileName, prompt) {
|
|
151
|
+
await writeTextFile(path.join(paths.sessionRoot, "prompts", fileName), prompt);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
async function createSession({
|
|
155
|
+
targetRoot = process.cwd(),
|
|
156
|
+
sessionId = "",
|
|
157
|
+
now = new Date()
|
|
158
|
+
} = {}) {
|
|
159
|
+
if (sessionId && !isValidSessionId(sessionId)) {
|
|
160
|
+
return invalidSessionIdResponse({ targetRoot, sessionId });
|
|
161
|
+
}
|
|
162
|
+
const initialPaths = resolveSessionPaths({
|
|
163
|
+
targetRoot,
|
|
164
|
+
sessionId: sessionId || await createAvailableSessionId(targetRoot, now)
|
|
165
|
+
});
|
|
166
|
+
const existingSession = await resolveExistingSessionRoot(initialPaths);
|
|
167
|
+
if (existingSession.root) {
|
|
168
|
+
return failSession(initialPaths, {
|
|
169
|
+
code: "session_exists",
|
|
170
|
+
message: `Session already exists: ${initialPaths.sessionId}`,
|
|
171
|
+
status: SESSION_STATUS.BLOCKED
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const preconditions = await applyPreconditions(initialPaths, [
|
|
176
|
+
() => assertTargetRootWritable(initialPaths.targetRoot),
|
|
177
|
+
() => assertGitRepository(initialPaths.targetRoot)
|
|
178
|
+
]);
|
|
179
|
+
if (!preconditions.ok) {
|
|
180
|
+
return failSession(initialPaths, {
|
|
181
|
+
...preconditions.error,
|
|
182
|
+
preconditions: preconditions.preconditions
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
await ensureStudioGitExclude(initialPaths.targetRoot);
|
|
187
|
+
await mkdir(initialPaths.sessionRoot, { recursive: true });
|
|
188
|
+
await writeTextFile(path.join(initialPaths.sessionRoot, "transcript.log"), "");
|
|
189
|
+
await markStatus(initialPaths, SESSION_STATUS.PENDING);
|
|
190
|
+
await writeReceipt(initialPaths, "session_created", `Created JSKIT Studio issue session ${initialPaths.sessionId}.`);
|
|
191
|
+
|
|
192
|
+
return buildSessionResponse(initialPaths, {
|
|
193
|
+
ok: true,
|
|
194
|
+
preconditions: preconditions.preconditions
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const SESSION_ARCHIVE_ROOTS = Object.freeze([
|
|
199
|
+
"active",
|
|
200
|
+
"completed",
|
|
201
|
+
"abandoned"
|
|
202
|
+
]);
|
|
203
|
+
|
|
204
|
+
function normalizeArchiveFilter(archive = "active") {
|
|
205
|
+
const requestedArchives = Array.isArray(archive) ? archive : [archive];
|
|
206
|
+
const normalized = requestedArchives
|
|
207
|
+
.map((entry) => String(entry || "").trim().toLowerCase())
|
|
208
|
+
.filter(Boolean);
|
|
209
|
+
if (normalized.includes("all")) {
|
|
210
|
+
return SESSION_ARCHIVE_ROOTS;
|
|
211
|
+
}
|
|
212
|
+
const allowed = new Set(SESSION_ARCHIVE_ROOTS);
|
|
213
|
+
const selected = normalized.filter((entry) => allowed.has(entry));
|
|
214
|
+
return selected.length > 0 ? [...new Set(selected)] : ["active"];
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
async function listSessions({ targetRoot = process.cwd(), archive = "active" } = {}) {
|
|
218
|
+
const paths = resolveSessionPaths({ targetRoot });
|
|
219
|
+
const sessions = [];
|
|
220
|
+
const rootsByArchive = {
|
|
221
|
+
abandoned: paths.abandonedSessionsRoot,
|
|
222
|
+
active: paths.sessionsRoot,
|
|
223
|
+
completed: paths.completedSessionsRoot
|
|
224
|
+
};
|
|
225
|
+
const selectedArchives = normalizeArchiveFilter(archive);
|
|
226
|
+
const roots = selectedArchives.map((archiveName) => ({
|
|
227
|
+
archive: archiveName,
|
|
228
|
+
root: rootsByArchive[archiveName]
|
|
229
|
+
}));
|
|
230
|
+
|
|
231
|
+
for (const rootInfo of roots) {
|
|
232
|
+
let entries = [];
|
|
233
|
+
try {
|
|
234
|
+
entries = await readdir(rootInfo.root, { withFileTypes: true });
|
|
235
|
+
} catch {
|
|
236
|
+
entries = [];
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
for (const entry of entries) {
|
|
240
|
+
if (!entry.isDirectory() || !isValidSessionId(entry.name)) {
|
|
241
|
+
continue;
|
|
242
|
+
}
|
|
243
|
+
const sessionPaths = resolveSessionPaths({
|
|
244
|
+
targetRoot,
|
|
245
|
+
sessionId: entry.name
|
|
246
|
+
});
|
|
247
|
+
const response = await buildSessionResponse({
|
|
248
|
+
...sessionPaths,
|
|
249
|
+
archive: rootInfo.archive,
|
|
250
|
+
sessionRoot: path.join(rootInfo.root, entry.name)
|
|
251
|
+
});
|
|
252
|
+
sessions.push(response);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
sessions.sort((left, right) => right.sessionId.localeCompare(left.sessionId));
|
|
256
|
+
return {
|
|
257
|
+
archive: selectedArchives.length === 1 ? selectedArchives[0] : "mixed",
|
|
258
|
+
archives: selectedArchives,
|
|
259
|
+
ok: true,
|
|
260
|
+
stepDefinitions: buildStepDefinitions(),
|
|
261
|
+
sessions
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
async function inspectSession({
|
|
266
|
+
targetRoot = process.cwd(),
|
|
267
|
+
sessionId
|
|
268
|
+
} = {}) {
|
|
269
|
+
return withExistingSession({ targetRoot, sessionId }, (paths, context) => {
|
|
270
|
+
return buildSessionResponse(paths, {
|
|
271
|
+
preconditions: context.preconditions
|
|
272
|
+
});
|
|
273
|
+
});
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function emptySessionDetails(response) {
|
|
277
|
+
return {
|
|
278
|
+
...response,
|
|
279
|
+
issueTitle: "",
|
|
280
|
+
issueText: "",
|
|
281
|
+
planText: "",
|
|
282
|
+
receipts: [],
|
|
283
|
+
transcriptLog: ""
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
async function inspectSessionDetails({
|
|
288
|
+
targetRoot = process.cwd(),
|
|
289
|
+
sessionId
|
|
290
|
+
} = {}) {
|
|
291
|
+
const context = await existingSessionContext({ targetRoot, sessionId });
|
|
292
|
+
if (!context.ok) {
|
|
293
|
+
return emptySessionDetails(context.response);
|
|
294
|
+
}
|
|
295
|
+
const { paths, preconditions } = context;
|
|
296
|
+
const response = await buildSessionResponse(paths, { preconditions });
|
|
297
|
+
|
|
298
|
+
const [issueText, issueTitle, planText, receipts, transcriptLog] = await Promise.all([
|
|
299
|
+
readTextIfExists(path.join(paths.sessionRoot, "issue.md")),
|
|
300
|
+
readTrimmedFile(path.join(paths.sessionRoot, "issue_title")),
|
|
301
|
+
readTextIfExists(path.join(paths.sessionRoot, "plan.md")),
|
|
302
|
+
readReceiptSteps(paths),
|
|
303
|
+
readTextIfExists(path.join(paths.sessionRoot, "transcript.log"))
|
|
304
|
+
]);
|
|
305
|
+
|
|
306
|
+
return {
|
|
307
|
+
...response,
|
|
308
|
+
issueTitle,
|
|
309
|
+
issueText: issueText.trim(),
|
|
310
|
+
planText: planText.trim(),
|
|
311
|
+
receipts,
|
|
312
|
+
transcriptLog
|
|
313
|
+
};
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
async function removeEmptyStaleWorktreeDirectory(paths) {
|
|
317
|
+
try {
|
|
318
|
+
const entries = await readdir(paths.worktree);
|
|
319
|
+
if (entries.length > 0) {
|
|
320
|
+
return {
|
|
321
|
+
ok: false,
|
|
322
|
+
message: `Worktree path exists but is not a registered Git worktree: ${paths.worktree}`
|
|
323
|
+
};
|
|
324
|
+
}
|
|
325
|
+
await rmdir(paths.worktree);
|
|
326
|
+
return {
|
|
327
|
+
ok: true
|
|
328
|
+
};
|
|
329
|
+
} catch (error) {
|
|
330
|
+
if (error?.code === "ENOENT") {
|
|
331
|
+
return {
|
|
332
|
+
ok: true
|
|
333
|
+
};
|
|
334
|
+
}
|
|
335
|
+
return {
|
|
336
|
+
ok: false,
|
|
337
|
+
message: `Cannot prepare worktree path ${paths.worktree}: ${error?.message || error}`
|
|
338
|
+
};
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
async function createWorktree(paths, _options = {}, context = {}) {
|
|
343
|
+
const preconditions = context.preconditions || [];
|
|
344
|
+
if (await hasWorktree(paths)) {
|
|
345
|
+
await writeReceipt(paths, "worktree_created", `Reused existing worktree ${paths.worktree}.`);
|
|
346
|
+
await markStatus(paths, SESSION_STATUS.RUNNING);
|
|
347
|
+
return buildSessionResponse(paths, {
|
|
348
|
+
preconditions
|
|
349
|
+
});
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
await mkdir(path.dirname(paths.worktree), { recursive: true });
|
|
353
|
+
const staleWorktree = await removeEmptyStaleWorktreeDirectory(paths);
|
|
354
|
+
if (!staleWorktree.ok) {
|
|
355
|
+
return failSession(paths, {
|
|
356
|
+
code: "worktree_path_blocked",
|
|
357
|
+
message: staleWorktree.message,
|
|
358
|
+
repairCommand: `ls -la ${paths.worktree}`,
|
|
359
|
+
preconditions
|
|
360
|
+
});
|
|
361
|
+
}
|
|
362
|
+
const result = await runGit(paths.targetRoot, ["worktree", "add", "-b", paths.branch, paths.worktree, "HEAD"], {
|
|
363
|
+
timeout: 30000
|
|
364
|
+
});
|
|
365
|
+
if (!result.ok) {
|
|
366
|
+
return failSession(paths, {
|
|
367
|
+
code: "worktree_create_failed",
|
|
368
|
+
message: result.output || `Failed to create worktree ${paths.worktree}.`,
|
|
369
|
+
repairCommand: `git worktree add -b ${paths.branch} ${paths.worktree} HEAD`,
|
|
370
|
+
preconditions
|
|
371
|
+
});
|
|
372
|
+
}
|
|
373
|
+
await writeReceipt(paths, "worktree_created", `Created worktree ${paths.worktree} on branch ${paths.branch}.`);
|
|
374
|
+
await markStatus(paths, SESSION_STATUS.RUNNING);
|
|
375
|
+
return buildSessionResponse(paths, {
|
|
376
|
+
preconditions
|
|
377
|
+
});
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
async function recordDependenciesInstalled(paths, {
|
|
381
|
+
message = "Installed Node dependencies in the session worktree.",
|
|
382
|
+
preconditions = []
|
|
383
|
+
} = {}) {
|
|
384
|
+
await writeReceipt(paths, "dependencies_installed", message);
|
|
385
|
+
await markStatus(paths, SESSION_STATUS.RUNNING);
|
|
386
|
+
return buildSessionResponse(paths, {
|
|
387
|
+
preconditions
|
|
388
|
+
});
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
async function installDependencies(paths, _options = {}, context = {}) {
|
|
392
|
+
const preconditions = context.preconditions || [];
|
|
393
|
+
const result = await runCommand("npm", ["install"], {
|
|
394
|
+
cwd: paths.worktree,
|
|
395
|
+
timeout: 1000 * 60 * 10
|
|
396
|
+
});
|
|
397
|
+
if (!result.ok) {
|
|
398
|
+
return failSession(paths, {
|
|
399
|
+
code: "dependencies_install_failed",
|
|
400
|
+
message: result.output || "npm install failed in the session worktree.",
|
|
401
|
+
repairCommand: `cd ${paths.worktree} && npm install`,
|
|
402
|
+
preconditions
|
|
403
|
+
});
|
|
404
|
+
}
|
|
405
|
+
return recordDependenciesInstalled(paths, {
|
|
406
|
+
message: result.output || "Installed Node dependencies in the session worktree.",
|
|
407
|
+
preconditions
|
|
408
|
+
});
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
async function adoptDependenciesInstalled({
|
|
412
|
+
targetRoot = process.cwd(),
|
|
413
|
+
sessionId,
|
|
414
|
+
message = ""
|
|
415
|
+
} = {}) {
|
|
416
|
+
return withExistingSession({ targetRoot, sessionId }, async (paths, context = {}) => {
|
|
417
|
+
const artifacts = await readSessionArtifacts(paths);
|
|
418
|
+
if (artifacts.nextStep !== "dependencies_installed") {
|
|
419
|
+
return buildSessionResponse(paths, {
|
|
420
|
+
ok: false,
|
|
421
|
+
errors: [
|
|
422
|
+
createError({
|
|
423
|
+
code: "session_step_mismatch",
|
|
424
|
+
message: `Cannot record dependencies for ${paths.sessionId}; current step is ${artifacts.nextStep || "complete"}.`
|
|
425
|
+
})
|
|
426
|
+
],
|
|
427
|
+
preconditions: context.preconditions || []
|
|
428
|
+
});
|
|
429
|
+
}
|
|
430
|
+
return recordDependenciesInstalled(paths, {
|
|
431
|
+
message,
|
|
432
|
+
preconditions: context.preconditions || []
|
|
433
|
+
});
|
|
434
|
+
});
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
async function renderIssuePrompt(paths, options = {}) {
|
|
438
|
+
const userInput = normalizeText(options.prompt);
|
|
439
|
+
if (!userInput) {
|
|
440
|
+
return failSession(paths, {
|
|
441
|
+
code: "prompt_required",
|
|
442
|
+
message: "The issue prompt step requires --prompt.",
|
|
443
|
+
repairCommand: `jskit session ${paths.sessionId} step --prompt "<what should change>"`
|
|
444
|
+
});
|
|
445
|
+
}
|
|
446
|
+
const prompt = await renderPrompt(paths, "new_issue.md", {
|
|
447
|
+
user_input: userInput
|
|
448
|
+
});
|
|
449
|
+
await writePromptArtifact(paths, "issue_draft.md", prompt);
|
|
450
|
+
await writeReceipt(paths, "issue_prompt_rendered", "Rendered the issue drafting prompt.");
|
|
451
|
+
await markStatus(paths, SESSION_STATUS.WAITING_FOR_USER);
|
|
452
|
+
return buildSessionResponse(paths, {
|
|
453
|
+
ok: true,
|
|
454
|
+
prompt,
|
|
455
|
+
status: SESSION_STATUS.WAITING_FOR_USER
|
|
456
|
+
});
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
async function draftIssue(paths, options = {}) {
|
|
460
|
+
const issueText = extractIssueText(options.issue);
|
|
461
|
+
if (!issueText) {
|
|
462
|
+
return failSession(paths, {
|
|
463
|
+
code: "issue_required",
|
|
464
|
+
message: "The issue drafting step requires --issue, --issue-file, or --issue -.",
|
|
465
|
+
repairCommand: `jskit session ${paths.sessionId} step --issue -`
|
|
466
|
+
});
|
|
467
|
+
}
|
|
468
|
+
const issueTitle = normalizeText(options.issueTitle) || extractIssueTitle(options.issue) || titleFromIssue(issueText);
|
|
469
|
+
await writeTextFile(path.join(paths.sessionRoot, "issue.md"), issueText);
|
|
470
|
+
await writeTextFile(path.join(paths.sessionRoot, "issue_title"), issueTitle);
|
|
471
|
+
await writeReceipt(paths, "issue_drafted", "Saved approved issue text.");
|
|
472
|
+
await markStatus(paths, SESSION_STATUS.RUNNING);
|
|
473
|
+
return buildSessionResponse(paths);
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
function titleFromIssue(issueText) {
|
|
477
|
+
const firstMeaningfulLine = String(issueText || "")
|
|
478
|
+
.split(/\r?\n/u)
|
|
479
|
+
.map((line) => line.replace(/^#+\s*/u, "").trim())
|
|
480
|
+
.find(Boolean);
|
|
481
|
+
return (firstMeaningfulLine || "JSKIT Studio issue").slice(0, 120);
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
async function createIssue(paths, _options = {}, context = {}) {
|
|
485
|
+
const preconditions = context.preconditions || [];
|
|
486
|
+
const issueText = await readTrimmedFile(path.join(paths.sessionRoot, "issue.md"));
|
|
487
|
+
const issueTitle = await readTrimmedFile(path.join(paths.sessionRoot, "issue_title")) || titleFromIssue(issueText);
|
|
488
|
+
const result = await runCommand("gh", [
|
|
489
|
+
"issue",
|
|
490
|
+
"create",
|
|
491
|
+
"--title",
|
|
492
|
+
issueTitle,
|
|
493
|
+
"--body-file",
|
|
494
|
+
path.join(paths.sessionRoot, "issue.md")
|
|
495
|
+
], {
|
|
496
|
+
cwd: paths.targetRoot,
|
|
497
|
+
timeout: 30000
|
|
498
|
+
});
|
|
499
|
+
if (!result.ok || !result.stdout) {
|
|
500
|
+
return failSession(paths, {
|
|
501
|
+
code: "issue_create_failed",
|
|
502
|
+
message: result.output || "GitHub issue creation failed.",
|
|
503
|
+
repairCommand: "gh issue create",
|
|
504
|
+
preconditions
|
|
505
|
+
});
|
|
506
|
+
}
|
|
507
|
+
const issueUrl = result.stdout.split(/\r?\n/u).map((line) => line.trim()).find(Boolean) || result.stdout;
|
|
508
|
+
await writeTextFile(path.join(paths.sessionRoot, "issue_url"), issueUrl);
|
|
509
|
+
await writeReceipt(paths, "issue_created", `Created GitHub issue ${issueUrl}.`);
|
|
510
|
+
await markStatus(paths, SESSION_STATUS.RUNNING);
|
|
511
|
+
return buildSessionResponse(paths, {
|
|
512
|
+
preconditions
|
|
513
|
+
});
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
async function makePlan(paths, options = {}, context = {}) {
|
|
517
|
+
const preconditions = context.preconditions || [];
|
|
518
|
+
const issueText = await readTrimmedFile(path.join(paths.sessionRoot, "issue.md"));
|
|
519
|
+
const issueTitle = await readTrimmedFile(path.join(paths.sessionRoot, "issue_title")) || titleFromIssue(issueText);
|
|
520
|
+
const issueUrl = await readTrimmedFile(path.join(paths.sessionRoot, "issue_url"));
|
|
521
|
+
const issueNumber = issueNumberFromUrl(issueUrl);
|
|
522
|
+
const planText = extractPlanText(options.plan);
|
|
523
|
+
|
|
524
|
+
if (!planText) {
|
|
525
|
+
const prompt = await renderPrompt(paths, "plan_issue.md", {
|
|
526
|
+
issue_file: path.join(paths.sessionRoot, "issue.md"),
|
|
527
|
+
issue_number: issueNumber,
|
|
528
|
+
issue_text: issueText,
|
|
529
|
+
issue_title: issueTitle,
|
|
530
|
+
issue_title_file: path.join(paths.sessionRoot, "issue_title"),
|
|
531
|
+
issue_url: issueUrl,
|
|
532
|
+
plan_file: path.join(paths.sessionRoot, "plan.md"),
|
|
533
|
+
worktree: paths.worktree
|
|
534
|
+
});
|
|
535
|
+
await writePromptArtifact(paths, "plan_request.md", prompt);
|
|
536
|
+
await markStatus(paths, SESSION_STATUS.WAITING_FOR_USER);
|
|
537
|
+
return buildSessionResponse(paths, {
|
|
538
|
+
ok: true,
|
|
539
|
+
preconditions,
|
|
540
|
+
prompt,
|
|
541
|
+
status: SESSION_STATUS.WAITING_FOR_USER
|
|
542
|
+
});
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
const planPath = path.join(paths.sessionRoot, "plan.md");
|
|
546
|
+
await writeTextFile(planPath, planText);
|
|
547
|
+
const commentResult = await runCommand("gh", ["issue", "comment", issueUrl, "--body-file", planPath], {
|
|
548
|
+
cwd: paths.targetRoot,
|
|
549
|
+
timeout: 1000 * 60
|
|
550
|
+
});
|
|
551
|
+
if (!commentResult.ok) {
|
|
552
|
+
return failSession(paths, {
|
|
553
|
+
code: "plan_comment_failed",
|
|
554
|
+
message: commentResult.output || "Failed to comment the implementation plan on the GitHub issue.",
|
|
555
|
+
repairCommand: `gh issue comment ${issueUrl} --body-file ${planPath}`,
|
|
556
|
+
preconditions
|
|
557
|
+
});
|
|
558
|
+
}
|
|
559
|
+
const executionPrompt = await renderPrompt(paths, "execute_plan.md", {
|
|
560
|
+
issue_file: path.join(paths.sessionRoot, "issue.md"),
|
|
561
|
+
issue_number: issueNumber,
|
|
562
|
+
issue_title: issueTitle,
|
|
563
|
+
issue_url: issueUrl,
|
|
564
|
+
plan_file: planPath,
|
|
565
|
+
plan_text: planText,
|
|
566
|
+
worktree: paths.worktree
|
|
567
|
+
});
|
|
568
|
+
await writePromptArtifact(paths, "plan_execution.md", executionPrompt);
|
|
569
|
+
await writeReceipt(paths, "plan_made", `Saved plan and commented on ${issueUrl}.`);
|
|
570
|
+
await markStatus(paths, SESSION_STATUS.WAITING_FOR_USER);
|
|
571
|
+
return buildSessionResponse(paths, {
|
|
572
|
+
codex: PLAN_EXECUTION_CODEX_HANDOFF,
|
|
573
|
+
preconditions,
|
|
574
|
+
prompt: executionPrompt,
|
|
575
|
+
status: SESSION_STATUS.WAITING_FOR_USER
|
|
576
|
+
});
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
async function worktreeStatus(worktree) {
|
|
580
|
+
const result = await runGitInWorktree(worktree, ["status", "--porcelain=v1"]);
|
|
581
|
+
if (!result.ok) {
|
|
582
|
+
return {
|
|
583
|
+
ok: false,
|
|
584
|
+
changedFiles: [],
|
|
585
|
+
output: result.output
|
|
586
|
+
};
|
|
587
|
+
}
|
|
588
|
+
const changedFiles = result.stdout.split(/\r?\n/u).map((line) => line.trim()).filter(Boolean);
|
|
589
|
+
return {
|
|
590
|
+
ok: true,
|
|
591
|
+
changedFiles,
|
|
592
|
+
output: result.stdout
|
|
593
|
+
};
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
async function untrackedFiles(worktree) {
|
|
597
|
+
const result = await runGitInWorktree(worktree, ["ls-files", "--others", "--exclude-standard", "-z"], {
|
|
598
|
+
timeout: 15000
|
|
599
|
+
});
|
|
600
|
+
if (!result.ok) {
|
|
601
|
+
return [];
|
|
602
|
+
}
|
|
603
|
+
return result.stdout
|
|
604
|
+
.split("\0")
|
|
605
|
+
.filter((line) => line.length > 0);
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
async function untrackedFileDiff(worktree, filePath) {
|
|
609
|
+
const result = await runGitInWorktree(worktree, [
|
|
610
|
+
"diff",
|
|
611
|
+
"--no-color",
|
|
612
|
+
"--no-ext-diff",
|
|
613
|
+
"--no-index",
|
|
614
|
+
"--",
|
|
615
|
+
"/dev/null",
|
|
616
|
+
filePath
|
|
617
|
+
], {
|
|
618
|
+
timeout: 15000
|
|
619
|
+
});
|
|
620
|
+
if (result.ok || result.exitCode === 1) {
|
|
621
|
+
return result.stdout;
|
|
622
|
+
}
|
|
623
|
+
return "";
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
async function untrackedFilesDiff(worktree) {
|
|
627
|
+
const diffs = [];
|
|
628
|
+
for (const filePath of await untrackedFiles(worktree)) {
|
|
629
|
+
const diff = await untrackedFileDiff(worktree, filePath);
|
|
630
|
+
if (diff) {
|
|
631
|
+
diffs.push(diff);
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
return diffs.join("\n");
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
async function inspectSessionDiff({
|
|
638
|
+
targetRoot = process.cwd(),
|
|
639
|
+
sessionId
|
|
640
|
+
} = {}) {
|
|
641
|
+
return withExistingSession({ targetRoot, sessionId }, async (paths) => {
|
|
642
|
+
const session = await buildSessionResponse(paths);
|
|
643
|
+
if (!await hasWorktree(paths)) {
|
|
644
|
+
return {
|
|
645
|
+
...session,
|
|
646
|
+
ok: false,
|
|
647
|
+
errors: [
|
|
648
|
+
createError({
|
|
649
|
+
code: "worktree_missing",
|
|
650
|
+
message: "Session worktree is not available for diff inspection."
|
|
651
|
+
})
|
|
652
|
+
],
|
|
653
|
+
gitStatus: "",
|
|
654
|
+
hasChanges: false,
|
|
655
|
+
stagedDiff: "",
|
|
656
|
+
unstagedDiff: "",
|
|
657
|
+
untrackedDiff: ""
|
|
658
|
+
};
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
const [status, unstagedDiff, stagedDiff] = await Promise.all([
|
|
662
|
+
runGitInWorktree(paths.worktree, ["status", "--porcelain=v1"], { timeout: 15000 }),
|
|
663
|
+
runGitInWorktree(paths.worktree, ["diff", "--no-color", "--no-ext-diff"], { timeout: 30000 }),
|
|
664
|
+
runGitInWorktree(paths.worktree, ["diff", "--cached", "--no-color", "--no-ext-diff"], { timeout: 30000 })
|
|
665
|
+
]);
|
|
666
|
+
|
|
667
|
+
if (!status.ok || !unstagedDiff.ok || !stagedDiff.ok) {
|
|
668
|
+
return {
|
|
669
|
+
...session,
|
|
670
|
+
ok: false,
|
|
671
|
+
errors: [
|
|
672
|
+
createError({
|
|
673
|
+
code: "session_diff_failed",
|
|
674
|
+
message: [status, unstagedDiff, stagedDiff].find((result) => !result.ok)?.output ||
|
|
675
|
+
"Failed to inspect session worktree diff."
|
|
676
|
+
})
|
|
677
|
+
],
|
|
678
|
+
gitStatus: status.stdout || "",
|
|
679
|
+
hasChanges: false,
|
|
680
|
+
stagedDiff: stagedDiff.stdout || "",
|
|
681
|
+
unstagedDiff: unstagedDiff.stdout || "",
|
|
682
|
+
untrackedDiff: ""
|
|
683
|
+
};
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
const untrackedDiff = await untrackedFilesDiff(paths.worktree);
|
|
687
|
+
return {
|
|
688
|
+
...session,
|
|
689
|
+
gitStatus: status.stdout,
|
|
690
|
+
hasChanges: Boolean(status.stdout.trim()),
|
|
691
|
+
stagedDiff: stagedDiff.stdout,
|
|
692
|
+
unstagedDiff: unstagedDiff.stdout,
|
|
693
|
+
untrackedDiff,
|
|
694
|
+
worktree: paths.worktree
|
|
695
|
+
};
|
|
696
|
+
});
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
async function acceptImplementationChanges(paths) {
|
|
700
|
+
const status = await worktreeStatus(paths.worktree);
|
|
701
|
+
if (!status.ok) {
|
|
702
|
+
return failSession(paths, {
|
|
703
|
+
code: "git_status_failed",
|
|
704
|
+
message: status.output || "Failed to inspect worktree changes.",
|
|
705
|
+
repairCommand: `git -C ${paths.worktree} status --short`
|
|
706
|
+
});
|
|
707
|
+
}
|
|
708
|
+
if (status.changedFiles.length < 1) {
|
|
709
|
+
return failSession(paths, {
|
|
710
|
+
code: "changes_missing",
|
|
711
|
+
message: "No worktree changes found. Ask Codex to implement the approved plan, inspect the worktree, then accept changes once ready.",
|
|
712
|
+
repairCommand: `jskit session ${paths.sessionId} step`
|
|
713
|
+
});
|
|
714
|
+
}
|
|
715
|
+
await writeReceipt(paths, "implementation_changes_accepted", `Accepted ${status.changedFiles.length} changed file entries for commit.`);
|
|
716
|
+
await markStatus(paths, SESSION_STATUS.RUNNING);
|
|
717
|
+
return buildSessionResponse(paths);
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
async function commitWorktree(paths, {
|
|
721
|
+
message,
|
|
722
|
+
allowNoChanges = false
|
|
723
|
+
} = {}) {
|
|
724
|
+
const status = await worktreeStatus(paths.worktree);
|
|
725
|
+
if (!status.ok) {
|
|
726
|
+
return {
|
|
727
|
+
ok: false,
|
|
728
|
+
output: status.output
|
|
729
|
+
};
|
|
730
|
+
}
|
|
731
|
+
if (status.changedFiles.length < 1) {
|
|
732
|
+
return {
|
|
733
|
+
changedFiles: [],
|
|
734
|
+
ok: allowNoChanges,
|
|
735
|
+
output: allowNoChanges ? "No changes to commit." : "No changes found."
|
|
736
|
+
};
|
|
737
|
+
}
|
|
738
|
+
const addResult = await runGitInWorktree(paths.worktree, ["add", "."]);
|
|
739
|
+
if (!addResult.ok) {
|
|
740
|
+
return {
|
|
741
|
+
ok: false,
|
|
742
|
+
output: addResult.output
|
|
743
|
+
};
|
|
744
|
+
}
|
|
745
|
+
const commitResult = await runGitInWorktree(paths.worktree, ["commit", "-m", message], {
|
|
746
|
+
timeout: 30000
|
|
747
|
+
});
|
|
748
|
+
if (!commitResult.ok) {
|
|
749
|
+
return {
|
|
750
|
+
ok: false,
|
|
751
|
+
output: commitResult.output
|
|
752
|
+
};
|
|
753
|
+
}
|
|
754
|
+
return {
|
|
755
|
+
changedFiles: status.changedFiles,
|
|
756
|
+
ok: true,
|
|
757
|
+
output: commitResult.output
|
|
758
|
+
};
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
async function commitImplementation(paths) {
|
|
762
|
+
const result = await commitWorktree(paths, {
|
|
763
|
+
message: `Implement JSKIT session ${paths.sessionId}`
|
|
764
|
+
});
|
|
765
|
+
if (!result.ok) {
|
|
766
|
+
return failSession(paths, {
|
|
767
|
+
code: "commit_failed",
|
|
768
|
+
message: result.output || "Failed to commit implementation changes.",
|
|
769
|
+
repairCommand: `git -C ${paths.worktree} status --short`
|
|
770
|
+
});
|
|
771
|
+
}
|
|
772
|
+
await writeReceipt(paths, "implementation_changes_committed", `Committed implementation changes for ${paths.sessionId}.`);
|
|
773
|
+
await markStatus(paths, SESSION_STATUS.RUNNING);
|
|
774
|
+
return buildSessionResponse(paths);
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
async function changedFilesFromLastCommit(paths) {
|
|
778
|
+
const result = await runGitInWorktree(paths.worktree, ["show", "--name-only", "--format=", "HEAD"]);
|
|
779
|
+
if (!result.ok) {
|
|
780
|
+
return "";
|
|
781
|
+
}
|
|
782
|
+
return result.stdout.split(/\r?\n/u).map((line) => line.trim()).filter(Boolean).join("\n");
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
async function renderReviewPrompt(paths) {
|
|
786
|
+
const prompt = await renderPrompt(paths, "review_changes.md", {
|
|
787
|
+
changed_files: await changedFilesFromLastCommit(paths)
|
|
788
|
+
});
|
|
789
|
+
await writePromptArtifact(paths, "review.md", prompt);
|
|
790
|
+
await writeReceipt(paths, "review_prompt_rendered", "Started code review.");
|
|
791
|
+
await markStatus(paths, SESSION_STATUS.WAITING_FOR_USER);
|
|
792
|
+
return buildSessionResponse(paths, {
|
|
793
|
+
codex: REVIEW_EXECUTION_CODEX_HANDOFF,
|
|
794
|
+
prompt,
|
|
795
|
+
status: SESSION_STATUS.WAITING_FOR_USER
|
|
796
|
+
});
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
async function acceptReviewChanges(paths) {
|
|
800
|
+
const status = await worktreeStatus(paths.worktree);
|
|
801
|
+
if (!status.ok) {
|
|
802
|
+
return failSession(paths, {
|
|
803
|
+
code: "git_status_failed",
|
|
804
|
+
message: status.output || "Failed to inspect review changes.",
|
|
805
|
+
repairCommand: `git -C ${paths.worktree} status --short`
|
|
806
|
+
});
|
|
807
|
+
}
|
|
808
|
+
const message = status.changedFiles.length > 0
|
|
809
|
+
? `Accepted ${status.changedFiles.length} review changed file entries for commit.`
|
|
810
|
+
: "Accepted review with no file changes.";
|
|
811
|
+
await writeReceipt(paths, "review_changes_accepted", message);
|
|
812
|
+
await markStatus(paths, SESSION_STATUS.RUNNING);
|
|
813
|
+
return buildSessionResponse(paths);
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
async function commitReviewChanges(paths) {
|
|
817
|
+
const result = await commitWorktree(paths, {
|
|
818
|
+
allowNoChanges: true,
|
|
819
|
+
message: `Apply review changes for ${paths.sessionId}`
|
|
820
|
+
});
|
|
821
|
+
if (!result.ok) {
|
|
822
|
+
return failSession(paths, {
|
|
823
|
+
code: "review_commit_failed",
|
|
824
|
+
message: result.output || "Failed to commit review changes.",
|
|
825
|
+
repairCommand: `git -C ${paths.worktree} status --short`
|
|
826
|
+
});
|
|
827
|
+
}
|
|
828
|
+
const message = result.changedFiles?.length
|
|
829
|
+
? "Committed review changes."
|
|
830
|
+
: "No review changes detected.";
|
|
831
|
+
await writeReceipt(paths, "review_changes_committed", message);
|
|
832
|
+
await markStatus(paths, SESSION_STATUS.RUNNING);
|
|
833
|
+
return buildSessionResponse(paths);
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
async function userCheck(paths, options = {}) {
|
|
837
|
+
const result = normalizeText(options.userCheck || options["user-check"]).toLowerCase();
|
|
838
|
+
if (result === "passed" || result === "pass" || result === "ok" || result === "yes") {
|
|
839
|
+
await writeReceipt(paths, "user_check_completed", "User confirmed check passed.");
|
|
840
|
+
await markStatus(paths, SESSION_STATUS.RUNNING);
|
|
841
|
+
return buildSessionResponse(paths);
|
|
842
|
+
}
|
|
843
|
+
if (result === "failed" || result === "fail" || result === "no") {
|
|
844
|
+
return failSession(paths, {
|
|
845
|
+
code: "user_check_failed",
|
|
846
|
+
message: "User check was reported as failed. Continue in Codex, then retry this step with --user-check passed.",
|
|
847
|
+
repairCommand: `jskit session ${paths.sessionId} step --user-check passed`
|
|
848
|
+
});
|
|
849
|
+
}
|
|
850
|
+
const prompt = await renderPrompt(paths, "user_check.md");
|
|
851
|
+
await writePromptArtifact(paths, "user_check.md", prompt);
|
|
852
|
+
await markStatus(paths, SESSION_STATUS.WAITING_FOR_USER);
|
|
853
|
+
return buildSessionResponse(paths, {
|
|
854
|
+
prompt,
|
|
855
|
+
status: SESSION_STATUS.WAITING_FOR_USER
|
|
856
|
+
});
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
async function readPackageJson(root) {
|
|
860
|
+
try {
|
|
861
|
+
return JSON.parse(await readFile(path.join(root, "package.json"), "utf8"));
|
|
862
|
+
} catch {
|
|
863
|
+
return {};
|
|
864
|
+
}
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
async function doctorCommandForWorktree(worktree) {
|
|
868
|
+
const packageJson = await readPackageJson(worktree);
|
|
869
|
+
const scripts = packageJson && typeof packageJson.scripts === "object" ? packageJson.scripts : {};
|
|
870
|
+
if (scripts["verify:local"]) {
|
|
871
|
+
return ["npm", ["run", "verify:local"]];
|
|
872
|
+
}
|
|
873
|
+
if (scripts.verify) {
|
|
874
|
+
return ["npm", ["run", "verify"]];
|
|
875
|
+
}
|
|
876
|
+
return ["npx", ["jskit", "app", "verify"]];
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
async function runDoctor(paths) {
|
|
880
|
+
const [command, args] = await doctorCommandForWorktree(paths.worktree);
|
|
881
|
+
const result = await runCommand(command, args, {
|
|
882
|
+
cwd: paths.worktree,
|
|
883
|
+
timeout: 1000 * 60 * 15
|
|
884
|
+
});
|
|
885
|
+
await writeTextFile(path.join(paths.sessionRoot, "doctor.log"), result.output);
|
|
886
|
+
if (!result.ok) {
|
|
887
|
+
const prompt = await renderPrompt(paths, "doctor_failure.md", {
|
|
888
|
+
doctor_output: result.output
|
|
889
|
+
});
|
|
890
|
+
await writePromptArtifact(paths, "doctor_failure.md", prompt);
|
|
891
|
+
return failSession(paths, {
|
|
892
|
+
code: "doctor_failed",
|
|
893
|
+
message: "Doctor/verification command failed. Paste the failure prompt into Codex, then rerun this step.",
|
|
894
|
+
repairCommand: `${command} ${args.join(" ")}`,
|
|
895
|
+
prompt
|
|
896
|
+
});
|
|
897
|
+
}
|
|
898
|
+
await writeReceipt(paths, "doctor_run", `Doctor command passed: ${command} ${args.join(" ")}.`);
|
|
899
|
+
await markStatus(paths, SESSION_STATUS.RUNNING);
|
|
900
|
+
return buildSessionResponse(paths);
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
function issueNumberFromUrl(issueUrl) {
|
|
904
|
+
const match = /\/issues\/(\d+)(?:\b|$)/u.exec(String(issueUrl || ""));
|
|
905
|
+
return match ? match[1] : "";
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
async function createPr(paths) {
|
|
909
|
+
const pushResult = await runGitInWorktree(paths.worktree, ["push", "-u", "origin", "HEAD"], {
|
|
910
|
+
timeout: 1000 * 60 * 5
|
|
911
|
+
});
|
|
912
|
+
if (!pushResult.ok) {
|
|
913
|
+
return failSession(paths, {
|
|
914
|
+
code: "branch_push_failed",
|
|
915
|
+
message: pushResult.output || "Failed to push session branch.",
|
|
916
|
+
repairCommand: `git -C ${paths.worktree} push -u origin HEAD`
|
|
917
|
+
});
|
|
918
|
+
}
|
|
919
|
+
const issueUrl = await readTrimmedFile(path.join(paths.sessionRoot, "issue_url"));
|
|
920
|
+
const issueText = await readTrimmedFile(path.join(paths.sessionRoot, "issue.md"));
|
|
921
|
+
const issueTitle = await readTrimmedFile(path.join(paths.sessionRoot, "issue_title")) || titleFromIssue(issueText);
|
|
922
|
+
const issueNumber = issueNumberFromUrl(issueUrl);
|
|
923
|
+
const body = [
|
|
924
|
+
issueNumber ? `Closes #${issueNumber}` : "",
|
|
925
|
+
"",
|
|
926
|
+
issueText
|
|
927
|
+
].join("\n").trim();
|
|
928
|
+
const bodyPath = path.join(paths.sessionRoot, "pr_body.md");
|
|
929
|
+
await writeTextFile(bodyPath, body);
|
|
930
|
+
const result = await runCommand("gh", [
|
|
931
|
+
"pr",
|
|
932
|
+
"create",
|
|
933
|
+
"--title",
|
|
934
|
+
issueTitle,
|
|
935
|
+
"--body-file",
|
|
936
|
+
bodyPath
|
|
937
|
+
], {
|
|
938
|
+
cwd: paths.worktree,
|
|
939
|
+
timeout: 1000 * 60
|
|
940
|
+
});
|
|
941
|
+
if (!result.ok || !result.stdout) {
|
|
942
|
+
const prompt = await renderPrompt(paths, "pr_failure.md", {
|
|
943
|
+
doctor_output: result.output
|
|
944
|
+
});
|
|
945
|
+
await writePromptArtifact(paths, "pr_create_failure.md", prompt);
|
|
946
|
+
return failSession(paths, {
|
|
947
|
+
code: "pr_create_failed",
|
|
948
|
+
message: result.output || "Failed to create PR.",
|
|
949
|
+
repairCommand: "gh pr create",
|
|
950
|
+
prompt
|
|
951
|
+
});
|
|
952
|
+
}
|
|
953
|
+
const prUrl = result.stdout.split(/\r?\n/u).map((line) => line.trim()).find(Boolean) || result.stdout;
|
|
954
|
+
await writeTextFile(path.join(paths.sessionRoot, "pr_url"), prUrl);
|
|
955
|
+
await writeReceipt(paths, "pr_created", `Pushed branch ${paths.branch} and created PR ${prUrl}.`);
|
|
956
|
+
await markStatus(paths, SESSION_STATUS.RUNNING);
|
|
957
|
+
return buildSessionResponse(paths);
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
async function mergePr(paths) {
|
|
961
|
+
const prUrl = await readTrimmedFile(path.join(paths.sessionRoot, "pr_url"));
|
|
962
|
+
const mergeMarkerPath = path.join(paths.sessionRoot, "pr_merge_completed");
|
|
963
|
+
const mergeAlreadyCompleted = await readTrimmedFile(mergeMarkerPath);
|
|
964
|
+
if (!mergeAlreadyCompleted) {
|
|
965
|
+
const mergeResult = await runCommand("gh", ["pr", "merge", prUrl, "--merge", "--delete-branch"], {
|
|
966
|
+
cwd: paths.worktree,
|
|
967
|
+
timeout: 1000 * 60 * 5
|
|
968
|
+
});
|
|
969
|
+
if (!mergeResult.ok) {
|
|
970
|
+
const prompt = await renderPrompt(paths, "pr_failure.md", {
|
|
971
|
+
doctor_output: mergeResult.output
|
|
972
|
+
});
|
|
973
|
+
await writePromptArtifact(paths, "pr_merge_failure.md", prompt);
|
|
974
|
+
return failSession(paths, {
|
|
975
|
+
code: "pr_merge_failed",
|
|
976
|
+
message: mergeResult.output || "Failed to merge PR.",
|
|
977
|
+
repairCommand: `gh pr merge ${prUrl} --merge --delete-branch`,
|
|
978
|
+
prompt
|
|
979
|
+
});
|
|
980
|
+
}
|
|
981
|
+
const issueUrl = await readTrimmedFile(path.join(paths.sessionRoot, "issue_url"));
|
|
982
|
+
if (issueUrl) {
|
|
983
|
+
await runCommand("gh", ["issue", "close", issueUrl, "--comment", `Merged PR ${prUrl}.`], {
|
|
984
|
+
cwd: paths.worktree,
|
|
985
|
+
timeout: 1000 * 60
|
|
986
|
+
});
|
|
987
|
+
}
|
|
988
|
+
await writeTextFile(mergeMarkerPath, `${prUrl}\n`);
|
|
989
|
+
}
|
|
990
|
+
if (await hasWorktree(paths)) {
|
|
991
|
+
const result = await runGit(paths.targetRoot, ["worktree", "remove", paths.worktree], {
|
|
992
|
+
timeout: 1000 * 60
|
|
993
|
+
});
|
|
994
|
+
if (!result.ok) {
|
|
995
|
+
return failSession(paths, {
|
|
996
|
+
code: "worktree_remove_failed",
|
|
997
|
+
message: result.output || "Failed to remove worktree.",
|
|
998
|
+
repairCommand: `git worktree remove ${paths.worktree}`
|
|
999
|
+
});
|
|
1000
|
+
}
|
|
1001
|
+
}
|
|
1002
|
+
await writeReceipt(paths, "pr_merged", `Merged PR ${prUrl} and removed worktree ${paths.worktree}.`);
|
|
1003
|
+
await markStatus(paths, SESSION_STATUS.RUNNING);
|
|
1004
|
+
return buildSessionResponse(paths);
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
async function finishSession(paths) {
|
|
1008
|
+
const prUrl = await readTrimmedFile(path.join(paths.sessionRoot, "pr_url"));
|
|
1009
|
+
const issueUrl = await readTrimmedFile(path.join(paths.sessionRoot, "issue_url"));
|
|
1010
|
+
const codexThreadId = await readTrimmedFile(path.join(paths.sessionRoot, "codex_thread_id"));
|
|
1011
|
+
const prompt = await renderPrompt(paths, "final_comment.md", {
|
|
1012
|
+
codex_thread_id: codexThreadId,
|
|
1013
|
+
issue_url: issueUrl,
|
|
1014
|
+
pr_url: prUrl,
|
|
1015
|
+
transcript_log: path.join(paths.completedSessionRoot, "transcript.log")
|
|
1016
|
+
});
|
|
1017
|
+
await writeTextFile(path.join(paths.sessionRoot, "final_comment.md"), prompt);
|
|
1018
|
+
if (issueUrl) {
|
|
1019
|
+
await runCommand("gh", ["issue", "comment", issueUrl, "--body-file", path.join(paths.sessionRoot, "final_comment.md")], {
|
|
1020
|
+
cwd: paths.targetRoot,
|
|
1021
|
+
timeout: 1000 * 60
|
|
1022
|
+
});
|
|
1023
|
+
}
|
|
1024
|
+
await writeReceipt(paths, "session_finished", `Finished session ${paths.sessionId}.`);
|
|
1025
|
+
await markStatus(paths, SESSION_STATUS.FINISHED);
|
|
1026
|
+
await markCurrentStep(paths, "");
|
|
1027
|
+
const archivedPaths = await archiveSession(paths, "completed");
|
|
1028
|
+
return buildSessionResponse(archivedPaths, {
|
|
1029
|
+
status: SESSION_STATUS.FINISHED
|
|
1030
|
+
});
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
const STEP_RUNNERS = Object.freeze({
|
|
1034
|
+
worktree_created: createWorktree,
|
|
1035
|
+
dependencies_installed: installDependencies,
|
|
1036
|
+
issue_prompt_rendered: renderIssuePrompt,
|
|
1037
|
+
issue_drafted: draftIssue,
|
|
1038
|
+
issue_created: createIssue,
|
|
1039
|
+
plan_made: makePlan,
|
|
1040
|
+
implementation_changes_accepted: acceptImplementationChanges,
|
|
1041
|
+
implementation_changes_committed: commitImplementation,
|
|
1042
|
+
review_prompt_rendered: renderReviewPrompt,
|
|
1043
|
+
review_changes_accepted: acceptReviewChanges,
|
|
1044
|
+
review_changes_committed: commitReviewChanges,
|
|
1045
|
+
user_check_completed: userCheck,
|
|
1046
|
+
doctor_run: runDoctor,
|
|
1047
|
+
pr_created: createPr,
|
|
1048
|
+
pr_merged: mergePr,
|
|
1049
|
+
session_finished: finishSession
|
|
1050
|
+
});
|
|
1051
|
+
|
|
1052
|
+
const PRECONDITION_RUNNERS = Object.freeze({
|
|
1053
|
+
git_current_branch: (paths) => assertGitCurrentBranch(paths.targetRoot),
|
|
1054
|
+
git_repository: (paths) => assertGitRepository(paths.targetRoot),
|
|
1055
|
+
github_auth: (paths) => assertGhAuth(paths.targetRoot),
|
|
1056
|
+
github_origin: (paths) => assertGithubOrigin(paths.targetRoot),
|
|
1057
|
+
issue_text_exists: assertIssueTextExists,
|
|
1058
|
+
issue_url_exists: assertIssueUrlExists,
|
|
1059
|
+
pr_url_exists: assertPrUrlExists,
|
|
1060
|
+
session_exists: assertSessionExists,
|
|
1061
|
+
worktree_exists: assertWorktreeExists
|
|
1062
|
+
});
|
|
1063
|
+
|
|
1064
|
+
async function runNamedPreconditions(paths, names = []) {
|
|
1065
|
+
return applyPreconditions(
|
|
1066
|
+
paths,
|
|
1067
|
+
names.map((name) => {
|
|
1068
|
+
return async () => PRECONDITION_RUNNERS[name](paths);
|
|
1069
|
+
})
|
|
1070
|
+
);
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1073
|
+
async function runSessionStep({
|
|
1074
|
+
targetRoot = process.cwd(),
|
|
1075
|
+
sessionId,
|
|
1076
|
+
options = {}
|
|
1077
|
+
} = {}) {
|
|
1078
|
+
return withExistingSession({ targetRoot, sessionId }, async (paths) => {
|
|
1079
|
+
const artifacts = await readSessionArtifacts(paths);
|
|
1080
|
+
if (artifacts.status === SESSION_STATUS.FINISHED || artifacts.status === SESSION_STATUS.ABANDONED) {
|
|
1081
|
+
return buildSessionResponse(paths, {
|
|
1082
|
+
ok: true,
|
|
1083
|
+
status: artifacts.status
|
|
1084
|
+
});
|
|
1085
|
+
}
|
|
1086
|
+
const nextStep = artifacts.nextStep;
|
|
1087
|
+
if (!nextStep) {
|
|
1088
|
+
return finishSession(paths);
|
|
1089
|
+
}
|
|
1090
|
+
if (nextStep === "session_created") {
|
|
1091
|
+
return failSession(paths, {
|
|
1092
|
+
code: "session_not_initialized",
|
|
1093
|
+
message: "Session exists but is missing its creation receipt.",
|
|
1094
|
+
repairCommand: "jskit session create"
|
|
1095
|
+
});
|
|
1096
|
+
}
|
|
1097
|
+
const runner = STEP_RUNNERS[nextStep];
|
|
1098
|
+
if (typeof runner !== "function") {
|
|
1099
|
+
return failSession(paths, {
|
|
1100
|
+
code: "step_not_implemented",
|
|
1101
|
+
message: `No runner exists for step ${nextStep}.`,
|
|
1102
|
+
status: SESSION_STATUS.FAILED
|
|
1103
|
+
});
|
|
1104
|
+
}
|
|
1105
|
+
const stepPreconditions = await runNamedPreconditions(paths, STEP_PRECONDITION_NAMES[nextStep] || ["session_exists"]);
|
|
1106
|
+
if (!stepPreconditions.ok) {
|
|
1107
|
+
return failSession(paths, {
|
|
1108
|
+
...stepPreconditions.error,
|
|
1109
|
+
preconditions: stepPreconditions.preconditions
|
|
1110
|
+
});
|
|
1111
|
+
}
|
|
1112
|
+
return runner(paths, options, {
|
|
1113
|
+
preconditions: stepPreconditions.preconditions
|
|
1114
|
+
});
|
|
1115
|
+
});
|
|
1116
|
+
}
|
|
1117
|
+
|
|
1118
|
+
async function abandonSession({
|
|
1119
|
+
targetRoot = process.cwd(),
|
|
1120
|
+
sessionId
|
|
1121
|
+
} = {}) {
|
|
1122
|
+
return withExistingSession({ targetRoot, sessionId }, async (paths) => {
|
|
1123
|
+
const artifacts = await readSessionArtifacts(paths);
|
|
1124
|
+
if (artifacts.status === SESSION_STATUS.FINISHED || artifacts.status === SESSION_STATUS.ABANDONED) {
|
|
1125
|
+
return buildSessionResponse(paths, {
|
|
1126
|
+
status: artifacts.status
|
|
1127
|
+
});
|
|
1128
|
+
}
|
|
1129
|
+
const issueUrl = await readTrimmedFile(path.join(paths.sessionRoot, "issue_url"));
|
|
1130
|
+
if (issueUrl) {
|
|
1131
|
+
const closeIssueResult = await runCommand("gh", ["issue", "close", issueUrl, "--comment", `Abandoned JSKIT Studio session ${paths.sessionId}.`], {
|
|
1132
|
+
cwd: paths.targetRoot,
|
|
1133
|
+
timeout: 1000 * 60
|
|
1134
|
+
});
|
|
1135
|
+
if (!closeIssueResult.ok) {
|
|
1136
|
+
return failSession(paths, {
|
|
1137
|
+
code: "issue_close_failed",
|
|
1138
|
+
message: closeIssueResult.output || "Failed to close GitHub issue for abandoned session.",
|
|
1139
|
+
repairCommand: `gh issue close ${issueUrl}`,
|
|
1140
|
+
status: SESSION_STATUS.FAILED
|
|
1141
|
+
});
|
|
1142
|
+
}
|
|
1143
|
+
}
|
|
1144
|
+
if (await hasWorktree(paths)) {
|
|
1145
|
+
await runGit(paths.targetRoot, ["worktree", "remove", "--force", paths.worktree], {
|
|
1146
|
+
timeout: 1000 * 60
|
|
1147
|
+
});
|
|
1148
|
+
}
|
|
1149
|
+
await writeTextFile(
|
|
1150
|
+
path.join(paths.sessionRoot, "steps", "abandoned"),
|
|
1151
|
+
`${timestampForReceipt()}\nAbandoned session ${paths.sessionId}.`
|
|
1152
|
+
);
|
|
1153
|
+
await markStatus(paths, SESSION_STATUS.ABANDONED);
|
|
1154
|
+
await markCurrentStep(paths, "");
|
|
1155
|
+
const archivedPaths = await archiveSession(paths, "abandoned");
|
|
1156
|
+
return buildSessionResponse(archivedPaths, {
|
|
1157
|
+
status: SESSION_STATUS.ABANDONED
|
|
1158
|
+
});
|
|
1159
|
+
});
|
|
1160
|
+
}
|
|
1161
|
+
|
|
1162
|
+
async function adoptCodexThreadId({
|
|
1163
|
+
targetRoot = process.cwd(),
|
|
1164
|
+
sessionId,
|
|
1165
|
+
codexThreadId
|
|
1166
|
+
} = {}) {
|
|
1167
|
+
if (!isValidSessionId(sessionId)) {
|
|
1168
|
+
return invalidSessionIdResponse({ targetRoot, sessionId });
|
|
1169
|
+
}
|
|
1170
|
+
const normalizedThreadId = normalizeText(codexThreadId);
|
|
1171
|
+
if (!normalizedThreadId) {
|
|
1172
|
+
return failSession(resolveSessionPaths({ targetRoot, sessionId }), {
|
|
1173
|
+
code: "codex_thread_id_required",
|
|
1174
|
+
message: "Codex thread id is required."
|
|
1175
|
+
});
|
|
1176
|
+
}
|
|
1177
|
+
return withExistingSession({ targetRoot, sessionId }, async (paths) => {
|
|
1178
|
+
if (paths.archive && paths.archive !== "active") {
|
|
1179
|
+
return buildSessionResponse(paths, {
|
|
1180
|
+
ok: false,
|
|
1181
|
+
errors: [
|
|
1182
|
+
createError({
|
|
1183
|
+
code: "session_archived_read_only",
|
|
1184
|
+
message: `Session ${paths.sessionId} is archived and cannot be mutated.`
|
|
1185
|
+
})
|
|
1186
|
+
]
|
|
1187
|
+
});
|
|
1188
|
+
}
|
|
1189
|
+
await writeTextFile(path.join(paths.sessionRoot, "codex_thread_id"), normalizedThreadId);
|
|
1190
|
+
return buildSessionResponse(paths);
|
|
1191
|
+
});
|
|
1192
|
+
}
|
|
1193
|
+
|
|
1194
|
+
export {
|
|
1195
|
+
SESSION_STATUS,
|
|
1196
|
+
STEP_DEFINITIONS,
|
|
1197
|
+
STEP_IDS,
|
|
1198
|
+
STEP_PRECONDITION_NAMES,
|
|
1199
|
+
abandonSession,
|
|
1200
|
+
adoptDependenciesInstalled,
|
|
1201
|
+
adoptCodexThreadId,
|
|
1202
|
+
buildSessionResponse,
|
|
1203
|
+
buildSessionErrorResponse,
|
|
1204
|
+
createSession,
|
|
1205
|
+
createSessionId,
|
|
1206
|
+
extractIssueTitle,
|
|
1207
|
+
extractIssueText,
|
|
1208
|
+
extractPlanText,
|
|
1209
|
+
inspectSession,
|
|
1210
|
+
inspectSessionDiff,
|
|
1211
|
+
inspectSessionDetails,
|
|
1212
|
+
isValidSessionId,
|
|
1213
|
+
listSessions,
|
|
1214
|
+
renderTemplate,
|
|
1215
|
+
recordDependenciesInstalled,
|
|
1216
|
+
resolveSessionPaths,
|
|
1217
|
+
runSessionStep
|
|
1218
|
+
};
|