@jskit-ai/jskit-cli 0.2.80 → 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.
@@ -1,10 +1,13 @@
1
1
  import {
2
2
  mkdir,
3
3
  readFile,
4
- readdir
4
+ readdir,
5
+ rmdir
5
6
  } from "node:fs/promises";
6
7
  import path from "node:path";
7
8
  import {
9
+ PLAN_EXECUTION_CODEX_HANDOFF,
10
+ REVIEW_EXECUTION_CODEX_HANDOFF,
8
11
  SESSION_STATUS,
9
12
  STEP_DEFINITIONS,
10
13
  STEP_IDS,
@@ -47,8 +50,8 @@ import {
47
50
  assertGitCurrentBranch,
48
51
  assertGitRepository,
49
52
  assertGithubOrigin,
50
- assertIssueArtifacts,
51
53
  assertIssueTextExists,
54
+ assertIssueUrlExists,
52
55
  assertPrUrlExists,
53
56
  assertSessionExists,
54
57
  assertTargetRootWritable,
@@ -121,10 +124,31 @@ async function withExistingSession(input, handler) {
121
124
  });
122
125
  }
123
126
 
124
- function extractIssueText(value = "") {
127
+ function extractMarkedText(value = "", marker = "") {
125
128
  const text = normalizeText(value);
126
- const match = /\[issue_text\]([\s\S]*?)\[\/issue_text\]/u.exec(text);
127
- return normalizeText(match ? match[1] : text);
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);
128
152
  }
129
153
 
130
154
  async function createSession({
@@ -161,7 +185,6 @@ async function createSession({
161
185
 
162
186
  await ensureStudioGitExclude(initialPaths.targetRoot);
163
187
  await mkdir(initialPaths.sessionRoot, { recursive: true });
164
- await mkdir(initialPaths.worktreesRoot, { recursive: true });
165
188
  await writeTextFile(path.join(initialPaths.sessionRoot, "transcript.log"), "");
166
189
  await markStatus(initialPaths, SESSION_STATUS.PENDING);
167
190
  await writeReceipt(initialPaths, "session_created", `Created JSKIT Studio issue session ${initialPaths.sessionId}.`);
@@ -172,14 +195,38 @@ async function createSession({
172
195
  });
173
196
  }
174
197
 
175
- async function listSessions({ targetRoot = process.cwd() } = {}) {
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" } = {}) {
176
218
  const paths = resolveSessionPaths({ targetRoot });
177
219
  const sessions = [];
178
- const roots = [
179
- { archive: "active", root: paths.sessionsRoot },
180
- { archive: "completed", root: paths.completedSessionsRoot },
181
- { archive: "abandoned", root: paths.abandonedSessionsRoot }
182
- ];
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
+ }));
183
230
 
184
231
  for (const rootInfo of roots) {
185
232
  let entries = [];
@@ -207,6 +254,8 @@ async function listSessions({ targetRoot = process.cwd() } = {}) {
207
254
  }
208
255
  sessions.sort((left, right) => right.sessionId.localeCompare(left.sessionId));
209
256
  return {
257
+ archive: selectedArchives.length === 1 ? selectedArchives[0] : "mixed",
258
+ archives: selectedArchives,
210
259
  ok: true,
211
260
  stepDefinitions: buildStepDefinitions(),
212
261
  sessions
@@ -227,7 +276,9 @@ async function inspectSession({
227
276
  function emptySessionDetails(response) {
228
277
  return {
229
278
  ...response,
279
+ issueTitle: "",
230
280
  issueText: "",
281
+ planText: "",
231
282
  receipts: [],
232
283
  transcriptLog: ""
233
284
  };
@@ -244,20 +295,50 @@ async function inspectSessionDetails({
244
295
  const { paths, preconditions } = context;
245
296
  const response = await buildSessionResponse(paths, { preconditions });
246
297
 
247
- const [issueText, receipts, transcriptLog] = await Promise.all([
298
+ const [issueText, issueTitle, planText, receipts, transcriptLog] = await Promise.all([
248
299
  readTextIfExists(path.join(paths.sessionRoot, "issue.md")),
300
+ readTrimmedFile(path.join(paths.sessionRoot, "issue_title")),
301
+ readTextIfExists(path.join(paths.sessionRoot, "plan.md")),
249
302
  readReceiptSteps(paths),
250
303
  readTextIfExists(path.join(paths.sessionRoot, "transcript.log"))
251
304
  ]);
252
305
 
253
306
  return {
254
307
  ...response,
308
+ issueTitle,
255
309
  issueText: issueText.trim(),
310
+ planText: planText.trim(),
256
311
  receipts,
257
312
  transcriptLog
258
313
  };
259
314
  }
260
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
+
261
342
  async function createWorktree(paths, _options = {}, context = {}) {
262
343
  const preconditions = context.preconditions || [];
263
344
  if (await hasWorktree(paths)) {
@@ -268,7 +349,16 @@ async function createWorktree(paths, _options = {}, context = {}) {
268
349
  });
269
350
  }
270
351
 
271
- await mkdir(paths.worktreesRoot, { recursive: true });
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
+ }
272
362
  const result = await runGit(paths.targetRoot, ["worktree", "add", "-b", paths.branch, paths.worktree, "HEAD"], {
273
363
  timeout: 30000
274
364
  });
@@ -287,6 +377,63 @@ async function createWorktree(paths, _options = {}, context = {}) {
287
377
  });
288
378
  }
289
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
+
290
437
  async function renderIssuePrompt(paths, options = {}) {
291
438
  const userInput = normalizeText(options.prompt);
292
439
  if (!userInput) {
@@ -299,7 +446,7 @@ async function renderIssuePrompt(paths, options = {}) {
299
446
  const prompt = await renderPrompt(paths, "new_issue.md", {
300
447
  user_input: userInput
301
448
  });
302
- await writeTextFile(path.join(paths.sessionRoot, "prompt.md"), prompt);
449
+ await writePromptArtifact(paths, "issue_draft.md", prompt);
303
450
  await writeReceipt(paths, "issue_prompt_rendered", "Rendered the issue drafting prompt.");
304
451
  await markStatus(paths, SESSION_STATUS.WAITING_FOR_USER);
305
452
  return buildSessionResponse(paths, {
@@ -318,7 +465,9 @@ async function draftIssue(paths, options = {}) {
318
465
  repairCommand: `jskit session ${paths.sessionId} step --issue -`
319
466
  });
320
467
  }
468
+ const issueTitle = normalizeText(options.issueTitle) || extractIssueTitle(options.issue) || titleFromIssue(issueText);
321
469
  await writeTextFile(path.join(paths.sessionRoot, "issue.md"), issueText);
470
+ await writeTextFile(path.join(paths.sessionRoot, "issue_title"), issueTitle);
322
471
  await writeReceipt(paths, "issue_drafted", "Saved approved issue text.");
323
472
  await markStatus(paths, SESSION_STATUS.RUNNING);
324
473
  return buildSessionResponse(paths);
@@ -335,11 +484,12 @@ function titleFromIssue(issueText) {
335
484
  async function createIssue(paths, _options = {}, context = {}) {
336
485
  const preconditions = context.preconditions || [];
337
486
  const issueText = await readTrimmedFile(path.join(paths.sessionRoot, "issue.md"));
487
+ const issueTitle = await readTrimmedFile(path.join(paths.sessionRoot, "issue_title")) || titleFromIssue(issueText);
338
488
  const result = await runCommand("gh", [
339
489
  "issue",
340
490
  "create",
341
491
  "--title",
342
- titleFromIssue(issueText),
492
+ issueTitle,
343
493
  "--body-file",
344
494
  path.join(paths.sessionRoot, "issue.md")
345
495
  ], {
@@ -363,18 +513,65 @@ async function createIssue(paths, _options = {}, context = {}) {
363
513
  });
364
514
  }
365
515
 
366
- async function renderImplementationPrompt(paths) {
516
+ async function makePlan(paths, options = {}, context = {}) {
517
+ const preconditions = context.preconditions || [];
367
518
  const issueText = await readTrimmedFile(path.join(paths.sessionRoot, "issue.md"));
519
+ const issueTitle = await readTrimmedFile(path.join(paths.sessionRoot, "issue_title")) || titleFromIssue(issueText);
368
520
  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
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
372
567
  });
373
- await writeTextFile(path.join(paths.sessionRoot, "prompt.md"), prompt);
374
- await writeReceipt(paths, "implementation_prompt_rendered", "Rendered the implementation prompt.");
568
+ await writePromptArtifact(paths, "plan_execution.md", executionPrompt);
569
+ await writeReceipt(paths, "plan_made", `Saved plan and commented on ${issueUrl}.`);
375
570
  await markStatus(paths, SESSION_STATUS.WAITING_FOR_USER);
376
571
  return buildSessionResponse(paths, {
377
- prompt,
572
+ codex: PLAN_EXECUTION_CODEX_HANDOFF,
573
+ preconditions,
574
+ prompt: executionPrompt,
378
575
  status: SESSION_STATUS.WAITING_FOR_USER
379
576
  });
380
577
  }
@@ -396,7 +593,110 @@ async function worktreeStatus(worktree) {
396
593
  };
397
594
  }
398
595
 
399
- async function detectChanges(paths) {
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) {
400
700
  const status = await worktreeStatus(paths.worktree);
401
701
  if (!status.ok) {
402
702
  return failSession(paths, {
@@ -408,11 +708,11 @@ async function detectChanges(paths) {
408
708
  if (status.changedFiles.length < 1) {
409
709
  return failSession(paths, {
410
710
  code: "changes_missing",
411
- message: "No worktree changes found. Paste the implementation prompt into Codex and retry after changes exist.",
711
+ message: "No worktree changes found. Ask Codex to implement the approved plan, inspect the worktree, then accept changes once ready.",
412
712
  repairCommand: `jskit session ${paths.sessionId} step`
413
713
  });
414
714
  }
415
- await writeReceipt(paths, "implementation_changes_detected", `Detected ${status.changedFiles.length} changed file entries.`);
715
+ await writeReceipt(paths, "implementation_changes_accepted", `Accepted ${status.changedFiles.length} changed file entries for commit.`);
416
716
  await markStatus(paths, SESSION_STATUS.RUNNING);
417
717
  return buildSessionResponse(paths);
418
718
  }
@@ -482,83 +782,73 @@ async function changedFilesFromLastCommit(paths) {
482
782
  return result.stdout.split(/\r?\n/u).map((line) => line.trim()).filter(Boolean).join("\n");
483
783
  }
484
784
 
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) {
785
+ async function renderReviewPrompt(paths) {
494
786
  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." : ""
787
+ changed_files: await changedFilesFromLastCommit(paths)
498
788
  });
499
- await writeTextFile(path.join(paths.sessionRoot, "prompt.md"), prompt);
500
- await writeReceipt(paths, reviewPromptStepId(passNumber), `Rendered review prompt pass ${passNumber}.`);
789
+ await writePromptArtifact(paths, "review.md", prompt);
790
+ await writeReceipt(paths, "review_prompt_rendered", "Started code review.");
501
791
  await markStatus(paths, SESSION_STATUS.WAITING_FOR_USER);
502
792
  return buildSessionResponse(paths, {
793
+ codex: REVIEW_EXECUTION_CODEX_HANDOFF,
503
794
  prompt,
504
795
  status: SESSION_STATUS.WAITING_FOR_USER
505
796
  });
506
797
  }
507
798
 
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";
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);
514
814
  }
515
815
 
516
- async function detectAndCommitReviewChanges(paths, passNumber) {
816
+ async function commitReviewChanges(paths) {
517
817
  const result = await commitWorktree(paths, {
518
818
  allowNoChanges: true,
519
- message: `Apply review pass ${passNumber} for ${paths.sessionId}`
819
+ message: `Apply review changes for ${paths.sessionId}`
520
820
  });
521
821
  if (!result.ok) {
522
822
  return failSession(paths, {
523
823
  code: "review_commit_failed",
524
- message: result.output || `Failed to commit review pass ${passNumber}.`,
824
+ message: result.output || "Failed to commit review changes.",
525
825
  repairCommand: `git -C ${paths.worktree} status --short`
526
826
  });
527
827
  }
528
828
  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);
829
+ ? "Committed review changes."
830
+ : "No review changes detected.";
831
+ await writeReceipt(paths, "review_changes_committed", message);
532
832
  await markStatus(paths, SESSION_STATUS.RUNNING);
533
833
  return buildSessionResponse(paths);
534
834
  }
535
835
 
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 = {}) {
836
+ async function userCheck(paths, options = {}) {
545
837
  const result = normalizeText(options.userCheck || options["user-check"]).toLowerCase();
546
838
  if (result === "passed" || result === "pass" || result === "ok" || result === "yes") {
547
- await writeReceipt(paths, userCheckStepId(passNumber), `User confirmed check ${passNumber} passed.`);
839
+ await writeReceipt(paths, "user_check_completed", "User confirmed check passed.");
548
840
  await markStatus(paths, SESSION_STATUS.RUNNING);
549
841
  return buildSessionResponse(paths);
550
842
  }
551
843
  if (result === "failed" || result === "fail" || result === "no") {
552
844
  return failSession(paths, {
553
845
  code: "user_check_failed",
554
- message: `User check ${passNumber} was reported as failed. Continue in Codex, then retry this step with --user-check passed.`,
846
+ message: "User check was reported as failed. Continue in Codex, then retry this step with --user-check passed.",
555
847
  repairCommand: `jskit session ${paths.sessionId} step --user-check passed`
556
848
  });
557
849
  }
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);
850
+ const prompt = await renderPrompt(paths, "user_check.md");
851
+ await writePromptArtifact(paths, "user_check.md", prompt);
562
852
  await markStatus(paths, SESSION_STATUS.WAITING_FOR_USER);
563
853
  return buildSessionResponse(paths, {
564
854
  prompt,
@@ -597,7 +887,7 @@ async function runDoctor(paths) {
597
887
  const prompt = await renderPrompt(paths, "doctor_failure.md", {
598
888
  doctor_output: result.output
599
889
  });
600
- await writeTextFile(path.join(paths.sessionRoot, "prompt.md"), prompt);
890
+ await writePromptArtifact(paths, "doctor_failure.md", prompt);
601
891
  return failSession(paths, {
602
892
  code: "doctor_failed",
603
893
  message: "Doctor/verification command failed. Paste the failure prompt into Codex, then rerun this step.",
@@ -610,30 +900,25 @@ async function runDoctor(paths) {
610
900
  return buildSessionResponse(paths);
611
901
  }
612
902
 
613
- async function pushBranch(paths) {
614
- const result = await runGitInWorktree(paths.worktree, ["push", "-u", "origin", "HEAD"], {
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"], {
615
910
  timeout: 1000 * 60 * 5
616
911
  });
617
- if (!result.ok) {
912
+ if (!pushResult.ok) {
618
913
  return failSession(paths, {
619
914
  code: "branch_push_failed",
620
- message: result.output || "Failed to push session branch.",
915
+ message: pushResult.output || "Failed to push session branch.",
621
916
  repairCommand: `git -C ${paths.worktree} push -u origin HEAD`
622
917
  });
623
918
  }
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
919
  const issueUrl = await readTrimmedFile(path.join(paths.sessionRoot, "issue_url"));
636
920
  const issueText = await readTrimmedFile(path.join(paths.sessionRoot, "issue.md"));
921
+ const issueTitle = await readTrimmedFile(path.join(paths.sessionRoot, "issue_title")) || titleFromIssue(issueText);
637
922
  const issueNumber = issueNumberFromUrl(issueUrl);
638
923
  const body = [
639
924
  issueNumber ? `Closes #${issueNumber}` : "",
@@ -646,7 +931,7 @@ async function createPr(paths) {
646
931
  "pr",
647
932
  "create",
648
933
  "--title",
649
- titleFromIssue(issueText),
934
+ issueTitle,
650
935
  "--body-file",
651
936
  bodyPath
652
937
  ], {
@@ -657,7 +942,7 @@ async function createPr(paths) {
657
942
  const prompt = await renderPrompt(paths, "pr_failure.md", {
658
943
  doctor_output: result.output
659
944
  });
660
- await writeTextFile(path.join(paths.sessionRoot, "prompt.md"), prompt);
945
+ await writePromptArtifact(paths, "pr_create_failure.md", prompt);
661
946
  return failSession(paths, {
662
947
  code: "pr_create_failed",
663
948
  message: result.output || "Failed to create PR.",
@@ -667,42 +952,41 @@ async function createPr(paths) {
667
952
  }
668
953
  const prUrl = result.stdout.split(/\r?\n/u).map((line) => line.trim()).find(Boolean) || result.stdout;
669
954
  await writeTextFile(path.join(paths.sessionRoot, "pr_url"), prUrl);
670
- await writeReceipt(paths, "pr_created", `Created PR ${prUrl}.`);
955
+ await writeReceipt(paths, "pr_created", `Pushed branch ${paths.branch} and created PR ${prUrl}.`);
671
956
  await markStatus(paths, SESSION_STATUS.RUNNING);
672
957
  return buildSessionResponse(paths);
673
958
  }
674
959
 
675
960
  async function mergePr(paths) {
676
961
  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}.`], {
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"], {
696
966
  cwd: paths.worktree,
697
- timeout: 1000 * 60
967
+ timeout: 1000 * 60 * 5
698
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`);
699
989
  }
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
990
  if (await hasWorktree(paths)) {
707
991
  const result = await runGit(paths.targetRoot, ["worktree", "remove", paths.worktree], {
708
992
  timeout: 1000 * 60
@@ -715,7 +999,7 @@ async function removeWorktree(paths) {
715
999
  });
716
1000
  }
717
1001
  }
718
- await writeReceipt(paths, "worktree_removed", `Removed worktree ${paths.worktree}.`);
1002
+ await writeReceipt(paths, "pr_merged", `Merged PR ${prUrl} and removed worktree ${paths.worktree}.`);
719
1003
  await markStatus(paths, SESSION_STATUS.RUNNING);
720
1004
  return buildSessionResponse(paths);
721
1005
  }
@@ -748,26 +1032,20 @@ async function finishSession(paths) {
748
1032
 
749
1033
  const STEP_RUNNERS = Object.freeze({
750
1034
  worktree_created: createWorktree,
1035
+ dependencies_installed: installDependencies,
751
1036
  issue_prompt_rendered: renderIssuePrompt,
752
1037
  issue_drafted: draftIssue,
753
1038
  issue_created: createIssue,
754
- implementation_prompt_rendered: renderImplementationPrompt,
755
- implementation_changes_detected: detectChanges,
1039
+ plan_made: makePlan,
1040
+ implementation_changes_accepted: acceptImplementationChanges,
756
1041
  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),
1042
+ review_prompt_rendered: renderReviewPrompt,
1043
+ review_changes_accepted: acceptReviewChanges,
1044
+ review_changes_committed: commitReviewChanges,
1045
+ user_check_completed: userCheck,
766
1046
  doctor_run: runDoctor,
767
- branch_pushed: pushBranch,
768
1047
  pr_created: createPr,
769
1048
  pr_merged: mergePr,
770
- worktree_removed: removeWorktree,
771
1049
  session_finished: finishSession
772
1050
  });
773
1051
 
@@ -776,8 +1054,8 @@ const PRECONDITION_RUNNERS = Object.freeze({
776
1054
  git_repository: (paths) => assertGitRepository(paths.targetRoot),
777
1055
  github_auth: (paths) => assertGhAuth(paths.targetRoot),
778
1056
  github_origin: (paths) => assertGithubOrigin(paths.targetRoot),
779
- issue_artifacts: assertIssueArtifacts,
780
1057
  issue_text_exists: assertIssueTextExists,
1058
+ issue_url_exists: assertIssueUrlExists,
781
1059
  pr_url_exists: assertPrUrlExists,
782
1060
  session_exists: assertSessionExists,
783
1061
  worktree_exists: assertWorktreeExists
@@ -850,10 +1128,18 @@ async function abandonSession({
850
1128
  }
851
1129
  const issueUrl = await readTrimmedFile(path.join(paths.sessionRoot, "issue_url"));
852
1130
  if (issueUrl) {
853
- await runCommand("gh", ["issue", "close", issueUrl, "--comment", `Abandoned JSKIT Studio session ${paths.sessionId}.`], {
1131
+ const closeIssueResult = await runCommand("gh", ["issue", "close", issueUrl, "--comment", `Abandoned JSKIT Studio session ${paths.sessionId}.`], {
854
1132
  cwd: paths.targetRoot,
855
1133
  timeout: 1000 * 60
856
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
+ }
857
1143
  }
858
1144
  if (await hasWorktree(paths)) {
859
1145
  await runGit(paths.targetRoot, ["worktree", "remove", "--force", paths.worktree], {
@@ -911,17 +1197,22 @@ export {
911
1197
  STEP_IDS,
912
1198
  STEP_PRECONDITION_NAMES,
913
1199
  abandonSession,
1200
+ adoptDependenciesInstalled,
914
1201
  adoptCodexThreadId,
915
1202
  buildSessionResponse,
916
1203
  buildSessionErrorResponse,
917
1204
  createSession,
918
1205
  createSessionId,
1206
+ extractIssueTitle,
919
1207
  extractIssueText,
1208
+ extractPlanText,
920
1209
  inspectSession,
1210
+ inspectSessionDiff,
921
1211
  inspectSessionDetails,
922
1212
  isValidSessionId,
923
1213
  listSessions,
924
1214
  renderTemplate,
1215
+ recordDependenciesInstalled,
925
1216
  resolveSessionPaths,
926
1217
  runSessionStep
927
1218
  };