@jskit-ai/jskit-cli 0.2.79 → 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.
@@ -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
+ };