@jskit-ai/jskit-cli 0.2.89 → 0.2.91

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.
Files changed (25) hide show
  1. package/package.json +4 -4
  2. package/src/server/commandHandlers/session.js +173 -142
  3. package/src/server/core/argParser.js +0 -4
  4. package/src/server/core/commandCatalog.js +47 -35
  5. package/src/server/sessionRuntime/constants.js +125 -348
  6. package/src/server/sessionRuntime/io.js +2 -2
  7. package/src/server/sessionRuntime/preconditions.js +39 -143
  8. package/src/server/sessionRuntime/promptRenderer.js +2 -15
  9. package/src/server/sessionRuntime/prompts/app_blueprint.md +2 -7
  10. package/src/server/sessionRuntime/prompts/{automated_checks.md → automated_checks_run.md} +2 -17
  11. package/src/server/sessionRuntime/prompts/{update_blueprint.md → blueprint_updated.md} +2 -11
  12. package/src/server/sessionRuntime/prompts/{deep_ui_check.md → deep_ui_check_run.md} +2 -19
  13. package/src/server/sessionRuntime/prompts/final_report_created.md +44 -0
  14. package/src/server/sessionRuntime/prompts/issue_created.md +26 -0
  15. package/src/server/sessionRuntime/prompts/issue_prompt_rendered.md +1 -0
  16. package/src/server/sessionRuntime/prompts/{plan_issue.md → make_plan.md} +8 -37
  17. package/src/server/sessionRuntime/prompts/{execute_plan.md → plan_executed.md} +4 -29
  18. package/src/server/sessionRuntime/prompts/{prepare_pr_merge.md → pr_merge_prepared.md} +3 -3
  19. package/src/server/sessionRuntime/prompts/{resolve_deslop_findings.md → review_changes_accepted_resolve.md} +2 -6
  20. package/src/server/sessionRuntime/prompts/{review_changes.md → review_prompt_rendered.md} +3 -28
  21. package/src/server/sessionRuntime/prompts/{user_check.md → user_check_completed.md} +1 -11
  22. package/src/server/sessionRuntime/responses.js +431 -292
  23. package/src/server/sessionRuntime.js +1201 -926
  24. package/src/server/sessionRuntime/prompts/issue_details.md +0 -49
  25. package/src/server/sessionRuntime/prompts/new_issue.md +0 -46
@@ -13,13 +13,17 @@ import path from "node:path";
13
13
  import {
14
14
  BLUEPRINT_CODEX_HANDOFF,
15
15
  AUTOMATED_CHECK_REPAIR_CODEX_HANDOFF,
16
- CYCLE_STEP_IDS,
16
+ DEPENDENCIES_INSTALL_RESULT_FILE,
17
17
  DEEP_UI_CHECK_CODEX_HANDOFF,
18
- ISSUE_DETAILS_CODEX_HANDOFF,
18
+ ISSUE_DEFINITION_CODEX_HANDOFF,
19
+ ISSUE_FILE_CODEX_HANDOFF,
20
+ PLAN_CODEX_HANDOFF,
19
21
  PLAN_EXECUTION_CODEX_HANDOFF,
22
+ PR_FILE_CODEX_HANDOFF,
20
23
  PR_MERGE_PREP_CODEX_HANDOFF,
21
24
  REVIEW_PASS_LIMIT,
22
25
  REVIEW_EXECUTION_CODEX_HANDOFF,
26
+ RESOLVE_DESLOP_CODEX_HANDOFF,
23
27
  SESSION_STATUS,
24
28
  SESSION_WORKFLOW_VERSION,
25
29
  STEP_DEFINITION_BY_ID,
@@ -35,7 +39,7 @@ import {
35
39
  runCommand,
36
40
  runGit,
37
41
  runGitInWorktree,
38
- timestampForReceipt,
42
+ timestampForStepRecord,
39
43
  writeTextFile
40
44
  } from "./sessionRuntime/io.js";
41
45
  import {
@@ -57,13 +61,11 @@ import {
57
61
  markStatus,
58
62
  normalizeReviewPassNumber,
59
63
  readActiveCycle,
60
- readReceiptSteps,
64
+ readStepRecords,
61
65
  readReviewPasses,
62
66
  readSessionArtifacts,
63
67
  reviewPassRoot,
64
- writeActiveCycle,
65
- writeCycleReceipt,
66
- writeReceipt
68
+ writeStepRecord
67
69
  } from "./sessionRuntime/responses.js";
68
70
  import {
69
71
  applyPreconditions,
@@ -73,22 +75,20 @@ import {
73
75
  assertBlueprintUpdateSatisfied,
74
76
  assertDeepUiCheckSatisfied,
75
77
  assertDependenciesInstalled,
76
- assertFinalReportExists,
77
78
  assertGhAuth,
78
79
  assertGitCurrentBranch,
79
80
  assertGitRepository,
80
81
  assertGithubOrigin,
81
- assertIssueMetadataExists,
82
82
  assertIssueTextExists,
83
83
  assertIssueUrlExists,
84
84
  assertAutomatedChecksPassed,
85
- assertIssueDetailsExists,
86
85
  assertMainCheckoutSyncSatisfied,
87
- assertPlanTextExists,
88
86
  assertPrUrlExists,
87
+ assertPullRequestFileExists,
89
88
  assertReadyJskitApp,
90
89
  assertSessionExists,
91
90
  assertTargetRootWritable,
91
+ assertUserCheckPassed,
92
92
  assertWorktreeExists,
93
93
  ensureStudioGitExclude,
94
94
  hasWorktree
@@ -176,15 +176,6 @@ function extractMarkedText(value = "", marker = "") {
176
176
  return normalizeText(matches.length > 0 ? matches[matches.length - 1][1] : "");
177
177
  }
178
178
 
179
- function extractMarkedField(value = "", fieldName = "") {
180
- const normalizedFieldName = normalizeText(fieldName);
181
- if (!normalizedFieldName) {
182
- return "";
183
- }
184
- const pattern = new RegExp(`^\\s*${normalizedFieldName.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&")}\\s*:\\s*(.+)$`, "imu");
185
- return normalizeText(pattern.exec(normalizeText(value))?.[1] || "");
186
- }
187
-
188
179
  function extractIssueTitle(value = "") {
189
180
  return extractMarkedText(value, "issue_title");
190
181
  }
@@ -193,100 +184,10 @@ function extractIssueText(value = "") {
193
184
  return extractMarkedText(value, "issue_text") || normalizeText(value);
194
185
  }
195
186
 
196
- function extractPlanText(value = "") {
197
- return extractMarkedText(value, "plan") || normalizeText(value);
198
- }
199
-
200
- function extractIssueDetails(value = "") {
201
- return extractMarkedText(value, "issue_details");
202
- }
203
-
204
- function extractIssueCategory(value = "") {
205
- return extractMarkedText(value, "issue_category");
206
- }
207
-
208
- function extractUiImpact(value = "") {
209
- return extractMarkedText(value, "ui_impact");
210
- }
211
-
212
- function extractAgentDecisions(value = "") {
213
- return extractMarkedText(value, "agent_decisions");
214
- }
215
-
216
- function normalizeIssueCategory(value = "") {
217
- const category = normalizeText(value).toLowerCase();
218
- return ["client", "server", "client_server", "tooling", "unknown"].includes(category)
219
- ? category
220
- : "";
221
- }
222
-
223
- function normalizeUiImpact(value = "") {
224
- const impact = normalizeText(value).toLowerCase();
225
- return ["none", "possible", "definite", "unknown"].includes(impact)
226
- ? impact
227
- : "";
228
- }
229
-
230
187
  async function writePromptArtifact(paths, fileName, prompt) {
231
188
  await writeTextFile(path.join(paths.sessionRoot, "prompts", fileName), prompt);
232
189
  }
233
190
 
234
- async function codexResultPath(paths, stepId) {
235
- if (CYCLE_STEP_IDS.includes(stepId)) {
236
- const activeCycle = await readActiveCycle(paths);
237
- return path.join(cycleRootPath(paths, activeCycle), "codex_results", `${stepId}.md`);
238
- }
239
- return path.join(paths.sessionRoot, "codex_results", `${stepId}.md`);
240
- }
241
-
242
- function codexResponseContractForStep(stepId) {
243
- const contract = STEP_DEFINITION_BY_ID[stepId]?.codex?.responseContract;
244
- return contract && typeof contract === "object" && !Array.isArray(contract) ? contract : null;
245
- }
246
-
247
- async function requireCodexStepResult(paths, stepId, result, preconditions = [], contractOverride = null) {
248
- const contract = contractOverride || codexResponseContractForStep(stepId);
249
- if (contract?.required !== true || !contract.marker) {
250
- return null;
251
- }
252
-
253
- const source = String(result || "");
254
- if (!source.trim()) {
255
- return failSession(paths, {
256
- code: "codex_result_required",
257
- message: `The ${STEP_DEFINITION_BY_ID[stepId]?.label || stepId} step has already produced a Codex prompt. Paste Codex's final marked result with --codex-result - before JSKIT records the receipt.`,
258
- repairCommand: `jskit session ${paths.sessionId} step --codex-result -`,
259
- preconditions
260
- });
261
- }
262
-
263
- const markedResult = extractMarkedText(source, contract.marker);
264
- if (!markedResult) {
265
- return failSession(paths, {
266
- code: "codex_result_marker_missing",
267
- message: `Codex output for ${STEP_DEFINITION_BY_ID[stepId]?.label || stepId} must include [${contract.marker}]... [/${contract.marker}] before JSKIT can advance.`,
268
- repairCommand: `jskit session ${paths.sessionId} step --codex-result -`,
269
- preconditions
270
- });
271
- }
272
- const stepField = normalizeText(contract.stepField);
273
- if (stepField) {
274
- const resultStep = extractMarkedField(markedResult, stepField);
275
- if (resultStep !== stepId) {
276
- return failSession(paths, {
277
- code: "codex_result_step_mismatch",
278
- message: `Codex output for ${STEP_DEFINITION_BY_ID[stepId]?.label || stepId} must include ${stepField}: ${stepId} inside [${contract.marker}] before JSKIT can advance.`,
279
- repairCommand: `jskit session ${paths.sessionId} step --codex-result -`,
280
- preconditions
281
- });
282
- }
283
- }
284
-
285
- await writeTextFile(await codexResultPath(paths, stepId), source);
286
- await appendAgentDecisions(paths, extractAgentDecisions(source));
287
- return null;
288
- }
289
-
290
191
  function commandText(command, args = []) {
291
192
  return [command, ...args].map((part) => {
292
193
  const value = String(part || "");
@@ -300,28 +201,6 @@ function cycleRootPath(paths, cycle) {
300
201
  return path.join(paths.sessionRoot, "cycles", `cycle_${cycle}`);
301
202
  }
302
203
 
303
- function cyclePlanPath(paths, cycle) {
304
- return path.join(cycleRootPath(paths, cycle), "plan.md");
305
- }
306
-
307
- function cyclePlanPromptFileName(cycle) {
308
- return `cycle_${cycle}_plan_request.md`;
309
- }
310
-
311
- function cyclePlanExecutionPromptFileName(cycle) {
312
- return `cycle_${cycle}_plan_execution.md`;
313
- }
314
-
315
- async function readCurrentPlan(paths) {
316
- const activeCycle = await readActiveCycle(paths);
317
- const planPath = cyclePlanPath(paths, activeCycle);
318
- return {
319
- activeCycle,
320
- planPath,
321
- planText: await readTrimmedFile(planPath)
322
- };
323
- }
324
-
325
204
  function commandOutputSummary(output = "") {
326
205
  const normalized = normalizeText(output);
327
206
  if (normalized.length <= 1800) {
@@ -341,7 +220,7 @@ async function appendCommandLog(paths, {
341
220
  return;
342
221
  }
343
222
  const entry = {
344
- at: timestampForReceipt(),
223
+ at: timestampForStepRecord(),
345
224
  command: commandText(command, args),
346
225
  cwd,
347
226
  exitCode: Number.isInteger(result.exitCode) ? result.exitCode : null,
@@ -420,14 +299,14 @@ function packageScriptRepairCommand(paths, command, args) {
420
299
  return `cd ${paths.worktree} && ${command} ${args.join(" ")}`;
421
300
  }
422
301
 
423
- function packageScriptReceiptName(scriptName) {
302
+ function packageScriptRecordName(scriptName) {
424
303
  return normalizeText(scriptName).replace(/[^a-zA-Z0-9._-]+/gu, "_");
425
304
  }
426
305
 
427
- async function writeSessionHookReceipt(paths, scriptName, message) {
306
+ async function writeSessionHookRecord(paths, scriptName, message) {
428
307
  await writeTextFile(
429
- path.join(paths.sessionRoot, "hooks", packageScriptReceiptName(scriptName)),
430
- `${timestampForReceipt()}\n${normalizeText(message) || `${scriptName} completed.`}`
308
+ path.join(paths.sessionRoot, "hooks", packageScriptRecordName(scriptName)),
309
+ `${timestampForStepRecord()}\n${normalizeText(message) || `${scriptName} completed.`}`
431
310
  );
432
311
  }
433
312
 
@@ -465,7 +344,7 @@ async function runOptionalSessionPackageScript(paths, {
465
344
  })
466
345
  };
467
346
  }
468
- await writeSessionHookReceipt(paths, scriptName, result.output || `${scriptName} completed.`);
347
+ await writeSessionHookRecord(paths, scriptName, result.output || `${scriptName} completed.`);
469
348
  return {
470
349
  ok: true,
471
350
  ran: true,
@@ -497,29 +376,6 @@ async function runSessionProvisioningHook(paths, {
497
376
  });
498
377
  }
499
378
 
500
- async function readIssueMetadata(paths) {
501
- const source = await readTextIfExists(path.join(paths.sessionRoot, "issue_metadata.json"));
502
- if (!source) {
503
- return {};
504
- }
505
- try {
506
- const parsed = JSON.parse(source);
507
- return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
508
- } catch {
509
- return {};
510
- }
511
- }
512
-
513
- async function writeIssueMetadata(paths, metadata = {}) {
514
- const existing = await readIssueMetadata(paths);
515
- const next = {
516
- ...existing,
517
- ...metadata
518
- };
519
- await writeTextFile(path.join(paths.sessionRoot, "issue_metadata.json"), `${JSON.stringify(next, null, 2)}\n`);
520
- return next;
521
- }
522
-
523
379
  async function readGithubComments(paths) {
524
380
  const source = await readTextIfExists(path.join(paths.sessionRoot, "github_comments.json"));
525
381
  if (!source) {
@@ -564,7 +420,7 @@ async function commentOnIssueOnce(paths, {
564
420
  }
565
421
  comments[normalizedPurpose] = {
566
422
  bodyFile,
567
- commentedAt: timestampForReceipt(),
423
+ commentedAt: timestampForStepRecord(),
568
424
  issueUrl,
569
425
  purpose: normalizedPurpose
570
426
  };
@@ -575,61 +431,42 @@ async function commentOnIssueOnce(paths, {
575
431
  };
576
432
  }
577
433
 
578
- async function appendAgentDecisions(paths, decisions = "") {
579
- const normalized = normalizeText(decisions);
580
- if (!normalized) {
581
- return;
582
- }
583
- const decisionsPath = path.join(paths.sessionRoot, "agent_decisions.md");
584
- const existing = await readTextIfExists(decisionsPath);
585
- await writeTextFile(
586
- decisionsPath,
587
- `${existing}${existing && !existing.endsWith("\n") ? "\n" : ""}${normalized}\n`
588
- );
589
- }
590
-
591
- async function appendAgentDecisionsInput(paths, options = {}) {
592
- const source = normalizeText(options.agentDecisions || options["agent-decisions"]);
593
- if (!source) {
594
- return;
595
- }
596
- await appendAgentDecisions(paths, extractAgentDecisions(source) || source);
597
- }
598
-
599
- async function recordIssueInAgentDecisions(paths, issueUrl = "") {
600
- const normalizedIssueUrl = normalizeText(issueUrl);
601
- if (!normalizedIssueUrl) {
602
- return;
603
- }
604
- const decisionsPath = path.join(paths.sessionRoot, "agent_decisions.md");
605
- const existing = await readTextIfExists(decisionsPath);
606
- if (existing.includes(`Issue: ${normalizedIssueUrl}`)) {
607
- return;
608
- }
609
- await writeTextFile(
610
- decisionsPath,
611
- `${existing}${existing && !existing.endsWith("\n") ? "\n" : ""}Issue: ${normalizedIssueUrl}\n\n`
612
- );
613
- }
614
-
615
434
  function issueMetadataFromUrl(issueUrl = "") {
616
- const match = /^https:\/\/github\.com\/([^/\s]+)\/([^/\s]+)\/issues\/(\d+)(?:\b|$)/u.exec(normalizeText(issueUrl));
435
+ const match = /^https:\/\/github\.com\/([^/]+)\/([^/]+)\/issues\/(\d+)(?:\b|$)/u.exec(String(issueUrl || "").trim());
617
436
  if (!match) {
618
437
  return {
619
438
  issueNumber: "",
439
+ issueUrl: normalizeText(issueUrl),
620
440
  owner: "",
621
441
  repository: ""
622
442
  };
623
443
  }
624
444
  return {
625
445
  issueNumber: match[3],
446
+ issueUrl: normalizeText(issueUrl),
626
447
  owner: match[1],
627
448
  repository: match[2]
628
449
  };
629
450
  }
630
451
 
631
- function nextCycleNumber(cycle = "001") {
632
- return String(Number.parseInt(String(cycle || "1"), 10) + 1).padStart(3, "0");
452
+ async function writeIssueMetadataFiles(paths, {
453
+ issueTitle = "",
454
+ issueUrl = ""
455
+ } = {}) {
456
+ const issueMetadata = issueMetadataFromUrl(issueUrl);
457
+ const metadataValues = {
458
+ issue_body_path: path.join(paths.sessionRoot, "issue.md"),
459
+ issue_number: issueMetadata.issueNumber,
460
+ issue_owner: issueMetadata.owner,
461
+ issue_repository: issueMetadata.repository,
462
+ issue_title: normalizeText(issueTitle),
463
+ issue_url: issueMetadata.issueUrl
464
+ };
465
+ await Promise.all(
466
+ Object.entries(metadataValues)
467
+ .filter(([, value]) => normalizeText(value))
468
+ .map(([name, value]) => writeTextFile(path.join(paths.sessionRoot, "metadata", name), value))
469
+ );
633
470
  }
634
471
 
635
472
  async function createSession({
@@ -667,11 +504,9 @@ async function createSession({
667
504
  await ensureStudioGitExclude(initialPaths.targetRoot);
668
505
  await mkdir(initialPaths.sessionRoot, { recursive: true });
669
506
  await writeTextFile(path.join(initialPaths.sessionRoot, "transcript.log"), "");
670
- await writeTextFile(path.join(initialPaths.sessionRoot, "agent_decisions.md"), `# Agent Decisions\n\nSession: ${initialPaths.sessionId}\nCreated: ${now.toISOString()}\n\n`);
671
507
  await writeTextFile(path.join(initialPaths.sessionRoot, "workflow_version"), `${SESSION_WORKFLOW_VERSION}\n`);
672
- await writeActiveCycle(initialPaths, "001");
673
508
  await markStatus(initialPaths, SESSION_STATUS.PENDING);
674
- await writeReceipt(initialPaths, "session_created", `Created JSKIT Studio issue session ${initialPaths.sessionId}.`);
509
+ await markCurrentStep(initialPaths, "worktree_created");
675
510
 
676
511
  return buildSessionResponse(initialPaths, {
677
512
  ok: true,
@@ -762,8 +597,7 @@ function emptySessionDetails(response) {
762
597
  ...response,
763
598
  issueTitle: "",
764
599
  issueText: "",
765
- planText: "",
766
- receipts: [],
600
+ stepRecords: [],
767
601
  transcriptLog: ""
768
602
  };
769
603
  }
@@ -778,12 +612,10 @@ async function inspectSessionDetails({
778
612
  }
779
613
  const { paths, preconditions } = context;
780
614
  const response = await buildSessionResponse(paths, { preconditions });
781
- const { planText } = await readCurrentPlan(paths);
782
-
783
- const [issueText, issueTitle, receipts, transcriptLog] = await Promise.all([
615
+ const [issueText, issueTitle, stepRecords, transcriptLog] = await Promise.all([
784
616
  readTextIfExists(path.join(paths.sessionRoot, "issue.md")),
785
617
  readTrimmedFile(path.join(paths.sessionRoot, "issue_title")),
786
- readReceiptSteps(paths),
618
+ readStepRecords(paths),
787
619
  readTextIfExists(path.join(paths.sessionRoot, "transcript.log"))
788
620
  ]);
789
621
 
@@ -791,8 +623,7 @@ async function inspectSessionDetails({
791
623
  ...response,
792
624
  issueTitle,
793
625
  issueText: issueText.trim(),
794
- planText: planText.trim(),
795
- receipts,
626
+ stepRecords,
796
627
  transcriptLog
797
628
  };
798
629
  }
@@ -825,6 +656,7 @@ async function removeEmptyStaleWorktreeDirectory(paths) {
825
656
 
826
657
  async function createWorktree(paths, _options = {}, context = {}) {
827
658
  const preconditions = context.preconditions || [];
659
+ const completeStep = context.completeStep !== false;
828
660
  const [baseBranchResult, baseCommitResult] = await Promise.all([
829
661
  runGit(paths.targetRoot, ["branch", "--show-current"], { timeout: 15000 }),
830
662
  runGit(paths.targetRoot, ["rev-parse", "--verify", "HEAD"], { timeout: 15000 })
@@ -838,7 +670,9 @@ async function createWorktree(paths, _options = {}, context = {}) {
838
670
  if (baseCommit && !await readTrimmedFile(path.join(paths.sessionRoot, "base_commit"))) {
839
671
  await writeTextFile(path.join(paths.sessionRoot, "base_commit"), `${baseCommit}\n`);
840
672
  }
841
- await writeReceipt(paths, "worktree_created", `Reused existing worktree ${paths.worktree}.`);
673
+ if (completeStep) {
674
+ await writeStepRecord(paths, "worktree_created", `Reused existing worktree ${paths.worktree}.`);
675
+ }
842
676
  await markStatus(paths, SESSION_STATUS.RUNNING);
843
677
  return buildSessionResponse(paths, {
844
678
  preconditions
@@ -873,7 +707,9 @@ async function createWorktree(paths, _options = {}, context = {}) {
873
707
  if (baseCommit) {
874
708
  await writeTextFile(path.join(paths.sessionRoot, "base_commit"), `${baseCommit}\n`);
875
709
  }
876
- await writeReceipt(paths, "worktree_created", `Created worktree ${paths.worktree} on branch ${paths.branch}.`);
710
+ if (completeStep) {
711
+ await writeStepRecord(paths, "worktree_created", `Created worktree ${paths.worktree} on branch ${paths.branch}.`);
712
+ }
877
713
  await markStatus(paths, SESSION_STATUS.RUNNING);
878
714
  return buildSessionResponse(paths, {
879
715
  preconditions
@@ -884,7 +720,21 @@ async function recordDependenciesInstalled(paths, {
884
720
  message = "Installed Node dependencies in the session worktree.",
885
721
  preconditions = []
886
722
  } = {}) {
887
- await writeReceipt(paths, "dependencies_installed", message);
723
+ await writeStepRecord(paths, "dependencies_installed", message);
724
+ await markStatus(paths, SESSION_STATUS.RUNNING);
725
+ return buildSessionResponse(paths, {
726
+ preconditions
727
+ });
728
+ }
729
+
730
+ async function recordDependencyInstallResult(paths, {
731
+ message = "Installed Node dependencies in the session worktree.",
732
+ preconditions = []
733
+ } = {}) {
734
+ await writeTextFile(
735
+ path.join(paths.sessionRoot, DEPENDENCIES_INSTALL_RESULT_FILE),
736
+ `${timestampForStepRecord()}\n${normalizeText(message) || "Installed Node dependencies in the session worktree."}`
737
+ );
888
738
  await markStatus(paths, SESSION_STATUS.RUNNING);
889
739
  return buildSessionResponse(paths, {
890
740
  preconditions
@@ -949,6 +799,7 @@ async function dependencyInstallCommandForWorktree(worktree) {
949
799
 
950
800
  async function installDependencies(paths, _options = {}, context = {}) {
951
801
  const preconditions = context.preconditions || [];
802
+ const completeStep = context.completeStep !== false;
952
803
  const [command, args] = await dependencyInstallCommandForWorktree(paths.worktree);
953
804
  const result = await runLoggedCommand(paths, "dependencies_install", command, args, {
954
805
  cwd: paths.worktree,
@@ -970,7 +821,8 @@ async function installDependencies(paths, _options = {}, context = {}) {
970
821
  return provisionResult.response;
971
822
  }
972
823
  const installMessage = result.output || `Installed Node dependencies in the session worktree with ${command} ${args.join(" ")}.`;
973
- return recordDependenciesInstalled(paths, {
824
+ const recorder = completeStep ? recordDependenciesInstalled : recordDependencyInstallResult;
825
+ return recorder(paths, {
974
826
  message: provisionResult.ran ? `${installMessage}\n${SESSION_PROVISION_PACKAGE_SCRIPT} completed.` : installMessage,
975
827
  preconditions
976
828
  });
@@ -1002,18 +854,39 @@ async function adoptDependenciesInstalled({
1002
854
  if (!provisionResult.ok) {
1003
855
  return provisionResult.response;
1004
856
  }
1005
- const receiptMessage = provisionResult.ran
857
+ const hookMessage = provisionResult.ran
1006
858
  ? `${normalizeText(message) || "Installed Node dependencies in the session worktree."}\n${SESSION_PROVISION_PACKAGE_SCRIPT} completed.`
1007
859
  : message;
1008
- return recordDependenciesInstalled(paths, {
1009
- message: receiptMessage,
860
+ return recordDependencyInstallResult(paths, {
861
+ message: hookMessage,
1010
862
  preconditions
1011
863
  });
1012
864
  });
1013
865
  }
1014
866
 
1015
- async function renderIssuePrompt(paths, options = {}) {
867
+ const STUDIO_CONTEXT_START_MARKER = "[[JSKIT_STUDIO_CONTEXT_START]]";
868
+ const STUDIO_CONTEXT_END_MARKER = "[[JSKIT_STUDIO_CONTEXT_END]]";
869
+
870
+ function issueDefinitionPrompt(userInput, context) {
871
+ return [
872
+ userInput,
873
+ "",
874
+ STUDIO_CONTEXT_START_MARKER,
875
+ "JSKIT Studio context marker: follow the instructions inside this context block normally, but ignore the surrounding JSKIT_STUDIO_CONTEXT markers.",
876
+ "",
877
+ context,
878
+ STUDIO_CONTEXT_END_MARKER
879
+ ].join("\n").trim();
880
+ }
881
+
882
+ async function renderIssuePrompt(paths, options = {}, context = {}) {
1016
883
  const userInput = normalizeText(options.prompt);
884
+ const issueDefinitionSentinelPath = path.join(paths.sessionRoot, "metadata", "issue_prompt_rendered_requested");
885
+ if (!userInput && context.completeStep !== false && await fileExists(issueDefinitionSentinelPath)) {
886
+ await writeStepRecord(paths, "issue_prompt_rendered", "Issue scoped in Codex terminal.");
887
+ await markStatus(paths, SESSION_STATUS.RUNNING);
888
+ return buildSessionResponse(paths);
889
+ }
1017
890
  if (!userInput) {
1018
891
  return failSession(paths, {
1019
892
  code: "prompt_required",
@@ -1021,43 +894,18 @@ async function renderIssuePrompt(paths, options = {}) {
1021
894
  repairCommand: `jskit session ${paths.sessionId} step --prompt "<what should change>"`
1022
895
  });
1023
896
  }
1024
- const prompt = await renderPrompt(paths, "new_issue.md", {
1025
- user_input: userInput
1026
- });
1027
- await writePromptArtifact(paths, "issue_draft.md", prompt);
1028
- await writeReceipt(paths, "issue_prompt_rendered", "Rendered the issue drafting prompt.");
897
+ const promptContext = await renderPrompt(paths, "issue_prompt_rendered.md");
898
+ const prompt = issueDefinitionPrompt(userInput, promptContext);
899
+ await writeTextFile(issueDefinitionSentinelPath, "true\n");
1029
900
  await markStatus(paths, SESSION_STATUS.WAITING_FOR_USER);
1030
901
  return buildSessionResponse(paths, {
902
+ codex: ISSUE_DEFINITION_CODEX_HANDOFF,
1031
903
  ok: true,
1032
904
  prompt,
1033
905
  status: SESSION_STATUS.WAITING_FOR_USER
1034
906
  });
1035
907
  }
1036
908
 
1037
- async function draftIssue(paths, options = {}) {
1038
- const issueText = extractIssueText(options.issue);
1039
- if (!issueText) {
1040
- return failSession(paths, {
1041
- code: "issue_required",
1042
- message: "The issue drafting step requires --issue, --issue-file, or --issue -.",
1043
- repairCommand: `jskit session ${paths.sessionId} step --issue -`
1044
- });
1045
- }
1046
- const issueTitle = normalizeText(options.issueTitle) || extractIssueTitle(options.issue);
1047
- if (!issueTitle) {
1048
- return failSession(paths, {
1049
- code: "issue_title_required",
1050
- message: "The issue drafting step requires an approved issue title.",
1051
- repairCommand: `jskit session ${paths.sessionId} step --issue-title "<title>" --issue -`
1052
- });
1053
- }
1054
- await writeTextFile(path.join(paths.sessionRoot, "issue.md"), issueText);
1055
- await writeTextFile(path.join(paths.sessionRoot, "issue_title"), issueTitle);
1056
- await writeReceipt(paths, "issue_drafted", "Saved approved issue text.");
1057
- await markStatus(paths, SESSION_STATUS.RUNNING);
1058
- return buildSessionResponse(paths);
1059
- }
1060
-
1061
909
  function titleFromIssue(issueText) {
1062
910
  const firstMeaningfulLine = String(issueText || "")
1063
911
  .split(/\r?\n/u)
@@ -1068,24 +916,56 @@ function titleFromIssue(issueText) {
1068
916
 
1069
917
  async function createIssue(paths, _options = {}, context = {}) {
1070
918
  const preconditions = context.preconditions || [];
919
+ const issueText = await readTrimmedFile(path.join(paths.sessionRoot, "issue.md"));
920
+ if (!issueText) {
921
+ return renderIssueFilePrompt(paths, context);
922
+ }
923
+ if (context.completeStep === false) {
924
+ return renderIssueFilePrompt(paths, context);
925
+ }
926
+ await writeStepRecord(paths, "issue_created", "Issue files are ready for review and submission.");
927
+ await markStatus(paths, SESSION_STATUS.RUNNING);
928
+ return buildSessionResponse(paths, {
929
+ preconditions
930
+ });
931
+ }
932
+
933
+ async function submitIssue(paths, _options = {}, context = {}) {
934
+ const preconditions = context.preconditions || [];
935
+ const completeStep = context.completeStep !== false;
1071
936
  const existingIssueUrl = await readTrimmedFile(path.join(paths.sessionRoot, "issue_url"));
1072
937
  const issueText = await readTrimmedFile(path.join(paths.sessionRoot, "issue.md"));
1073
938
  const issueTitle = await readTrimmedFile(path.join(paths.sessionRoot, "issue_title")) || titleFromIssue(issueText);
939
+ if (!issueText) {
940
+ return sessionStepError(paths, {
941
+ code: "issue_file_missing",
942
+ message: "Cannot create the GitHub issue until issue.md exists.",
943
+ repairCommand: `jskit session ${paths.sessionId} create_issue_file`
944
+ });
945
+ }
1074
946
  if (existingIssueUrl) {
1075
- await writeIssueMetadata(paths, {
1076
- ...issueMetadataFromUrl(existingIssueUrl),
1077
- issueBody: issueText,
1078
- issueBodyPath: path.join(paths.sessionRoot, "issue.md"),
947
+ await writeIssueMetadataFiles(paths, {
1079
948
  issueTitle,
1080
949
  issueUrl: existingIssueUrl
1081
950
  });
1082
- await recordIssueInAgentDecisions(paths, existingIssueUrl);
1083
- await writeReceipt(paths, "issue_created", `Reused GitHub issue ${existingIssueUrl}.`);
951
+ if (completeStep) {
952
+ await writeStepRecord(paths, "issue_submitted", `Reused GitHub issue ${existingIssueUrl}.`);
953
+ }
1084
954
  await markStatus(paths, SESSION_STATUS.RUNNING);
1085
955
  return buildSessionResponse(paths, {
1086
956
  preconditions
1087
957
  });
1088
958
  }
959
+ const githubPreconditions = await runNamedPreconditions(paths, ["github_auth", "github_origin"]);
960
+ if (!githubPreconditions.ok) {
961
+ return failSession(paths, {
962
+ ...githubPreconditions.error,
963
+ preconditions: [
964
+ ...preconditions,
965
+ ...githubPreconditions.preconditions
966
+ ]
967
+ });
968
+ }
1089
969
  const result = await runLoggedCommand(paths, "github_issue_create", "gh", [
1090
970
  "issue",
1091
971
  "create",
@@ -1107,40 +987,30 @@ async function createIssue(paths, _options = {}, context = {}) {
1107
987
  }
1108
988
  const issueUrl = result.stdout.split(/\r?\n/u).map((line) => line.trim()).find(Boolean) || result.stdout;
1109
989
  await writeTextFile(path.join(paths.sessionRoot, "issue_url"), issueUrl);
1110
- await writeIssueMetadata(paths, {
1111
- ...issueMetadataFromUrl(issueUrl),
1112
- issueBody: issueText,
1113
- issueBodyPath: path.join(paths.sessionRoot, "issue.md"),
990
+ await writeIssueMetadataFiles(paths, {
1114
991
  issueTitle,
1115
992
  issueUrl
1116
993
  });
1117
- await recordIssueInAgentDecisions(paths, issueUrl);
1118
- await writeReceipt(paths, "issue_created", `Created GitHub issue ${issueUrl}.`);
994
+ if (completeStep) {
995
+ await writeStepRecord(paths, "issue_submitted", `Created GitHub issue ${issueUrl}.`);
996
+ }
1119
997
  await markStatus(paths, SESSION_STATUS.RUNNING);
1120
998
  return buildSessionResponse(paths, {
1121
999
  preconditions
1122
1000
  });
1123
1001
  }
1124
1002
 
1125
- async function renderIssueDetailsPrompt(paths, _options = {}, context = {}) {
1003
+ async function renderIssueFilePrompt(paths, context = {}) {
1126
1004
  const preconditions = context.preconditions || [];
1127
- const issueText = await readTrimmedFile(path.join(paths.sessionRoot, "issue.md"));
1128
- const issueTitle = await readTrimmedFile(path.join(paths.sessionRoot, "issue_title")) || titleFromIssue(issueText);
1129
- const issueUrl = await readTrimmedFile(path.join(paths.sessionRoot, "issue_url"));
1130
- const issueNumber = issueNumberFromUrl(issueUrl);
1131
- const prompt = await renderPrompt(paths, "issue_details.md", {
1005
+ const issueFileSentinelPath = path.join(paths.sessionRoot, "metadata", "issue_created_requested");
1006
+ const prompt = await renderPrompt(paths, "issue_created.md", {
1132
1007
  issue_file: path.join(paths.sessionRoot, "issue.md"),
1133
- issue_number: issueNumber,
1134
- issue_text: issueText,
1135
- issue_title: issueTitle,
1136
- issue_url: issueUrl,
1137
- issue_details_file: path.join(paths.sessionRoot, "issue_details.md"),
1138
- worktree: paths.worktree
1008
+ issue_title_file: path.join(paths.sessionRoot, "issue_title")
1139
1009
  });
1140
- await writePromptArtifact(paths, "issue_details.md", prompt);
1010
+ await writeTextFile(issueFileSentinelPath, "true\n");
1141
1011
  await markStatus(paths, SESSION_STATUS.WAITING_FOR_USER);
1142
1012
  return buildSessionResponse(paths, {
1143
- codex: ISSUE_DETAILS_CODEX_HANDOFF,
1013
+ codex: ISSUE_FILE_CODEX_HANDOFF,
1144
1014
  ok: true,
1145
1015
  preconditions,
1146
1016
  prompt,
@@ -1148,145 +1018,47 @@ async function renderIssueDetailsPrompt(paths, _options = {}, context = {}) {
1148
1018
  });
1149
1019
  }
1150
1020
 
1151
- async function saveIssueDetails(paths, options = {}, context = {}) {
1152
- const preconditions = context.preconditions || [];
1153
- const source = normalizeText(options.issueDetails || options["issue-details"]);
1154
- const structuredIssueCategory = normalizeText(options.issueCategory || options["issue-category"]);
1155
- const structuredUiImpact = normalizeText(options.uiImpact || options["ui-impact"]);
1156
- const issueDetails = extractIssueDetails(source) || (
1157
- structuredIssueCategory && structuredUiImpact ? source : ""
1158
- );
1159
- if (!source && !structuredIssueCategory && !structuredUiImpact) {
1160
- return renderIssueDetailsPrompt(paths, options, context);
1161
- }
1162
- if (!issueDetails) {
1163
- return failSession(paths, {
1164
- code: "issue_details_required",
1165
- message: "The details step requires confirmed issue details from Codex or the Studio form.",
1166
- repairCommand: `jskit session ${paths.sessionId} step --issue-details -`,
1167
- preconditions
1168
- });
1169
- }
1170
-
1171
- const issueCategory = normalizeIssueCategory(structuredIssueCategory || extractIssueCategory(source));
1172
- const uiImpact = normalizeUiImpact(structuredUiImpact || extractUiImpact(source));
1173
- if (!issueCategory) {
1174
- return failSession(paths, {
1175
- code: "issue_category_invalid",
1176
- message: "Issue details must include [issue_category]client, server, client_server, tooling, or unknown[/issue_category].",
1177
- repairCommand: `jskit session ${paths.sessionId} step --issue-details -`,
1178
- preconditions
1179
- });
1180
- }
1181
- if (!uiImpact) {
1182
- return failSession(paths, {
1183
- code: "ui_impact_invalid",
1184
- message: "Issue details must include [ui_impact]none, possible, definite, or unknown[/ui_impact].",
1185
- repairCommand: `jskit session ${paths.sessionId} step --issue-details -`,
1186
- preconditions
1187
- });
1188
- }
1189
-
1190
- await writeTextFile(path.join(paths.sessionRoot, "issue_details.md"), issueDetails);
1191
- await writeIssueMetadata(paths, {
1192
- issueCategory,
1193
- uiImpact,
1194
- issueDetailsPath: path.join(paths.sessionRoot, "issue_details.md")
1195
- });
1196
-
1197
- const decisions = extractAgentDecisions(source);
1198
- await appendAgentDecisions(paths, decisions);
1199
-
1200
- const issueUrl = await readTrimmedFile(path.join(paths.sessionRoot, "issue_url"));
1201
- if (issueUrl) {
1202
- const commentResult = await commentOnIssueOnce(paths, {
1203
- bodyFile: path.join(paths.sessionRoot, "issue_details.md"),
1204
- issueUrl,
1205
- purpose: "issue_details"
1206
- });
1207
- if (!commentResult.ok) {
1208
- return failSession(paths, {
1209
- code: "issue_details_comment_failed",
1210
- message: commentResult.output || "Failed to comment the issue details on the GitHub issue.",
1211
- repairCommand: `gh issue comment ${issueUrl} --body-file ${path.join(paths.sessionRoot, "issue_details.md")}`,
1212
- preconditions
1213
- });
1214
- }
1215
- }
1216
-
1217
- await writeReceipt(paths, "issue_details_gathered", "Saved confirmed issue details and recorded the GitHub issue comment.");
1218
- await markStatus(paths, SESSION_STATUS.RUNNING);
1219
- return buildSessionResponse(paths, {
1220
- preconditions
1221
- });
1222
- }
1223
-
1224
- async function makePlan(paths, options = {}, context = {}) {
1021
+ async function makePlan(paths, _options = {}, context = {}) {
1225
1022
  const preconditions = context.preconditions || [];
1226
- const activeCycle = await readActiveCycle(paths);
1023
+ const makePlanSentinelPath = path.join(paths.sessionRoot, "metadata", "make_plan_requested");
1227
1024
  const issueText = await readTrimmedFile(path.join(paths.sessionRoot, "issue.md"));
1228
1025
  const issueTitle = await readTrimmedFile(path.join(paths.sessionRoot, "issue_title")) || titleFromIssue(issueText);
1229
1026
  const issueUrl = await readTrimmedFile(path.join(paths.sessionRoot, "issue_url"));
1230
1027
  const issueNumber = issueNumberFromUrl(issueUrl);
1231
- const issueDetails = await readTrimmedFile(path.join(paths.sessionRoot, "issue_details.md"));
1232
- const planText = extractPlanText(options.plan);
1233
- const agentDecisionsPath = path.join(paths.sessionRoot, "agent_decisions.md");
1234
- const agentDecisionsText = await readTextIfExists(agentDecisionsPath);
1235
- const currentCycleRoot = cycleRootPath(paths, activeCycle);
1236
- const planPath = cyclePlanPath(paths, activeCycle);
1237
- const reworkRequestPath = path.join(currentCycleRoot, "rework_request.md");
1238
- const reworkRequest = await readTextIfExists(reworkRequestPath);
1239
-
1240
- if (!planText) {
1241
- const prompt = await renderPrompt(paths, "plan_issue.md", {
1242
- active_cycle: activeCycle,
1243
- agent_decisions_file: agentDecisionsPath,
1244
- agent_decisions_text: agentDecisionsText,
1245
- app_blueprint_file: path.join(paths.worktree, ".jskit", "APP_BLUEPRINT.md"),
1246
- issue_file: path.join(paths.sessionRoot, "issue.md"),
1247
- issue_number: issueNumber,
1248
- issue_text: issueText,
1249
- issue_title: issueTitle,
1250
- issue_title_file: path.join(paths.sessionRoot, "issue_title"),
1251
- issue_url: issueUrl,
1252
- issue_details_file: path.join(paths.sessionRoot, "issue_details.md"),
1253
- issue_details_text: issueDetails,
1254
- plan_file: planPath,
1255
- plan_source: activeCycle === "001" ? "issue" : "rework",
1256
- rework_request: reworkRequest,
1257
- rework_request_file: reworkRequest ? reworkRequestPath : "",
1258
- worktree: paths.worktree
1259
- });
1260
- await writePromptArtifact(paths, cyclePlanPromptFileName(activeCycle), prompt);
1261
- await markStatus(paths, SESSION_STATUS.WAITING_FOR_USER);
1028
+ if (context.completeStep !== false && await fileExists(makePlanSentinelPath)) {
1029
+ await writeStepRecord(paths, "plan_made", "Plan reviewed in Codex terminal.");
1030
+ await markStatus(paths, SESSION_STATUS.RUNNING);
1262
1031
  return buildSessionResponse(paths, {
1263
- ok: true,
1264
- preconditions,
1265
- prompt,
1266
- status: SESSION_STATUS.WAITING_FOR_USER
1032
+ preconditions
1267
1033
  });
1268
1034
  }
1269
1035
 
1270
- await mkdir(currentCycleRoot, { recursive: true });
1271
- await writeTextFile(planPath, planText);
1272
- await appendAgentDecisions(paths, extractAgentDecisions(options.plan));
1273
- await writeReceipt(paths, "plan_made", `Saved cycle ${activeCycle} plan.`);
1274
- await markStatus(paths, SESSION_STATUS.RUNNING);
1036
+ const prompt = await renderPrompt(paths, "make_plan.md", {
1037
+ app_blueprint_file: path.join(paths.worktree, ".jskit", "APP_BLUEPRINT.md"),
1038
+ issue_file: path.join(paths.sessionRoot, "issue.md"),
1039
+ issue_number: issueNumber,
1040
+ issue_text: issueText,
1041
+ issue_title: issueTitle,
1042
+ issue_title_file: path.join(paths.sessionRoot, "issue_title"),
1043
+ issue_url: issueUrl,
1044
+ worktree: paths.worktree
1045
+ });
1046
+ await writeTextFile(makePlanSentinelPath, "true\n");
1047
+ await markStatus(paths, SESSION_STATUS.WAITING_FOR_USER);
1275
1048
  return buildSessionResponse(paths, {
1276
- preconditions
1049
+ codex: PLAN_CODEX_HANDOFF,
1050
+ ok: true,
1051
+ preconditions,
1052
+ prompt,
1053
+ status: SESSION_STATUS.WAITING_FOR_USER
1277
1054
  });
1278
1055
  }
1279
1056
 
1280
- async function renderPlanExecutionPrompt(paths, options = {}, context = {}) {
1057
+ async function renderPlanExecutionPrompt(paths, _options = {}, context = {}) {
1281
1058
  const preconditions = context.preconditions || [];
1282
- const activeCycle = await readActiveCycle(paths);
1283
- const executionPromptPath = path.join(paths.sessionRoot, "prompts", cyclePlanExecutionPromptFileName(activeCycle));
1284
- if (await fileExists(executionPromptPath)) {
1285
- const codexResultFailure = await requireCodexStepResult(paths, "plan_executed", options.codexResult, preconditions);
1286
- if (codexResultFailure) {
1287
- return codexResultFailure;
1288
- }
1289
- await writeReceipt(paths, "plan_executed", `Cycle ${activeCycle} plan execution completed by Codex.`);
1059
+ const executePlanSentinelPath = path.join(paths.sessionRoot, "metadata", "execute_plan_requested");
1060
+ if (context.completeStep !== false && await fileExists(executePlanSentinelPath)) {
1061
+ await writeStepRecord(paths, "plan_executed", "Plan execution completed by Codex.");
1290
1062
  await markStatus(paths, SESSION_STATUS.RUNNING);
1291
1063
  return buildSessionResponse(paths, {
1292
1064
  preconditions
@@ -1297,22 +1069,14 @@ async function renderPlanExecutionPrompt(paths, options = {}, context = {}) {
1297
1069
  const issueTitle = await readTrimmedFile(path.join(paths.sessionRoot, "issue_title")) || titleFromIssue(issueText);
1298
1070
  const issueUrl = await readTrimmedFile(path.join(paths.sessionRoot, "issue_url"));
1299
1071
  const issueNumber = issueNumberFromUrl(issueUrl);
1300
- const { planPath, planText } = await readCurrentPlan(paths);
1301
- const issueDetailsPath = path.join(paths.sessionRoot, "issue_details.md");
1302
- const issueDetails = await readTrimmedFile(issueDetailsPath);
1303
- const executionPrompt = await renderPrompt(paths, "execute_plan.md", {
1304
- active_cycle: activeCycle,
1072
+ const executionPrompt = await renderPrompt(paths, "plan_executed.md", {
1305
1073
  issue_file: path.join(paths.sessionRoot, "issue.md"),
1306
1074
  issue_number: issueNumber,
1307
1075
  issue_title: issueTitle,
1308
1076
  issue_url: issueUrl,
1309
- issue_details_file: issueDetailsPath,
1310
- issue_details_text: issueDetails,
1311
- plan_file: planPath,
1312
- plan_text: planText,
1313
1077
  worktree: paths.worktree
1314
1078
  });
1315
- await writePromptArtifact(paths, cyclePlanExecutionPromptFileName(activeCycle), executionPrompt);
1079
+ await writeTextFile(executePlanSentinelPath, "true\n");
1316
1080
  await markStatus(paths, SESSION_STATUS.WAITING_FOR_USER);
1317
1081
  return buildSessionResponse(paths, {
1318
1082
  codex: PLAN_EXECUTION_CODEX_HANDOFF,
@@ -1443,7 +1207,6 @@ async function inspectSessionDiff({
1443
1207
  }
1444
1208
 
1445
1209
  const FIRST_REWINDABLE_STEP_ID = "dependencies_installed";
1446
- const CYCLE_REWIND_TARGET_STEP_ID = "plan_made";
1447
1210
  const REWIND_CLOSED_STATUSES = Object.freeze([
1448
1211
  SESSION_STATUS.ABANDONED,
1449
1212
  SESSION_STATUS.FINISHED
@@ -1465,7 +1228,29 @@ async function removePromptArtifact(paths, fileName) {
1465
1228
  }
1466
1229
 
1467
1230
  async function removeGlobalCodexResult(paths, stepId) {
1468
- await removeSessionPath(paths, "codex_results", `${stepId}.md`);
1231
+ await removeSessionPath(paths, "codex_results", stepId);
1232
+ }
1233
+
1234
+ async function removeCycleCodexResults(paths, stepId) {
1235
+ const cyclesRoot = path.join(paths.sessionRoot, "cycles");
1236
+ let cycleDirectories = [];
1237
+ try {
1238
+ cycleDirectories = (await readdir(cyclesRoot, { withFileTypes: true }))
1239
+ .filter((entry) => entry.isDirectory() && /^cycle_\d+$/u.test(entry.name))
1240
+ .map((entry) => entry.name);
1241
+ } catch {
1242
+ cycleDirectories = [];
1243
+ }
1244
+ await Promise.all(cycleDirectories.map((cycleDirectory) => {
1245
+ return removeSessionPath(paths, "cycles", cycleDirectory, "codex_results", stepId);
1246
+ }));
1247
+ }
1248
+
1249
+ async function removeCodexResult(paths, stepId) {
1250
+ await Promise.all([
1251
+ removeGlobalCodexResult(paths, stepId),
1252
+ removeCycleCodexResults(paths, stepId)
1253
+ ]);
1469
1254
  }
1470
1255
 
1471
1256
  async function removeGithubCommentPurpose(paths, purpose) {
@@ -1481,21 +1266,6 @@ async function removeGithubCommentPurpose(paths, purpose) {
1481
1266
  await writeGithubComments(paths, comments);
1482
1267
  }
1483
1268
 
1484
- async function removeIssueDetailsMetadata(paths) {
1485
- const metadata = await readIssueMetadata(paths);
1486
- if (Object.keys(metadata).length === 0) {
1487
- return;
1488
- }
1489
- delete metadata.issueCategory;
1490
- delete metadata.issueDetailsPath;
1491
- delete metadata.uiImpact;
1492
- if (Object.keys(metadata).length === 0) {
1493
- await removeSessionRootFile(paths, "issue_metadata.json");
1494
- return;
1495
- }
1496
- await writeTextFile(path.join(paths.sessionRoot, "issue_metadata.json"), `${JSON.stringify(metadata, null, 2)}\n`);
1497
- }
1498
-
1499
1269
  async function removeCycleDirectories(paths) {
1500
1270
  for (const rootName of ["steps", "cycles"]) {
1501
1271
  const root = path.join(paths.sessionRoot, rootName);
@@ -1514,99 +1284,102 @@ async function removeCycleDirectories(paths) {
1514
1284
  }
1515
1285
  }
1516
1286
 
1517
- async function removeCyclePromptArtifacts(paths) {
1518
- const promptsRoot = path.join(paths.sessionRoot, "prompts");
1519
- let entries = [];
1520
- try {
1521
- entries = await readdir(promptsRoot, { withFileTypes: true });
1522
- } catch {
1523
- entries = [];
1524
- }
1525
- const cyclePromptPattern = /^cycle_\d+_(?:plan_request|plan_execution)\.md$/u;
1526
- const cyclePromptFiles = entries
1527
- .filter((entry) => entry.isFile() && cyclePromptPattern.test(entry.name))
1528
- .map((entry) => entry.name);
1529
- await Promise.all([
1530
- ...cyclePromptFiles.map((fileName) => removePromptArtifact(paths, fileName)),
1531
- removePromptArtifact(paths, "automated_checks_run.md"),
1532
- removePromptArtifact(paths, "deep_ui_check_run.md"),
1533
- removePromptArtifact(paths, "review.md"),
1534
- removePromptArtifact(paths, "user_check.md")
1535
- ]);
1287
+ async function removePlanArtifacts(paths) {
1288
+ await removeSessionPath(paths, "metadata", "make_plan_requested");
1536
1289
  }
1537
1290
 
1538
- async function cancelAllCycleState(paths) {
1291
+ async function removePlanExecutionArtifacts(paths) {
1539
1292
  await Promise.all([
1540
- removeCycleDirectories(paths),
1541
- removeCyclePromptArtifacts(paths),
1542
- removeSessionPath(paths, "checks"),
1543
- removeSessionPath(paths, "ui_checks"),
1544
- removeSessionPath(paths, "review_passes")
1293
+ removeSessionPath(paths, "metadata", "execute_plan_requested"),
1294
+ removeCodexResult(paths, "plan_executed")
1545
1295
  ]);
1546
- await writeActiveCycle(paths, "001");
1547
1296
  }
1548
1297
 
1549
1298
  const STEP_CANCELERS = Object.freeze({
1550
1299
  dependencies_installed: async () => {},
1551
1300
  issue_prompt_rendered: async (paths) => {
1552
- await removePromptArtifact(paths, "issue_draft.md");
1301
+ await removeSessionPath(paths, "metadata", "issue_prompt_rendered_requested");
1553
1302
  },
1554
- issue_drafted: async (paths) => {
1303
+ issue_created: async (paths) => {
1555
1304
  await Promise.all([
1305
+ removeSessionPath(paths, "metadata", "issue_created_requested"),
1556
1306
  removeSessionRootFile(paths, "issue.md"),
1557
1307
  removeSessionRootFile(paths, "issue_title")
1558
1308
  ]);
1559
1309
  },
1560
- issue_created: async (paths) => {
1310
+ issue_submitted: async (paths) => {
1561
1311
  await Promise.all([
1562
1312
  removeSessionRootFile(paths, "issue_url"),
1563
- removeSessionRootFile(paths, "issue_metadata.json")
1313
+ removeSessionPath(paths, "metadata", "issue_body_path"),
1314
+ removeSessionPath(paths, "metadata", "issue_details_path"),
1315
+ removeSessionPath(paths, "metadata", "issue_number"),
1316
+ removeSessionPath(paths, "metadata", "issue_owner"),
1317
+ removeSessionPath(paths, "metadata", "issue_repository"),
1318
+ removeSessionPath(paths, "metadata", "issue_title"),
1319
+ removeSessionPath(paths, "metadata", "issue_url")
1320
+ ]);
1321
+ },
1322
+ plan_made: removePlanArtifacts,
1323
+ plan_executed: removePlanExecutionArtifacts,
1324
+ deep_ui_check_run: async (paths) => {
1325
+ await Promise.all([
1326
+ removeSessionPath(paths, "ui_checks"),
1327
+ removeCodexResult(paths, "deep_ui_check_run")
1328
+ ]);
1329
+ },
1330
+ review_prompt_rendered: async (paths) => {
1331
+ await Promise.all([
1332
+ removePromptArtifact(paths, "review_prompt_rendered"),
1333
+ removeSessionPath(paths, "review_passes"),
1334
+ removeCodexResult(paths, "review_prompt_rendered")
1335
+ ]);
1336
+ },
1337
+ review_changes_accepted: async (paths) => {
1338
+ await removeSessionPath(paths, "review_passes");
1339
+ },
1340
+ automated_checks_run: async (paths) => {
1341
+ await Promise.all([
1342
+ removeSessionPath(paths, "checks"),
1343
+ removeCodexResult(paths, "automated_checks_run")
1564
1344
  ]);
1565
1345
  },
1566
- issue_details_gathered: async (paths) => {
1346
+ user_check_completed: async (paths) => {
1567
1347
  await Promise.all([
1568
- removePromptArtifact(paths, "issue_details.md"),
1569
- removeSessionRootFile(paths, "issue_details.md"),
1570
- removeGithubCommentPurpose(paths, "issue_details"),
1571
- removeIssueDetailsMetadata(paths)
1348
+ removePromptArtifact(paths, "user_check_completed"),
1349
+ removeSessionPath(paths, "steps", "user_check_failed")
1572
1350
  ]);
1573
1351
  },
1574
- plan_made: cancelAllCycleState,
1575
- plan_executed: cancelAllCycleState,
1576
- deep_ui_check_run: cancelAllCycleState,
1577
- review_prompt_rendered: cancelAllCycleState,
1578
- review_changes_accepted: cancelAllCycleState,
1579
- automated_checks_run: cancelAllCycleState,
1580
- user_check_completed: cancelAllCycleState,
1581
1352
  changes_committed: async (paths) => {
1582
1353
  await removeSessionRootFile(paths, "changes_committed.json");
1583
1354
  },
1584
1355
  blueprint_updated: async (paths) => {
1585
1356
  await Promise.all([
1586
- removePromptArtifact(paths, "update_blueprint.md"),
1357
+ removeSessionPath(paths, "metadata", "blueprint_updated_requested"),
1587
1358
  removeSessionRootFile(paths, BLUEPRINT_BASELINE_FILE),
1588
- removeGlobalCodexResult(paths, "blueprint_updated")
1359
+ removeCodexResult(paths, "blueprint_updated")
1589
1360
  ]);
1590
1361
  },
1591
1362
  final_report_created: async (paths) => {
1592
1363
  await Promise.all([
1364
+ removeSessionPath(paths, "metadata", "pull_request_file_requested"),
1365
+ removeSessionRootFile(paths, "pull_request.md"),
1366
+ removeSessionRootFile(paths, "final_report"),
1593
1367
  removeSessionRootFile(paths, "final_report.md"),
1594
1368
  removeGithubCommentPurpose(paths, "final_report")
1595
1369
  ]);
1596
1370
  },
1597
1371
  pr_created: async (paths) => {
1598
1372
  await Promise.all([
1599
- removePromptArtifact(paths, "pr_create_failure.md"),
1373
+ removePromptArtifact(paths, "pr_create_failure"),
1600
1374
  removeSessionRootFile(paths, "pr_body.md"),
1375
+ removeSessionRootFile(paths, "pull_request_body.md"),
1601
1376
  removeSessionRootFile(paths, "pr_url")
1602
1377
  ]);
1603
1378
  },
1604
1379
  pr_merge_prepared: async (paths) => {
1605
- await removePromptArtifact(paths, "prepare_pr_merge.md");
1606
- },
1607
- pr_finalized: async (paths) => {
1608
1380
  await Promise.all([
1609
- removePromptArtifact(paths, "pr_merge_failure.md"),
1381
+ removePromptArtifact(paths, "pr_merge_prepared"),
1382
+ removePromptArtifact(paths, "pr_merge_failure"),
1610
1383
  removeSessionRootFile(paths, "pr_base_branch"),
1611
1384
  removeSessionRootFile(paths, "pr_merge_completed"),
1612
1385
  removeSessionRootFile(paths, "pr_outcome.json")
@@ -1620,27 +1393,18 @@ const STEP_CANCELERS = Object.freeze({
1620
1393
  },
1621
1394
  session_finished: async (paths) => {
1622
1395
  await Promise.all([
1623
- removeSessionRootFile(paths, "final_comment.md")
1396
+ removeSessionRootFile(paths, "final_comment")
1624
1397
  ]);
1625
1398
  }
1626
1399
  });
1627
1400
 
1628
- function targetRequiresCycleReset(stepId) {
1629
- const targetIndex = STEP_IDS.indexOf(stepId);
1630
- const planIndex = STEP_IDS.indexOf(CYCLE_REWIND_TARGET_STEP_ID);
1631
- return targetIndex >= 0 && targetIndex <= planIndex;
1632
- }
1633
-
1634
1401
  function targetIsAllowedRewindStep(stepId) {
1635
1402
  if (!STEP_IDS.includes(stepId)) {
1636
1403
  return false;
1637
1404
  }
1638
- if (stepId === "session_created" || stepId === "worktree_created") {
1405
+ if (stepId === "worktree_created") {
1639
1406
  return false;
1640
1407
  }
1641
- if (CYCLE_STEP_IDS.includes(stepId)) {
1642
- return stepId === CYCLE_REWIND_TARGET_STEP_ID;
1643
- }
1644
1408
  return STEP_IDS.indexOf(stepId) >= STEP_IDS.indexOf(FIRST_REWINDABLE_STEP_ID);
1645
1409
  }
1646
1410
 
@@ -1649,30 +1413,28 @@ function deletedStepIdsForRewindTarget(stepId) {
1649
1413
  return targetIndex < 0 ? [] : STEP_IDS.slice(targetIndex);
1650
1414
  }
1651
1415
 
1652
- async function removeReceiptsForDeletedSteps(paths, deletedStepIds) {
1653
- await Promise.all(deletedStepIds
1654
- .filter((stepId) => !CYCLE_STEP_IDS.includes(stepId))
1655
- .map((stepId) => removeSessionPath(paths, "steps", stepId)));
1416
+ async function removeStepRecordsForDeletedSteps(paths, deletedStepIds) {
1417
+ const stepsRoot = path.join(paths.sessionRoot, "steps");
1418
+ let cycleDirectories = [];
1419
+ try {
1420
+ cycleDirectories = (await readdir(stepsRoot, { withFileTypes: true }))
1421
+ .filter((entry) => entry.isDirectory() && /^cycle_\d+$/u.test(entry.name))
1422
+ .map((entry) => entry.name);
1423
+ } catch {
1424
+ cycleDirectories = [];
1425
+ }
1426
+ await Promise.all(deletedStepIds.flatMap((stepId) => [
1427
+ removeSessionPath(paths, "steps", stepId),
1428
+ ...cycleDirectories.map((cycleDirectory) => removeSessionPath(paths, "steps", cycleDirectory, stepId))
1429
+ ]));
1656
1430
  }
1657
1431
 
1658
- async function cancelDeletedStepArtifacts(paths, deletedStepIds, {
1659
- cycleReset = false
1660
- } = {}) {
1661
- const cancelerIds = cycleReset
1662
- ? deletedStepIds.filter((stepId) => !CYCLE_STEP_IDS.includes(stepId)).concat(CYCLE_REWIND_TARGET_STEP_ID)
1663
- : deletedStepIds;
1664
- const calledCycleCancel = new Set();
1665
- for (const stepId of cancelerIds) {
1432
+ async function cancelDeletedStepArtifacts(paths, deletedStepIds) {
1433
+ for (const stepId of deletedStepIds) {
1666
1434
  const canceler = STEP_CANCELERS[stepId];
1667
1435
  if (typeof canceler !== "function") {
1668
1436
  continue;
1669
1437
  }
1670
- if (CYCLE_STEP_IDS.includes(stepId)) {
1671
- if (calledCycleCancel.has(CYCLE_REWIND_TARGET_STEP_ID)) {
1672
- continue;
1673
- }
1674
- calledCycleCancel.add(CYCLE_REWIND_TARGET_STEP_ID);
1675
- }
1676
1438
  await canceler(paths);
1677
1439
  }
1678
1440
  }
@@ -1727,15 +1489,12 @@ async function rewindSession({
1727
1489
  }
1728
1490
 
1729
1491
  if (!targetIsAllowedRewindStep(normalizedStepId)) {
1730
- const cycleHint = CYCLE_STEP_IDS.includes(normalizedStepId)
1731
- ? " Only Plan made can be used as a cycle rewind target; it resets all cycle/rework state."
1732
- : "";
1733
1492
  return buildSessionResponse(paths, {
1734
1493
  ok: false,
1735
1494
  errors: [
1736
1495
  createError({
1737
1496
  code: "rewind_step_not_allowed",
1738
- message: `Cannot rewind session ${paths.sessionId} to ${normalizedStepId || "(missing)"}.${cycleHint}`
1497
+ message: `Cannot rewind session ${paths.sessionId} to ${normalizedStepId || "(missing)"}.`
1739
1498
  })
1740
1499
  ],
1741
1500
  status: currentStatus
@@ -1756,12 +1515,8 @@ async function rewindSession({
1756
1515
  }
1757
1516
 
1758
1517
  const deletedStepIds = deletedStepIdsForRewindTarget(normalizedStepId);
1759
- const cycleReset = targetRequiresCycleReset(normalizedStepId);
1760
- await removeReceiptsForDeletedSteps(paths, deletedStepIds);
1761
- await cancelDeletedStepArtifacts(paths, deletedStepIds, { cycleReset });
1762
- if (cycleReset) {
1763
- await writeActiveCycle(paths, "001");
1764
- }
1518
+ await removeStepRecordsForDeletedSteps(paths, deletedStepIds);
1519
+ await cancelDeletedStepArtifacts(paths, deletedStepIds);
1765
1520
  await markCurrentStep(paths, normalizedStepId);
1766
1521
  await markStatus(paths, SESSION_STATUS.PENDING);
1767
1522
  return buildSessionResponse(paths, {
@@ -1872,7 +1627,7 @@ async function writeBlueprintBaseline(paths) {
1872
1627
  const payload = {
1873
1628
  changedFiles: Object.keys(snapshot).sort((left, right) => left.localeCompare(right)),
1874
1629
  files: snapshot,
1875
- recordedAt: timestampForReceipt()
1630
+ recordedAt: timestampForStepRecord()
1876
1631
  };
1877
1632
  await writeTextFile(blueprintBaselinePath(paths), `${JSON.stringify(payload, null, 2)}\n`);
1878
1633
  return payload;
@@ -1966,24 +1721,23 @@ async function renderReviewPrompt(paths) {
1966
1721
  const reviewPass = await resolveReviewPassForPrompt(paths);
1967
1722
  await writeCurrentReviewPass(paths, reviewPass);
1968
1723
  const changedFiles = await changedFilesSinceBase(paths);
1969
- const prompt = await renderPrompt(paths, "review_changes.md", {
1724
+ const prompt = await renderPrompt(paths, "review_prompt_rendered.md", {
1970
1725
  changed_files: changedFiles,
1971
1726
  review_pass_limit: String(REVIEW_PASS_LIMIT),
1972
1727
  review_pass_number: reviewPass
1973
1728
  });
1974
1729
  const passRoot = reviewPassRoot(paths, reviewPass);
1975
- await writePromptArtifact(paths, "review.md", prompt);
1730
+ await writePromptArtifact(paths, "review_prompt_rendered", prompt);
1976
1731
  await mkdir(passRoot, { recursive: true });
1977
- await writeTextFile(path.join(passRoot, "prompt.md"), prompt);
1732
+ await writeTextFile(path.join(passRoot, "review_prompt_rendered"), prompt);
1978
1733
  await writeReviewPassJson(paths, reviewPass, "prompt.json", {
1979
1734
  changedFiles: changedFiles.split(/\r?\n/u).filter(Boolean),
1980
1735
  maxPasses: REVIEW_PASS_LIMIT,
1981
1736
  pass: reviewPass,
1982
- promptPath: path.join(passRoot, "prompt.md"),
1737
+ promptPath: path.join(passRoot, "review_prompt_rendered"),
1983
1738
  status: "prompted",
1984
- startedAt: timestampForReceipt()
1739
+ startedAt: timestampForStepRecord()
1985
1740
  });
1986
- await writeReceipt(paths, "review_prompt_rendered", "Started code review.");
1987
1741
  await markStatus(paths, SESSION_STATUS.WAITING_FOR_USER);
1988
1742
  return buildSessionResponse(paths, {
1989
1743
  codex: REVIEW_EXECUTION_CODEX_HANDOFF,
@@ -1992,13 +1746,42 @@ async function renderReviewPrompt(paths) {
1992
1746
  });
1993
1747
  }
1994
1748
 
1995
- async function acceptReviewChanges(paths, options = {}) {
1749
+ async function renderResolveDeslopPrompt(paths, context = {}) {
1750
+ const preconditions = context.preconditions || [];
1751
+ const reviewPass = await readCurrentReviewPass(paths);
1752
+ const prompt = await renderPrompt(paths, "review_changes_accepted_resolve.md", {});
1753
+ await writePromptArtifact(paths, "review_changes_accepted_resolve", prompt);
1754
+ if (reviewPass) {
1755
+ await writeReviewPassJson(paths, reviewPass, "resolve_prompt.json", {
1756
+ pass: reviewPass,
1757
+ promptPath: path.join(paths.sessionRoot, "prompts", "review_changes_accepted_resolve"),
1758
+ status: "resolve_prompted",
1759
+ startedAt: timestampForStepRecord()
1760
+ });
1761
+ }
1762
+ await markStatus(paths, SESSION_STATUS.WAITING_FOR_USER);
1763
+ return buildSessionResponse(paths, {
1764
+ codex: RESOLVE_DESLOP_CODEX_HANDOFF,
1765
+ ok: true,
1766
+ preconditions,
1767
+ prompt,
1768
+ status: SESSION_STATUS.WAITING_FOR_USER
1769
+ });
1770
+ }
1771
+
1772
+ async function acceptReviewChanges(paths, options = {}, context = {}) {
1773
+ const resolveDeslop = options.resolveDeslop === true ||
1774
+ normalizeText(options["resolve-deslop"]).toLowerCase() === "true";
1775
+ if (resolveDeslop) {
1776
+ return renderResolveDeslopPrompt(paths, context);
1777
+ }
1778
+
1996
1779
  const reviewDecisionProvided = Object.hasOwn(options, "reviewFindingsRemaining") ||
1997
1780
  Object.hasOwn(options, "review-findings-remaining");
1998
1781
  if (!reviewDecisionProvided) {
1999
1782
  return failSession(paths, {
2000
1783
  code: "review_decision_required",
2001
- message: "Review/deslop requires an explicit decision before it can advance. Use reviewFindingsRemaining true to run another pass, or false when the review loop is done.",
1784
+ message: "Accept review/deslop requires an explicit decision: resolve review/deslop, run review/deslop again, or continue.",
2002
1785
  repairCommand: `jskit session ${paths.sessionId} step --review-findings-remaining false`
2003
1786
  });
2004
1787
  }
@@ -2017,64 +1800,36 @@ async function acceptReviewChanges(paths, options = {}) {
2017
1800
  const reviewPass = await readCurrentReviewPass(paths);
2018
1801
  const findingsRemaining = options.reviewFindingsRemaining === true ||
2019
1802
  normalizeText(options["review-findings-remaining"]).toLowerCase() === "true";
2020
- const remainingFindings = normalizeText(options.reviewFindings || options["review-findings"]);
2021
1803
  await writeReviewPassJson(paths, reviewPass, "accepted.json", {
2022
- acceptedAt: timestampForReceipt(),
1804
+ acceptedAt: timestampForStepRecord(),
2023
1805
  changedFiles: status.changedFiles || [],
2024
1806
  findingsRemaining,
2025
- remainingFindings,
1807
+ remainingFindings: "",
2026
1808
  pass: reviewPass,
2027
1809
  status: status.changedFiles?.length ? "accepted" : "no_changes"
2028
1810
  });
2029
- await writeReceipt(paths, "review_changes_accepted", message);
1811
+ await writeStepRecord(paths, "review_changes_accepted", message);
2030
1812
  await markStatus(paths, SESSION_STATUS.RUNNING);
2031
1813
  return buildSessionResponse(paths);
2032
1814
  }
2033
1815
 
2034
1816
  async function runAutomatedChecks(paths, {
2035
- stepId,
2036
- label
2037
- }, options = {}, context = {}) {
1817
+ stepId
1818
+ }, _options = {}, context = {}) {
2038
1819
  const preconditions = context.preconditions || [];
2039
1820
  const [command, args] = await doctorCommandForWorktree(paths.worktree);
2040
- const promptFileName = `${stepId}.md`;
2041
- const promptPath = path.join(paths.sessionRoot, "prompts", promptFileName);
2042
1821
  const checksRoot = path.join(paths.sessionRoot, "checks");
2043
1822
  await mkdir(checksRoot, { recursive: true });
2044
1823
  const checkCommand = [command, ...args].join(" ");
2045
1824
 
2046
- if (await fileExists(promptPath)) {
2047
- const codexResultFailure = await requireCodexStepResult(paths, stepId, options.codexResult, preconditions);
2048
- if (codexResultFailure) {
2049
- return codexResultFailure;
2050
- }
2051
- await writeTextFile(
2052
- path.join(checksRoot, `${stepId}.json`),
2053
- `${JSON.stringify({
2054
- command: checkCommand,
2055
- ok: true,
2056
- promptPath,
2057
- status: "completed_by_codex",
2058
- stepId
2059
- }, null, 2)}\n`
2060
- );
2061
- await writeReceipt(paths, stepId, `${label} completed by Codex: ${checkCommand}.`);
2062
- await markStatus(paths, SESSION_STATUS.RUNNING);
2063
- return buildSessionResponse(paths, {
2064
- preconditions
2065
- });
2066
- }
2067
-
2068
- const prompt = await renderPrompt(paths, "automated_checks.md", {
1825
+ const prompt = await renderPrompt(paths, "automated_checks_run.md", {
2069
1826
  check_command: checkCommand
2070
1827
  });
2071
- await writePromptArtifact(paths, promptFileName, prompt);
2072
1828
  await writeTextFile(
2073
1829
  path.join(checksRoot, `${stepId}.json`),
2074
1830
  `${JSON.stringify({
2075
1831
  command: checkCommand,
2076
1832
  ok: false,
2077
- promptPath,
2078
1833
  status: "prompted",
2079
1834
  stepId
2080
1835
  }, null, 2)}\n`
@@ -2096,86 +1851,26 @@ async function writeUiCheckJson(paths, fileName, payload) {
2096
1851
 
2097
1852
  async function runDeepUiCheck(paths, {
2098
1853
  stepId,
2099
- label,
2100
1854
  phase
2101
- }, options = {}, context = {}) {
1855
+ }, _options = {}, context = {}) {
2102
1856
  const preconditions = context.preconditions || [];
2103
- const issueMetadata = await readIssueMetadata(paths);
2104
- const uiImpact = normalizeUiImpact(issueMetadata.uiImpact) || "unknown";
2105
- const skipRequested = options.skipUiCheck === true || normalizeText(options["skip-ui-check"]).toLowerCase() === "true";
2106
- const skipReason = normalizeText(options.skipReason || options["skip-reason"]);
2107
- const shouldSkip = uiImpact === "none" || skipRequested;
2108
- if (shouldSkip) {
2109
- if (skipRequested && uiImpact !== "possible") {
2110
- return failSession(paths, {
2111
- code: "ui_check_skip_not_allowed",
2112
- message: `Deep UI check can only be manually skipped when uiImpact is possible. Current uiImpact is ${uiImpact}.`,
2113
- repairCommand: `jskit session ${paths.sessionId} step`,
2114
- preconditions
2115
- });
2116
- }
2117
- if (skipRequested && !skipReason) {
2118
- return failSession(paths, {
2119
- code: "ui_check_skip_reason_required",
2120
- message: "Skipping a possible Deep UI check requires --skip-reason.",
2121
- repairCommand: `jskit session ${paths.sessionId} step --skip-ui-check --skip-reason "<reason>"`,
2122
- preconditions
2123
- });
2124
- }
2125
- const reason = uiImpact === "none" ? "uiImpact is none." : skipReason;
2126
- await writeUiCheckJson(paths, stepId, {
2127
- ok: true,
2128
- phase,
2129
- reason,
2130
- status: "skipped",
2131
- stepId,
2132
- uiImpact
2133
- });
2134
- await writeReceipt(paths, stepId, `${label} skipped: ${reason}`);
2135
- await markStatus(paths, SESSION_STATUS.RUNNING);
2136
- return buildSessionResponse(paths, {
2137
- preconditions
2138
- });
2139
- }
2140
-
2141
- const promptFileName = `${stepId}.md`;
2142
- const promptPath = path.join(paths.sessionRoot, "prompts", promptFileName);
2143
- if (await fileExists(promptPath)) {
2144
- const codexResultFailure = await requireCodexStepResult(paths, stepId, options.codexResult, preconditions);
2145
- if (codexResultFailure) {
2146
- return codexResultFailure;
2147
- }
2148
- await writeReceipt(paths, stepId, `${label} completed by Codex.`);
2149
- await markStatus(paths, SESSION_STATUS.RUNNING);
2150
- return buildSessionResponse(paths, {
2151
- preconditions
2152
- });
2153
- }
2154
-
2155
1857
  const issueUrl = await readTrimmedFile(path.join(paths.sessionRoot, "issue_url"));
2156
1858
  const issueText = await readTrimmedFile(path.join(paths.sessionRoot, "issue.md"));
2157
1859
  const issueTitle = await readTrimmedFile(path.join(paths.sessionRoot, "issue_title")) || titleFromIssue(issueText);
2158
- const { planPath } = await readCurrentPlan(paths);
2159
- const prompt = await renderPrompt(paths, "deep_ui_check.md", {
1860
+ const prompt = await renderPrompt(paths, "deep_ui_check_run.md", {
2160
1861
  changed_files: await changedFilesSinceBase(paths),
2161
1862
  issue_file: path.join(paths.sessionRoot, "issue.md"),
2162
1863
  issue_number: issueNumberFromUrl(issueUrl),
2163
1864
  issue_title: issueTitle,
2164
1865
  issue_url: issueUrl,
2165
1866
  phase,
2166
- issue_details_file: path.join(paths.sessionRoot, "issue_details.md"),
2167
- plan_file: planPath,
2168
- ui_impact: uiImpact,
2169
1867
  worktree: paths.worktree
2170
1868
  });
2171
- await writePromptArtifact(paths, promptFileName, prompt);
2172
1869
  await writeUiCheckJson(paths, stepId, {
2173
1870
  ok: true,
2174
1871
  phase,
2175
- promptPath,
2176
1872
  status: "prompted",
2177
- stepId,
2178
- uiImpact
1873
+ stepId
2179
1874
  });
2180
1875
  await markStatus(paths, SESSION_STATUS.WAITING_FOR_USER);
2181
1876
  return buildSessionResponse(paths, {
@@ -2189,54 +1884,30 @@ async function runDeepUiCheck(paths, {
2189
1884
  async function userCheck(paths, options = {}) {
2190
1885
  const result = normalizeText(options.userCheck || options["user-check"]).toLowerCase();
2191
1886
  if (result === "passed" || result === "pass" || result === "ok" || result === "yes") {
2192
- await writeReceipt(paths, "user_check_completed", "User confirmed check passed.");
1887
+ await writeStepRecord(paths, "user_check_completed", "User confirmed check passed.");
2193
1888
  await markStatus(paths, SESSION_STATUS.RUNNING);
2194
1889
  return buildSessionResponse(paths);
2195
1890
  }
2196
1891
  if (result === "failed" || result === "fail" || result === "no") {
2197
- const activeCycle = await readActiveCycle(paths);
2198
- await writeCycleReceipt(paths, "user_check_failed", "User reported that manual verification failed.", {
2199
- cycle: activeCycle
2200
- });
2201
- const reworkNotes = normalizeText(options.reworkNotes || options["rework-notes"]);
2202
- if (!reworkNotes) {
2203
- await markStatus(paths, SESSION_STATUS.BLOCKED);
2204
- return buildSessionResponse(paths, {
2205
- ok: false,
2206
- errors: [
2207
- createError({
2208
- code: "user_check_failed",
2209
- message: "User check failed. Provide rework notes to start a new plan cycle.",
2210
- repairCommand: `jskit session ${paths.sessionId} step --user-check failed --rework-notes -`
2211
- })
2212
- ],
2213
- status: SESSION_STATUS.BLOCKED
2214
- });
2215
- }
2216
- const nextCycle = nextCycleNumber(activeCycle);
2217
- await writeTextFile(path.join(paths.sessionRoot, "cycles", `cycle_${nextCycle}`, "rework_request.md"), `${reworkNotes}\n`);
2218
- await writeActiveCycle(paths, nextCycle);
2219
- await writeCycleReceipt(paths, "cycle_started", `Started rework cycle ${nextCycle}.`, {
2220
- cycle: nextCycle
2221
- });
2222
- await markCurrentStep(paths, "plan_made");
2223
1892
  await markStatus(paths, SESSION_STATUS.RUNNING);
2224
- return buildSessionResponse(paths);
1893
+ return buildSessionResponse(paths, {
1894
+ warnings: [
1895
+ {
1896
+ code: "user_check_failed",
1897
+ message: "Complete user check failed. Rewind to the step that should be redone."
1898
+ }
1899
+ ]
1900
+ });
2225
1901
  }
2226
1902
  const issueText = await readTrimmedFile(path.join(paths.sessionRoot, "issue.md"));
2227
1903
  const issueUrl = await readTrimmedFile(path.join(paths.sessionRoot, "issue_url"));
2228
1904
  const issueTitle = await readTrimmedFile(path.join(paths.sessionRoot, "issue_title")) || titleFromIssue(issueText);
2229
- const { planPath, planText } = await readCurrentPlan(paths);
2230
- const prompt = await renderPrompt(paths, "user_check.md", {
1905
+ const prompt = await renderPrompt(paths, "user_check_completed.md", {
2231
1906
  issue_file: path.join(paths.sessionRoot, "issue.md"),
2232
1907
  issue_title: issueTitle,
2233
- issue_url: issueUrl,
2234
- issue_details_file: path.join(paths.sessionRoot, "issue_details.md"),
2235
- issue_details_text: await readTrimmedFile(path.join(paths.sessionRoot, "issue_details.md")),
2236
- plan_file: planPath,
2237
- plan_text: planText
1908
+ issue_url: issueUrl
2238
1909
  });
2239
- await writePromptArtifact(paths, "user_check.md", prompt);
1910
+ await writePromptArtifact(paths, "user_check_completed", prompt);
2240
1911
  await markStatus(paths, SESSION_STATUS.WAITING_FOR_USER);
2241
1912
  return buildSessionResponse(paths, {
2242
1913
  prompt,
@@ -2251,6 +1922,7 @@ async function readAcceptedChangesCommit(paths) {
2251
1922
 
2252
1923
  async function commitAcceptedChanges(paths, _options = {}, context = {}) {
2253
1924
  const preconditions = context.preconditions || [];
1925
+ const completeStep = context.completeStep !== false;
2254
1926
  let commitInfo = await readAcceptedChangesCommit(paths);
2255
1927
 
2256
1928
  if (!commitInfo?.commit) {
@@ -2269,7 +1941,7 @@ async function commitAcceptedChanges(paths, _options = {}, context = {}) {
2269
1941
  commitInfo = {
2270
1942
  changedFiles: result.changedFiles || [],
2271
1943
  commit: await currentHead(paths),
2272
- committedAt: timestampForReceipt(),
1944
+ committedAt: timestampForStepRecord(),
2273
1945
  noChanges: (result.changedFiles || []).length < 1
2274
1946
  };
2275
1947
  await writeTextFile(path.join(paths.sessionRoot, "changes_committed.json"), `${JSON.stringify(commitInfo, null, 2)}\n`);
@@ -2282,7 +1954,14 @@ async function commitAcceptedChanges(paths, _options = {}, context = {}) {
2282
1954
  message: "No accepted worktree changes were found; continuing without a new commit."
2283
1955
  });
2284
1956
  }
2285
- await writeReceipt(
1957
+ if (!completeStep) {
1958
+ await markStatus(paths, SESSION_STATUS.RUNNING);
1959
+ return buildSessionResponse(paths, {
1960
+ preconditions,
1961
+ warnings
1962
+ });
1963
+ }
1964
+ await writeStepRecord(
2286
1965
  paths,
2287
1966
  "changes_committed",
2288
1967
  commitInfo.noChanges === true
@@ -2296,23 +1975,16 @@ async function commitAcceptedChanges(paths, _options = {}, context = {}) {
2296
1975
  });
2297
1976
  }
2298
1977
 
2299
- async function updateBlueprint(paths, options = {}, context = {}) {
1978
+ async function updateBlueprint(paths, _options = {}, context = {}) {
2300
1979
  const preconditions = context.preconditions || [];
2301
1980
  const issueText = await readTrimmedFile(path.join(paths.sessionRoot, "issue.md"));
2302
1981
  const issueTitle = await readTrimmedFile(path.join(paths.sessionRoot, "issue_title")) || titleFromIssue(issueText);
2303
1982
  const issueUrl = await readTrimmedFile(path.join(paths.sessionRoot, "issue_url"));
2304
1983
  const issueNumber = issueNumberFromUrl(issueUrl);
2305
- const { planPath } = await readCurrentPlan(paths);
2306
- const issueDetailsPath = path.join(paths.sessionRoot, "issue_details.md");
2307
- const agentDecisionsPath = path.join(paths.sessionRoot, "agent_decisions.md");
2308
1984
  const blueprintPath = path.join(paths.worktree, BLUEPRINT_RELATIVE_PATH);
2309
- const blueprintPromptPath = path.join(paths.sessionRoot, "prompts", "update_blueprint.md");
1985
+ const blueprintSentinelPath = path.join(paths.sessionRoot, "metadata", "blueprint_updated_requested");
2310
1986
 
2311
- if (await fileExists(blueprintPromptPath)) {
2312
- const codexResultFailure = await requireCodexStepResult(paths, "blueprint_updated", options.codexResult, preconditions);
2313
- if (codexResultFailure) {
2314
- return codexResultFailure;
2315
- }
1987
+ if (context.completeStep !== false && await fileExists(blueprintSentinelPath)) {
2316
1988
  const changedFiles = await changedFilesInWorktree(paths);
2317
1989
  const unexpectedChanges = await unexpectedBlueprintStepChanges(paths, changedFiles);
2318
1990
  if (unexpectedChanges.length > 0) {
@@ -2335,9 +2007,9 @@ async function updateBlueprint(paths, options = {}, context = {}) {
2335
2007
  }
2336
2008
 
2337
2009
  if (changedFiles.includes(BLUEPRINT_RELATIVE_PATH)) {
2338
- await writeReceipt(paths, "blueprint_updated", "Codex updated the app blueprint; JSKIT will include it in the accepted changes commit.");
2010
+ await writeStepRecord(paths, "blueprint_updated", "Codex updated the app blueprint; JSKIT will include it in the accepted changes commit.");
2339
2011
  } else {
2340
- await writeReceipt(paths, "blueprint_updated", "Codex reviewed the app blueprint; no blueprint changes were needed.");
2012
+ await writeStepRecord(paths, "blueprint_updated", "Codex reviewed the app blueprint; no blueprint changes were needed.");
2341
2013
  }
2342
2014
  await markStatus(paths, SESSION_STATUS.RUNNING);
2343
2015
  return buildSessionResponse(paths, {
@@ -2347,19 +2019,16 @@ async function updateBlueprint(paths, options = {}, context = {}) {
2347
2019
  }
2348
2020
 
2349
2021
  await writeBlueprintBaseline(paths);
2350
- const prompt = await renderPrompt(paths, "update_blueprint.md", {
2351
- agent_decisions_file: agentDecisionsPath,
2022
+ const prompt = await renderPrompt(paths, "blueprint_updated.md", {
2352
2023
  app_blueprint_file: blueprintPath,
2353
2024
  changed_files: await changedFilesSinceBase(paths),
2354
2025
  issue_file: path.join(paths.sessionRoot, "issue.md"),
2355
2026
  issue_number: issueNumber,
2356
2027
  issue_title: issueTitle,
2357
2028
  issue_url: issueUrl,
2358
- issue_details_file: issueDetailsPath,
2359
- plan_file: planPath,
2360
2029
  worktree: paths.worktree
2361
2030
  });
2362
- await writePromptArtifact(paths, "update_blueprint.md", prompt);
2031
+ await writeTextFile(blueprintSentinelPath, "true\n");
2363
2032
  await markStatus(paths, SESSION_STATUS.WAITING_FOR_USER);
2364
2033
  return buildSessionResponse(paths, {
2365
2034
  codex: BLUEPRINT_CODEX_HANDOFF,
@@ -2450,13 +2119,10 @@ async function readReviewPassSummaries(paths) {
2450
2119
  .join("\n");
2451
2120
  }
2452
2121
 
2453
- async function createFinalReport(paths, _options = {}, context = {}) {
2122
+ async function renderPullRequestFilePrompt(paths, context = {}) {
2454
2123
  const preconditions = context.preconditions || [];
2455
2124
  const issueUrl = await readTrimmedFile(path.join(paths.sessionRoot, "issue_url"));
2456
2125
  const issueTitle = await readTrimmedFile(path.join(paths.sessionRoot, "issue_title"));
2457
- const issueDetails = await readTrimmedFile(path.join(paths.sessionRoot, "issue_details.md"));
2458
- const { planText } = await readCurrentPlan(paths);
2459
- const agentDecisions = await readTextIfExists(path.join(paths.sessionRoot, "agent_decisions.md"));
2460
2126
  const filesChanged = await changedFilesSinceBase(paths);
2461
2127
  const commits = await commitLinesSinceBase(paths);
2462
2128
  const checks = await readCheckSummaries(paths);
@@ -2464,80 +2130,43 @@ async function createFinalReport(paths, _options = {}, context = {}) {
2464
2130
  const reviewPasses = await readReviewPassSummaries(paths);
2465
2131
  const commandLogPath = path.join(paths.sessionRoot, "command_log.jsonl");
2466
2132
  const blueprintStatus = await readTextIfExists(path.join(paths.sessionRoot, "steps", "blueprint_updated"));
2467
- const userCheck = await readTextIfExists(path.join(paths.sessionRoot, "steps", `cycle_${await readActiveCycle(paths)}`, "user_check_completed"));
2468
- const report = [
2469
- `# Final Report: ${issueTitle || paths.sessionId}`,
2470
- "",
2471
- `Issue: ${issueUrl || "(missing)"}`,
2472
- `Session: ${paths.sessionId}`,
2473
- "",
2474
- "## Issue Details",
2475
- "",
2476
- issueDetails || "No issue details recorded.",
2477
- "",
2478
- "## Plan",
2479
- "",
2480
- planText || "No plan recorded.",
2481
- "",
2482
- "## Files Changed",
2483
- "",
2484
- filesChanged || "No changed files detected against the session base.",
2485
- "",
2486
- "## Commits",
2487
- "",
2488
- commits || "No commits detected against the session base.",
2489
- "",
2490
- "## Checks",
2491
- "",
2492
- checks || "No structured checks recorded.",
2493
- "",
2494
- "## UI Checks",
2495
- "",
2496
- uiChecks || "No structured UI checks recorded.",
2497
- "",
2498
- "## Review Passes",
2499
- "",
2500
- reviewPasses || "No structured review passes recorded.",
2501
- "",
2502
- "## Command Log",
2503
- "",
2504
- await fileExists(commandLogPath) ? commandLogPath : "No command log recorded.",
2505
- "",
2506
- "## User Check",
2507
- "",
2508
- userCheck.trim() || "No user check receipt recorded.",
2509
- "",
2510
- "## Blueprint",
2511
- "",
2512
- blueprintStatus.trim() || "No blueprint receipt recorded.",
2513
- "",
2514
- "## Remaining Unverified Gaps",
2515
- "",
2516
- "Review the check and UI check sections above; no additional gaps were recorded by JSKIT.",
2517
- "",
2518
- "## Decisions",
2519
- "",
2520
- agentDecisions.trim() || "No decision log recorded.",
2521
- ""
2522
- ].join("\n");
2523
- const reportPath = path.join(paths.sessionRoot, "final_report.md");
2524
- await writeTextFile(reportPath, report);
2525
- if (issueUrl) {
2526
- const commentResult = await commentOnIssueOnce(paths, {
2527
- bodyFile: reportPath,
2528
- issueUrl,
2529
- purpose: "final_report"
2530
- });
2531
- if (!commentResult.ok) {
2532
- return failSession(paths, {
2533
- code: "final_report_comment_failed",
2534
- message: commentResult.output || "Failed to comment final report on the GitHub issue.",
2535
- repairCommand: `gh issue comment ${issueUrl} --body-file ${reportPath}`,
2536
- preconditions
2537
- });
2538
- }
2133
+ const userCheck = await readTextIfExists(path.join(paths.sessionRoot, "steps", "user_check_completed")) ||
2134
+ await readTextIfExists(path.join(paths.sessionRoot, "steps", `cycle_${await readActiveCycle(paths)}`, "user_check_completed"));
2135
+ const prompt = await renderPrompt(paths, "final_report_created.md", {
2136
+ base_branch: await readTrimmedFile(path.join(paths.sessionRoot, "base_branch")),
2137
+ blueprint_status: blueprintStatus.trim() || "No blueprint update recorded.",
2138
+ checks: checks || "No structured checks recorded.",
2139
+ command_log: await fileExists(commandLogPath) ? commandLogPath : "No command log recorded.",
2140
+ commits: commits || "No commits detected against the session base.",
2141
+ files_changed: filesChanged || "No changed files detected against the session base.",
2142
+ issue_file: path.join(paths.sessionRoot, "issue.md"),
2143
+ issue_title: issueTitle || paths.sessionId,
2144
+ issue_url: issueUrl || "",
2145
+ pull_request_file: path.join(paths.sessionRoot, "pull_request.md"),
2146
+ review_passes: reviewPasses || "No structured review passes recorded.",
2147
+ session_id: paths.sessionId,
2148
+ ui_checks: uiChecks || "No structured UI checks recorded.",
2149
+ user_check: userCheck.trim() || "No user check recorded.",
2150
+ worktree: paths.worktree
2151
+ });
2152
+ await writeTextFile(path.join(paths.sessionRoot, "metadata", "pull_request_file_requested"), "true\n");
2153
+ await markStatus(paths, SESSION_STATUS.WAITING_FOR_USER);
2154
+ return buildSessionResponse(paths, {
2155
+ codex: PR_FILE_CODEX_HANDOFF,
2156
+ ok: true,
2157
+ preconditions,
2158
+ prompt,
2159
+ status: SESSION_STATUS.WAITING_FOR_USER
2160
+ });
2161
+ }
2162
+
2163
+ async function createPullRequestFile(paths, _options = {}, context = {}) {
2164
+ const preconditions = context.preconditions || [];
2165
+ const pullRequestText = await readTrimmedFile(path.join(paths.sessionRoot, "pull_request.md"));
2166
+ if (!pullRequestText || context.completeStep === false) {
2167
+ return renderPullRequestFilePrompt(paths, context);
2539
2168
  }
2540
- await writeReceipt(paths, "final_report_created", "Created final report and recorded the GitHub issue comment.");
2169
+ await writeStepRecord(paths, "final_report_created", "Pull request file is ready for review and submission.");
2541
2170
  await markStatus(paths, SESSION_STATUS.RUNNING);
2542
2171
  return buildSessionResponse(paths, {
2543
2172
  preconditions
@@ -2558,6 +2187,140 @@ function parseJsonObject(value) {
2558
2187
  }
2559
2188
  }
2560
2189
 
2190
+ function booleanOption(options = {}, ...names) {
2191
+ return names.some((name) => {
2192
+ const value = options[name];
2193
+ return value === true || normalizeText(value).toLowerCase() === "true";
2194
+ });
2195
+ }
2196
+
2197
+ function skipStepRequested(options = {}) {
2198
+ return booleanOption(options, "skipStep", "skip-step", "skip");
2199
+ }
2200
+
2201
+ function skipStepReason(options = {}, stepId = "") {
2202
+ return normalizeText(options.skipReason || options["skip-reason"]) ||
2203
+ `User skipped ${STEP_DEFINITION_BY_ID[stepId]?.label || stepId}.`;
2204
+ }
2205
+
2206
+ async function writeJsonFile(filePath, payload) {
2207
+ await writeTextFile(filePath, `${JSON.stringify(payload, null, 2)}\n`);
2208
+ }
2209
+
2210
+ async function writeTextIfMissing(filePath, value) {
2211
+ if (await fileExists(filePath)) {
2212
+ return;
2213
+ }
2214
+ await writeTextFile(filePath, value);
2215
+ }
2216
+
2217
+ async function writeSkippedIssueDraft(paths, reason) {
2218
+ await writeTextIfMissing(path.join(paths.sessionRoot, "issue_title"), `Skipped issue draft for ${paths.sessionId}\n`);
2219
+ await writeTextIfMissing(
2220
+ path.join(paths.sessionRoot, "issue.md"),
2221
+ `# Skipped issue draft\n\n${reason}\n`
2222
+ );
2223
+ }
2224
+
2225
+ async function writeSkippedReviewPass(paths, reason) {
2226
+ const reviewPass = normalizeReviewPassNumber(await readTrimmedFile(path.join(paths.sessionRoot, "review_passes", "current_pass")) || "001");
2227
+ await writeCurrentReviewPass(paths, reviewPass);
2228
+ await writeReviewPassJson(paths, reviewPass, "accepted.json", {
2229
+ acceptedAt: timestampForStepRecord(),
2230
+ changedFiles: [],
2231
+ findingsRemaining: false,
2232
+ pass: reviewPass,
2233
+ reason,
2234
+ status: "skipped"
2235
+ });
2236
+ }
2237
+
2238
+ async function writeSkippedStepArtifacts(paths, stepId, reason) {
2239
+ if (stepId === "issue_created") {
2240
+ await writeSkippedIssueDraft(paths, reason);
2241
+ }
2242
+ if (stepId === "issue_submitted") {
2243
+ await writeTextIfMissing(path.join(paths.sessionRoot, "issue_url"), `skipped://${paths.sessionId}/issue\n`);
2244
+ }
2245
+ if (stepId === "deep_ui_check_run") {
2246
+ await writeUiCheckJson(paths, stepId, {
2247
+ ok: true,
2248
+ reason,
2249
+ status: "skipped",
2250
+ stepId
2251
+ });
2252
+ }
2253
+ if (stepId === "automated_checks_run") {
2254
+ await mkdir(path.join(paths.sessionRoot, "checks"), { recursive: true });
2255
+ await writeJsonFile(path.join(paths.sessionRoot, "checks", `${stepId}.json`), {
2256
+ ok: true,
2257
+ reason,
2258
+ status: "skipped",
2259
+ stepId
2260
+ });
2261
+ }
2262
+ if (stepId === "review_prompt_rendered") {
2263
+ await writeSkippedReviewPass(paths, reason);
2264
+ }
2265
+ if (stepId === "review_changes_accepted") {
2266
+ await writeSkippedReviewPass(paths, reason);
2267
+ }
2268
+ if (stepId === "changes_committed") {
2269
+ await writeJsonFile(path.join(paths.sessionRoot, "changes_committed.json"), {
2270
+ changedFiles: [],
2271
+ commit: await currentHead(paths),
2272
+ committedAt: timestampForStepRecord(),
2273
+ noChanges: true,
2274
+ reason
2275
+ });
2276
+ }
2277
+ if (stepId === "final_report_created") {
2278
+ await writeTextIfMissing(
2279
+ path.join(paths.sessionRoot, "pull_request.md"),
2280
+ `# Pull Request: ${paths.sessionId}\n\nPull request file step skipped.\n\n${reason}\n`
2281
+ );
2282
+ }
2283
+ if (stepId === "pr_created") {
2284
+ await writeTextIfMissing(path.join(paths.sessionRoot, "pr_url"), `skipped://${paths.sessionId}/pr\n`);
2285
+ }
2286
+ if (stepId === "pr_merge_prepared") {
2287
+ const prUrl = await readTrimmedFile(path.join(paths.sessionRoot, "pr_url"));
2288
+ await writePrOutcome(paths, {
2289
+ outcome: "skipped",
2290
+ prUrl,
2291
+ reason
2292
+ });
2293
+ }
2294
+ if (stepId === "main_checkout_synced") {
2295
+ await writeMainCheckoutSync(paths, {
2296
+ reason,
2297
+ status: "skipped"
2298
+ });
2299
+ }
2300
+ }
2301
+
2302
+ async function skipCurrentStep(paths, stepId, options = {}) {
2303
+ if (["worktree_created", "dependencies_installed", "issue_prompt_rendered", "session_finished"].includes(stepId)) {
2304
+ return failSession(paths, {
2305
+ code: "session_step_skip_not_allowed",
2306
+ message: `Step ${stepId} cannot be skipped.`,
2307
+ repairCommand: `jskit session ${paths.sessionId} step`
2308
+ });
2309
+ }
2310
+ const reason = skipStepReason(options, stepId);
2311
+ await writeSkippedStepArtifacts(paths, stepId, reason);
2312
+ await writeStepRecord(paths, stepId, `Skipped: ${reason}`);
2313
+ await markStatus(paths, SESSION_STATUS.RUNNING);
2314
+ return buildSessionResponse(paths, {
2315
+ warnings: [
2316
+ {
2317
+ code: "session_step_skipped",
2318
+ message: `${STEP_DEFINITION_BY_ID[stepId]?.label || stepId} was skipped.`
2319
+ }
2320
+ ]
2321
+ });
2322
+ }
2323
+
2561
2324
  async function readPrState(paths, prUrl) {
2562
2325
  const prRef = normalizeText(prUrl);
2563
2326
  const args = prRef
@@ -2661,7 +2424,7 @@ async function removeSessionWorktree(paths) {
2661
2424
 
2662
2425
  async function writePrOutcome(paths, outcome) {
2663
2426
  await writeTextFile(path.join(paths.sessionRoot, "pr_outcome.json"), `${JSON.stringify({
2664
- recordedAt: timestampForReceipt(),
2427
+ recordedAt: timestampForStepRecord(),
2665
2428
  ...outcome
2666
2429
  }, null, 2)}\n`);
2667
2430
  }
@@ -2672,7 +2435,7 @@ function mainCheckoutSyncPath(paths) {
2672
2435
 
2673
2436
  async function writeMainCheckoutSync(paths, payload = {}) {
2674
2437
  await writeTextFile(mainCheckoutSyncPath(paths), `${JSON.stringify({
2675
- recordedAt: timestampForReceipt(),
2438
+ recordedAt: timestampForStepRecord(),
2676
2439
  ...payload
2677
2440
  }, null, 2)}\n`);
2678
2441
  }
@@ -2738,36 +2501,33 @@ async function updateLocalBaseBranch(paths, baseBranch = "") {
2738
2501
  }
2739
2502
 
2740
2503
  async function syncMainCheckout(paths, options = {}, context = {}) {
2504
+ const prUrl = await readTrimmedFile(path.join(paths.sessionRoot, "pr_url"));
2741
2505
  const prOutcome = parseJsonObject(await readTextIfExists(path.join(paths.sessionRoot, "pr_outcome.json")));
2742
2506
  const preconditions = context.preconditions || [];
2507
+ const completeStep = context.completeStep !== false;
2508
+ if (!prUrl) {
2509
+ return sessionStepError(paths, {
2510
+ code: "pr_url_missing",
2511
+ message: "Cannot sync the main checkout until the GitHub pull request exists.",
2512
+ repairCommand: `jskit session ${paths.sessionId} create_pr_on_gh`
2513
+ });
2514
+ }
2743
2515
  if (!prOutcome?.outcome) {
2744
- return failSession(paths, {
2516
+ return sessionStepError(paths, {
2745
2517
  code: "pr_outcome_missing",
2746
- message: "Cannot sync the main checkout before PR finalization records an outcome.",
2747
- preconditions,
2748
- repairCommand: `jskit session ${paths.sessionId} step`
2518
+ message: "Cannot sync the main checkout before the PR merge step records an outcome.",
2519
+ repairCommand: `jskit session ${paths.sessionId} next`
2749
2520
  });
2750
2521
  }
2751
2522
 
2752
- const skipRequested = options.skipMainSync === true ||
2753
- normalizeText(options["skip-main-sync"]).toLowerCase() === "true";
2754
- const skipReason = normalizeText(options.skipReason || options["skip-reason"]) ||
2755
- "User skipped main checkout sync.";
2756
- if (skipRequested || prOutcome.outcome !== "merged") {
2757
- const reason = prOutcome.outcome === "merged"
2758
- ? skipReason
2759
- : `PR outcome is ${prOutcome.outcome}; no main checkout sync is required.`;
2760
- await writeMainCheckoutSync(paths, {
2761
- branch: prOutcome.baseBranch || "",
2762
- outcome: prOutcome.outcome,
2763
- reason,
2764
- status: "skipped"
2523
+ void options;
2524
+ if (prOutcome.outcome !== "merged") {
2525
+ return sessionStepError(paths, {
2526
+ code: "main_checkout_sync_unavailable",
2527
+ message: `Cannot sync the main checkout because the PR outcome is ${prOutcome.outcome}.`,
2528
+ repairCommand: `jskit session ${paths.sessionId} next`
2765
2529
  });
2766
- await writeReceipt(paths, "main_checkout_synced", `Main checkout sync skipped: ${reason}`);
2767
- await markStatus(paths, SESSION_STATUS.RUNNING);
2768
- return buildSessionResponse(paths);
2769
2530
  }
2770
-
2771
2531
  const baseBranch = prOutcome.baseBranch || await readTrimmedFile(path.join(paths.sessionRoot, "pr_base_branch"));
2772
2532
  const syncFailure = await updateLocalBaseBranch(paths, baseBranch);
2773
2533
  if (syncFailure) {
@@ -2780,9 +2540,13 @@ async function syncMainCheckout(paths, options = {}, context = {}) {
2780
2540
  outcome: prOutcome.outcome,
2781
2541
  status: "synced"
2782
2542
  });
2783
- await writeReceipt(paths, "main_checkout_synced", `Fast-forwarded target checkout branch ${branch}.`);
2543
+ if (completeStep) {
2544
+ await writeStepRecord(paths, "main_checkout_synced", `Fast-forwarded target checkout branch ${branch}.`);
2545
+ }
2784
2546
  await markStatus(paths, SESSION_STATUS.RUNNING);
2785
- return buildSessionResponse(paths);
2547
+ return buildSessionResponse(paths, {
2548
+ preconditions
2549
+ });
2786
2550
  }
2787
2551
 
2788
2552
  async function updateHelperMapBeforePr(paths) {
@@ -2866,13 +2630,35 @@ async function updateHelperMapBeforePr(paths) {
2866
2630
  };
2867
2631
  }
2868
2632
 
2869
- async function createPr(paths) {
2633
+ async function createPr(paths, _options = {}, context = {}) {
2634
+ const preconditions = context.preconditions || [];
2635
+ const completeStep = context.completeStep !== false;
2636
+ const existingPrUrl = await readTrimmedFile(path.join(paths.sessionRoot, "pr_url"));
2637
+ if (existingPrUrl) {
2638
+ if (completeStep) {
2639
+ await writeStepRecord(paths, "pr_created", `Reused existing PR ${existingPrUrl}.`);
2640
+ }
2641
+ await markStatus(paths, SESSION_STATUS.RUNNING);
2642
+ return buildSessionResponse(paths, {
2643
+ preconditions
2644
+ });
2645
+ }
2646
+ const pullRequestPath = path.join(paths.sessionRoot, "pull_request.md");
2647
+ const pullRequestText = await readTrimmedFile(pullRequestPath);
2648
+ if (!pullRequestText) {
2649
+ return sessionStepError(paths, {
2650
+ code: "pull_request_file_missing",
2651
+ message: "Cannot create the GitHub pull request until pull_request.md exists.",
2652
+ repairCommand: `jskit session ${paths.sessionId} create_pull_request_file`
2653
+ });
2654
+ }
2870
2655
  const helperMapResult = await updateHelperMapBeforePr(paths);
2871
2656
  if (!helperMapResult.ok) {
2872
2657
  return failSession(paths, {
2873
2658
  code: helperMapResult.code,
2874
2659
  message: helperMapResult.message,
2875
- repairCommand: helperMapResult.repairCommand
2660
+ repairCommand: helperMapResult.repairCommand,
2661
+ preconditions
2876
2662
  });
2877
2663
  }
2878
2664
 
@@ -2884,34 +2670,30 @@ async function createPr(paths) {
2884
2670
  return failSession(paths, {
2885
2671
  code: "branch_push_failed",
2886
2672
  message: pushResult.output || "Failed to push session branch.",
2887
- repairCommand: `git -C ${paths.worktree} push -u origin HEAD`
2673
+ repairCommand: `git -C ${paths.worktree} push -u origin HEAD`,
2674
+ preconditions
2888
2675
  });
2889
2676
  }
2890
2677
  const existingPrState = await readCurrentBranchPrState(paths);
2891
2678
  if (existingPrState.ok && existingPrState.url && !prStateIsClosed(existingPrState)) {
2892
2679
  await writeTextFile(path.join(paths.sessionRoot, "pr_url"), existingPrState.url);
2893
- await writeReceipt(paths, "pr_created", `Pushed branch ${paths.branch} and reused existing PR ${existingPrState.url}. ${helperMapResult.message}`);
2680
+ if (completeStep) {
2681
+ await writeStepRecord(paths, "pr_created", `Pushed branch ${paths.branch} and reused existing PR ${existingPrState.url}. ${helperMapResult.message}`);
2682
+ }
2894
2683
  await markStatus(paths, SESSION_STATUS.RUNNING);
2895
- return buildSessionResponse(paths);
2684
+ return buildSessionResponse(paths, {
2685
+ preconditions
2686
+ });
2896
2687
  }
2897
- const issueUrl = await readTrimmedFile(path.join(paths.sessionRoot, "issue_url"));
2898
2688
  const issueText = await readTrimmedFile(path.join(paths.sessionRoot, "issue.md"));
2899
2689
  const issueTitle = await readTrimmedFile(path.join(paths.sessionRoot, "issue_title")) || titleFromIssue(issueText);
2900
- const issueNumber = issueNumberFromUrl(issueUrl);
2901
- const body = [
2902
- issueNumber ? `Closes #${issueNumber}` : "",
2903
- "",
2904
- issueText
2905
- ].join("\n").trim();
2906
- const bodyPath = path.join(paths.sessionRoot, "pr_body.md");
2907
- await writeTextFile(bodyPath, body);
2908
2690
  const result = await runLoggedCommand(paths, "github_pr_create", "gh", [
2909
2691
  "pr",
2910
2692
  "create",
2911
2693
  "--title",
2912
2694
  issueTitle,
2913
2695
  "--body-file",
2914
- bodyPath
2696
+ pullRequestPath
2915
2697
  ], {
2916
2698
  cwd: paths.worktree,
2917
2699
  timeout: 1000 * 60
@@ -2920,144 +2702,87 @@ async function createPr(paths) {
2920
2702
  const fallbackPrState = await readCurrentBranchPrState(paths);
2921
2703
  if (fallbackPrState.ok && fallbackPrState.url && !prStateIsClosed(fallbackPrState)) {
2922
2704
  await writeTextFile(path.join(paths.sessionRoot, "pr_url"), fallbackPrState.url);
2923
- await writeReceipt(paths, "pr_created", `Pushed branch ${paths.branch} and reused existing PR ${fallbackPrState.url}. ${helperMapResult.message}`);
2705
+ if (completeStep) {
2706
+ await writeStepRecord(paths, "pr_created", `Pushed branch ${paths.branch} and reused existing PR ${fallbackPrState.url}. ${helperMapResult.message}`);
2707
+ }
2924
2708
  await markStatus(paths, SESSION_STATUS.RUNNING);
2925
- return buildSessionResponse(paths);
2709
+ return buildSessionResponse(paths, {
2710
+ preconditions
2711
+ });
2926
2712
  }
2927
2713
  const prompt = await renderPrompt(paths, "pr_failure.md", {
2928
2714
  doctor_output: result.output
2929
2715
  });
2930
- await writePromptArtifact(paths, "pr_create_failure.md", prompt);
2716
+ await writePromptArtifact(paths, "pr_create_failure", prompt);
2931
2717
  return failSession(paths, {
2932
2718
  code: "pr_create_failed",
2933
2719
  message: result.output || "Failed to create PR.",
2934
2720
  repairCommand: "gh pr create",
2721
+ preconditions,
2935
2722
  prompt
2936
2723
  });
2937
2724
  }
2938
2725
  const prUrl = result.stdout.split(/\r?\n/u).map((line) => line.trim()).find(Boolean) || result.stdout;
2939
2726
  await writeTextFile(path.join(paths.sessionRoot, "pr_url"), prUrl);
2940
- await writeReceipt(paths, "pr_created", `Pushed branch ${paths.branch} and created PR ${prUrl}. ${helperMapResult.message}`);
2941
- await markStatus(paths, SESSION_STATUS.RUNNING);
2942
- return buildSessionResponse(paths);
2943
- }
2944
-
2945
- async function closePrWithoutMerge(paths, prUrl, options = {}) {
2946
- const reason = normalizeText(options.closeReason || options["close-reason"]) || "User skipped merge in JSKIT Studio.";
2947
- const prState = await readPrState(paths, prUrl);
2948
- if (!prState.ok) {
2949
- return failSession(paths, {
2950
- code: "pr_state_failed",
2951
- message: prState.output || "Failed to inspect PR before closing without merge.",
2952
- repairCommand: `gh pr view ${prUrl} --json state,mergedAt,url,baseRefName`
2953
- });
2954
- }
2955
- if (prStateIsMerged(prState)) {
2956
- return failSession(paths, {
2957
- code: "pr_already_merged",
2958
- message: "Cannot finish without merging because the PR is already merged.",
2959
- repairCommand: `jskit session ${paths.sessionId} step`
2960
- });
2961
- }
2962
- if (!prStateIsClosed(prState)) {
2963
- const commentResult = await runLoggedCommand(paths, "github_pr_comment", "gh", ["pr", "comment", prUrl, "--body", `JSKIT session ${paths.sessionId} finished without merging. Reason: ${reason}`], {
2964
- cwd: paths.targetRoot,
2965
- timeout: 1000 * 60
2966
- });
2967
- if (!commentResult.ok) {
2968
- return failSession(paths, {
2969
- code: "pr_comment_failed",
2970
- message: commentResult.output || "Failed to comment on PR before finishing without merge.",
2971
- repairCommand: `gh pr comment ${prUrl} --body "<reason>"`
2972
- });
2973
- }
2727
+ if (completeStep) {
2728
+ await writeStepRecord(paths, "pr_created", `Pushed branch ${paths.branch} and created PR ${prUrl}. ${helperMapResult.message}`);
2974
2729
  }
2975
- const issueUrl = await readTrimmedFile(path.join(paths.sessionRoot, "issue_url"));
2976
- if (issueUrl) {
2977
- await runLoggedCommand(paths, "github_issue_comment", "gh", ["issue", "comment", issueUrl, "--body", `JSKIT session ${paths.sessionId} finished without merging PR ${prUrl}. Reason: ${reason}`], {
2978
- cwd: paths.targetRoot,
2979
- timeout: 1000 * 60
2980
- });
2981
- }
2982
- await writePrOutcome(paths, {
2983
- issueUrl,
2984
- outcome: "closed_without_merge",
2985
- prUrl,
2986
- prState: prState.state,
2987
- reason
2988
- });
2989
- await writeReceipt(paths, "pr_finalized", `Finished without merging PR ${prUrl}; PR left open. Reason: ${reason}`);
2990
2730
  await markStatus(paths, SESSION_STATUS.RUNNING);
2991
- return buildSessionResponse(paths);
2731
+ return buildSessionResponse(paths, {
2732
+ preconditions
2733
+ });
2992
2734
  }
2993
2735
 
2994
2736
  async function preparePrMerge(paths, options = {}, context = {}) {
2995
2737
  const preconditions = context.preconditions || [];
2996
- const prepareMerge = options.prepareMerge === true ||
2997
- normalizeText(options["prepare-merge"]).toLowerCase() === "true";
2998
- const continueToMerge = options.continueToMerge === true ||
2999
- normalizeText(options["continue-to-merge"]).toLowerCase() === "true";
3000
-
3001
- if (prepareMerge) {
3002
- const prUrl = await readTrimmedFile(path.join(paths.sessionRoot, "pr_url"));
3003
- const baseBranch = await readTrimmedFile(path.join(paths.sessionRoot, "pr_base_branch")) ||
3004
- await readTrimmedFile(path.join(paths.sessionRoot, "base_branch")) ||
3005
- await currentTargetBranch(paths.targetRoot);
3006
- const prompt = await renderPrompt(paths, "prepare_pr_merge.md", {
3007
- base_branch: baseBranch,
3008
- final_report_file: path.join(paths.sessionRoot, "final_report.md"),
3009
- issue_url: await readTrimmedFile(path.join(paths.sessionRoot, "issue_url")),
3010
- pr_url: prUrl,
3011
- target_root: paths.targetRoot
3012
- });
3013
- await writePromptArtifact(paths, "prepare_pr_merge.md", prompt);
3014
- await markStatus(paths, SESSION_STATUS.WAITING_FOR_USER);
3015
- return buildSessionResponse(paths, {
3016
- codex: PR_MERGE_PREP_CODEX_HANDOFF,
3017
- ok: true,
3018
- preconditions,
3019
- prompt,
3020
- status: SESSION_STATUS.WAITING_FOR_USER
3021
- });
3022
- }
3023
-
3024
- if (!continueToMerge) {
3025
- return failSession(paths, {
3026
- code: "pr_merge_prepare_decision_required",
3027
- message: "Choose whether to ask Codex to prepare the PR for merge or continue to the merge decision.",
3028
- repairCommand: `jskit session ${paths.sessionId} step --continue-to-merge true`,
3029
- preconditions
3030
- });
3031
- }
3032
-
3033
- await writeReceipt(paths, "pr_merge_prepared", "User continued from PR merge preparation to the merge decision.");
3034
- await markStatus(paths, SESSION_STATUS.RUNNING);
2738
+ void options;
2739
+ const prUrl = await readTrimmedFile(path.join(paths.sessionRoot, "pr_url"));
2740
+ if (!prUrl) {
2741
+ return sessionStepError(paths, {
2742
+ code: "pr_url_missing",
2743
+ message: "Cannot prepare the pull request for merge until the GitHub pull request exists.",
2744
+ repairCommand: `jskit session ${paths.sessionId} create_pr_on_gh`
2745
+ });
2746
+ }
2747
+ const baseBranch = await readTrimmedFile(path.join(paths.sessionRoot, "pr_base_branch")) ||
2748
+ await readTrimmedFile(path.join(paths.sessionRoot, "base_branch")) ||
2749
+ await currentTargetBranch(paths.targetRoot);
2750
+ const prompt = await renderPrompt(paths, "pr_merge_prepared.md", {
2751
+ base_branch: baseBranch,
2752
+ issue_url: await readTrimmedFile(path.join(paths.sessionRoot, "issue_url")),
2753
+ pull_request_file: path.join(paths.sessionRoot, "pull_request.md"),
2754
+ pr_url: prUrl,
2755
+ target_root: paths.targetRoot
2756
+ });
2757
+ await writePromptArtifact(paths, "pr_merge_prepared", prompt);
2758
+ await markStatus(paths, SESSION_STATUS.WAITING_FOR_USER);
3035
2759
  return buildSessionResponse(paths, {
3036
- preconditions
2760
+ codex: PR_MERGE_PREP_CODEX_HANDOFF,
2761
+ ok: true,
2762
+ preconditions,
2763
+ prompt,
2764
+ status: SESSION_STATUS.WAITING_FOR_USER
3037
2765
  });
3038
2766
  }
3039
2767
 
3040
2768
  async function finalizePr(paths, options = {}, context = {}) {
3041
2769
  const preconditions = context.preconditions || [];
2770
+ const completeStep = context.completeStep !== false;
3042
2771
  const prUrl = await readTrimmedFile(path.join(paths.sessionRoot, "pr_url"));
3043
- const closeWithoutMerge = options.closeWithoutMerge === true ||
3044
- normalizeText(options["close-without-merge"]).toLowerCase() === "true" ||
3045
- options.skipMerge === true ||
3046
- normalizeText(options["skip-merge"]).toLowerCase() === "true";
3047
- if (closeWithoutMerge) {
3048
- const guardResult = await runSessionFinalizationGuard(paths, preconditions);
3049
- if (!guardResult.ok) {
3050
- return guardResult.response;
3051
- }
3052
- return closePrWithoutMerge(paths, prUrl, options);
3053
- }
3054
2772
  const mergePr = options.mergePr === true ||
3055
2773
  normalizeText(options["merge-pr"]).toLowerCase() === "true";
2774
+ if (!prUrl) {
2775
+ return sessionStepError(paths, {
2776
+ code: "pr_url_missing",
2777
+ message: "Cannot merge the pull request until the GitHub pull request exists.",
2778
+ repairCommand: `jskit session ${paths.sessionId} create_pr_on_gh`
2779
+ });
2780
+ }
3056
2781
  if (!mergePr) {
3057
2782
  return failSession(paths, {
3058
2783
  code: "pr_finalize_decision_required",
3059
2784
  message: "Choose whether to merge the PR or skip merge.",
3060
- repairCommand: `jskit session ${paths.sessionId} step --merge-pr true`,
2785
+ repairCommand: `jskit session ${paths.sessionId} merge_pr`,
3061
2786
  preconditions
3062
2787
  });
3063
2788
  }
@@ -3092,7 +2817,7 @@ async function finalizePr(paths, options = {}, context = {}) {
3092
2817
  const prompt = await renderPrompt(paths, "pr_failure.md", {
3093
2818
  doctor_output: mergeResult?.output || existingPrState.output
3094
2819
  });
3095
- await writePromptArtifact(paths, "pr_merge_failure.md", prompt);
2820
+ await writePromptArtifact(paths, "pr_merge_failure", prompt);
3096
2821
  return failSession(paths, {
3097
2822
  code: "pr_merge_failed",
3098
2823
  message: mergeResult?.output || existingPrState.output || "Failed to merge PR.",
@@ -3116,9 +2841,13 @@ async function finalizePr(paths, options = {}, context = {}) {
3116
2841
  });
3117
2842
  await writeTextFile(mergeMarkerPath, `${prUrl}\n`);
3118
2843
  }
3119
- await writeReceipt(paths, "pr_finalized", `Merged PR ${prUrl}.`);
2844
+ if (completeStep) {
2845
+ await writeStepRecord(paths, "pr_merge_prepared", `Merged PR ${prUrl}.`);
2846
+ }
3120
2847
  await markStatus(paths, SESSION_STATUS.RUNNING);
3121
- return buildSessionResponse(paths);
2848
+ return buildSessionResponse(paths, {
2849
+ preconditions
2850
+ });
3122
2851
  }
3123
2852
 
3124
2853
  async function finishSession(paths) {
@@ -3139,14 +2868,15 @@ async function finishSession(paths) {
3139
2868
  session_id: paths.sessionId,
3140
2869
  transcript_log: path.join(paths.completedSessionRoot, "transcript.log")
3141
2870
  });
3142
- await writeTextFile(path.join(paths.sessionRoot, "final_comment.md"), prompt);
2871
+ const finalCommentPath = path.join(paths.sessionRoot, "final_comment");
2872
+ await writeTextFile(finalCommentPath, prompt);
3143
2873
  if (issueUrl) {
3144
- await runLoggedCommand(paths, "github_issue_comment", "gh", ["issue", "comment", issueUrl, "--body-file", path.join(paths.sessionRoot, "final_comment.md")], {
2874
+ await runLoggedCommand(paths, "github_issue_comment", "gh", ["issue", "comment", issueUrl, "--body-file", finalCommentPath], {
3145
2875
  cwd: paths.targetRoot,
3146
2876
  timeout: 1000 * 60
3147
2877
  });
3148
2878
  }
3149
- await writeReceipt(paths, "session_finished", `Removed worktree ${paths.worktree} and finished session ${paths.sessionId} with PR outcome ${prOutcome?.outcome || "unknown"}.`);
2879
+ await writeStepRecord(paths, "session_finished", `Removed worktree ${paths.worktree} and finished session ${paths.sessionId} with PR outcome ${prOutcome?.outcome || "unknown"}.`);
3150
2880
  await markStatus(paths, SESSION_STATUS.FINISHED);
3151
2881
  await markCurrentStep(paths, "");
3152
2882
  const archivedPaths = await archiveSession(paths, "completed");
@@ -3159,17 +2889,14 @@ const STEP_RUNNERS = Object.freeze({
3159
2889
  worktree_created: createWorktree,
3160
2890
  dependencies_installed: installDependencies,
3161
2891
  issue_prompt_rendered: renderIssuePrompt,
3162
- issue_drafted: draftIssue,
3163
2892
  issue_created: createIssue,
3164
- issue_details_gathered: saveIssueDetails,
2893
+ issue_submitted: submitIssue,
3165
2894
  plan_made: makePlan,
3166
2895
  plan_executed: renderPlanExecutionPrompt,
3167
2896
  automated_checks_run: (paths, options, context) => runAutomatedChecks(paths, {
3168
- label: "Automated checks",
3169
2897
  stepId: "automated_checks_run"
3170
2898
  }, options, context),
3171
2899
  deep_ui_check_run: (paths, options, context) => runDeepUiCheck(paths, {
3172
- label: "Deep UI check",
3173
2900
  phase: "pre_review",
3174
2901
  stepId: "deep_ui_check_run"
3175
2902
  }, options, context),
@@ -3178,10 +2905,9 @@ const STEP_RUNNERS = Object.freeze({
3178
2905
  user_check_completed: userCheck,
3179
2906
  changes_committed: commitAcceptedChanges,
3180
2907
  blueprint_updated: updateBlueprint,
3181
- final_report_created: createFinalReport,
2908
+ final_report_created: createPullRequestFile,
3182
2909
  pr_created: createPr,
3183
2910
  pr_merge_prepared: preparePrMerge,
3184
- pr_finalized: finalizePr,
3185
2911
  main_checkout_synced: syncMainCheckout,
3186
2912
  session_finished: finishSession
3187
2913
  });
@@ -3190,21 +2916,19 @@ const PRECONDITION_RUNNERS = Object.freeze({
3190
2916
  accepted_changes_committed: assertAcceptedChangesCommitted,
3191
2917
  active_cycle_exists: assertActiveCycleExists,
3192
2918
  active_cycle_user_check_passed: assertActiveCycleUserCheckPassed,
2919
+ user_check_passed: assertUserCheckPassed,
3193
2920
  blueprint_update_satisfied: assertBlueprintUpdateSatisfied,
3194
2921
  deep_ui_check_satisfied: assertDeepUiCheckSatisfied,
3195
2922
  dependencies_installed: assertDependenciesInstalled,
3196
- final_report_exists: assertFinalReportExists,
2923
+ pull_request_file_exists: assertPullRequestFileExists,
3197
2924
  git_current_branch: (paths) => assertGitCurrentBranch(paths.targetRoot),
3198
2925
  git_repository: (paths) => assertGitRepository(paths.targetRoot),
3199
2926
  github_auth: (paths) => assertGhAuth(paths.targetRoot),
3200
2927
  github_origin: (paths) => assertGithubOrigin(paths.targetRoot),
3201
- issue_metadata_exists: assertIssueMetadataExists,
3202
2928
  issue_text_exists: assertIssueTextExists,
3203
2929
  issue_url_exists: assertIssueUrlExists,
3204
2930
  automated_checks_passed: assertAutomatedChecksPassed,
3205
- issue_details_exists: assertIssueDetailsExists,
3206
2931
  main_checkout_sync_satisfied: assertMainCheckoutSyncSatisfied,
3207
- plan_text_exists: assertPlanTextExists,
3208
2932
  pr_url_exists: assertPrUrlExists,
3209
2933
  ready_jskit_app: assertReadyJskitApp,
3210
2934
  session_exists: assertSessionExists,
@@ -3220,10 +2944,171 @@ async function runNamedPreconditions(paths, names = []) {
3220
2944
  );
3221
2945
  }
3222
2946
 
3223
- async function runSessionStep({
2947
+ function sessionStepError(paths, {
2948
+ code,
2949
+ message,
2950
+ repairCommand = ""
2951
+ } = {}) {
2952
+ return buildSessionResponse(paths, {
2953
+ ok: false,
2954
+ errors: [
2955
+ createError({
2956
+ code,
2957
+ message,
2958
+ repairCommand
2959
+ })
2960
+ ]
2961
+ });
2962
+ }
2963
+
2964
+ async function createIssueFileAction(paths, options = {}, context = {}) {
2965
+ void options;
2966
+ const artifacts = await readSessionArtifacts(paths);
2967
+ if (artifacts.nextStep === "issue_prompt_rendered") {
2968
+ if (!artifacts.issueDefinitionRequested) {
2969
+ return sessionStepError(paths, {
2970
+ code: "issue_prompt_missing",
2971
+ message: "Cannot create the issue-file prompt until the issue-definition prompt has been created.",
2972
+ repairCommand: `jskit session ${paths.sessionId} define_issue --prompt "<what should change>"`
2973
+ });
2974
+ }
2975
+ await writeStepRecord(paths, "issue_prompt_rendered", "Issue scoped in Codex terminal.");
2976
+ await markStatus(paths, SESSION_STATUS.RUNNING);
2977
+ }
2978
+ return renderIssueFilePrompt(paths, context);
2979
+ }
2980
+
2981
+ async function createGithubIssueAction(paths, options = {}, context = {}) {
2982
+ const issueText = await readTrimmedFile(path.join(paths.sessionRoot, "issue.md"));
2983
+ if (!issueText) {
2984
+ return sessionStepError(paths, {
2985
+ code: "issue_file_missing",
2986
+ message: "Cannot create the GitHub issue until issue.md exists.",
2987
+ repairCommand: `jskit session ${paths.sessionId} create_issue_file`
2988
+ });
2989
+ }
2990
+ return submitIssue(paths, options, context);
2991
+ }
2992
+
2993
+ const STEP_ACTION_RUNNERS = Object.freeze({
2994
+ worktree_created: Object.freeze({
2995
+ create_worktree: createWorktree
2996
+ }),
2997
+ dependencies_installed: Object.freeze({
2998
+ run_npm_install: installDependencies
2999
+ }),
3000
+ issue_prompt_rendered: Object.freeze({
3001
+ create_issue_file: createIssueFileAction,
3002
+ define_issue: renderIssuePrompt
3003
+ }),
3004
+ issue_created: Object.freeze({
3005
+ create_issue_file: createIssueFileAction
3006
+ }),
3007
+ issue_submitted: Object.freeze({
3008
+ create_issue_on_gh: createGithubIssueAction
3009
+ }),
3010
+ plan_made: Object.freeze({
3011
+ make_plan: makePlan
3012
+ }),
3013
+ plan_executed: Object.freeze({
3014
+ execute_plan: renderPlanExecutionPrompt
3015
+ }),
3016
+ deep_ui_check_run: Object.freeze({
3017
+ run_deep_ui_check: (paths, options, context) => runDeepUiCheck(paths, {
3018
+ phase: "pre_review",
3019
+ stepId: "deep_ui_check_run"
3020
+ }, options, context)
3021
+ }),
3022
+ review_prompt_rendered: Object.freeze({
3023
+ resolve_deslop: (paths, _options, context) => renderResolveDeslopPrompt(paths, context)
3024
+ }),
3025
+ review_changes_accepted: Object.freeze({
3026
+ resolve_deslop: (paths, _options, context) => renderResolveDeslopPrompt(paths, context)
3027
+ }),
3028
+ automated_checks_run: Object.freeze({
3029
+ run_automated_checks: (paths, options, context) => runAutomatedChecks(paths, {
3030
+ stepId: "automated_checks_run"
3031
+ }, options, context)
3032
+ }),
3033
+ blueprint_updated: Object.freeze({
3034
+ update_blueprint: updateBlueprint
3035
+ }),
3036
+ changes_committed: Object.freeze({
3037
+ commit_changes: commitAcceptedChanges
3038
+ }),
3039
+ final_report_created: Object.freeze({
3040
+ create_pull_request_file: createPullRequestFile
3041
+ }),
3042
+ pr_created: Object.freeze({
3043
+ create_pr_on_gh: createPr
3044
+ }),
3045
+ pr_merge_prepared: Object.freeze({
3046
+ merge_pr: (paths, options, context) => finalizePr(paths, {
3047
+ ...options,
3048
+ mergePr: true
3049
+ }, context),
3050
+ prepare_for_merge: preparePrMerge
3051
+ }),
3052
+ main_checkout_synced: Object.freeze({
3053
+ sync_main_checkout: syncMainCheckout
3054
+ }),
3055
+ session_finished: Object.freeze({
3056
+ finish_session: finishSession
3057
+ })
3058
+ });
3059
+
3060
+ async function runSessionStepAction({
3224
3061
  targetRoot = process.cwd(),
3225
3062
  sessionId,
3063
+ action,
3226
3064
  options = {}
3065
+ } = {}) {
3066
+ return withExistingSession({ targetRoot, sessionId }, async (paths) => {
3067
+ const artifacts = await readSessionArtifacts(paths);
3068
+ if (artifacts.status === SESSION_STATUS.FINISHED || artifacts.status === SESSION_STATUS.ABANDONED) {
3069
+ return buildSessionResponse(paths, {
3070
+ ok: true,
3071
+ status: artifacts.status
3072
+ });
3073
+ }
3074
+ if (artifacts.workflowVersion !== SESSION_WORKFLOW_VERSION) {
3075
+ return buildSessionResponse(paths, {
3076
+ ok: false,
3077
+ errors: [
3078
+ createError({
3079
+ code: "unsupported_workflow_version",
3080
+ message: `Session ${paths.sessionId} uses workflow version ${artifacts.workflowVersion || "unknown"}, but this JSKIT runtime expects ${SESSION_WORKFLOW_VERSION}.`
3081
+ })
3082
+ ],
3083
+ status: SESSION_STATUS.BLOCKED
3084
+ });
3085
+ }
3086
+ const nextStep = artifacts.nextStep;
3087
+ const runner = STEP_ACTION_RUNNERS[nextStep]?.[normalizeText(action)];
3088
+ if (typeof runner !== "function") {
3089
+ return sessionStepError(paths, {
3090
+ code: "session_action_not_available",
3091
+ message: `Action ${normalizeText(action) || "(missing)"} is not available while the current step is ${nextStep || "complete"}.`,
3092
+ repairCommand: `jskit session ${paths.sessionId}`
3093
+ });
3094
+ }
3095
+ const stepPreconditions = await runNamedPreconditions(paths, STEP_PRECONDITION_NAMES[nextStep] || ["session_exists"]);
3096
+ if (!stepPreconditions.ok) {
3097
+ return sessionStepError(paths, {
3098
+ ...stepPreconditions.error,
3099
+ repairCommand: stepPreconditions.error?.repairCommand || `jskit session ${paths.sessionId}`
3100
+ });
3101
+ }
3102
+ return runner(paths, options, {
3103
+ completeStep: false,
3104
+ preconditions: stepPreconditions.preconditions
3105
+ });
3106
+ });
3107
+ }
3108
+
3109
+ async function advanceSessionStep({
3110
+ targetRoot = process.cwd(),
3111
+ sessionId
3227
3112
  } = {}) {
3228
3113
  return withExistingSession({ targetRoot, sessionId }, async (paths) => {
3229
3114
  const artifacts = await readSessionArtifacts(paths);
@@ -3249,13 +3134,401 @@ async function runSessionStep({
3249
3134
  if (!nextStep) {
3250
3135
  return finishSession(paths);
3251
3136
  }
3252
- if (nextStep === "session_created") {
3253
- return failSession(paths, {
3254
- code: "session_not_initialized",
3255
- message: "Session exists but is missing its creation receipt.",
3256
- repairCommand: "jskit session create"
3137
+ if (nextStep === "worktree_created") {
3138
+ if (!await hasWorktree(paths)) {
3139
+ return sessionStepError(paths, {
3140
+ code: "worktree_not_created",
3141
+ message: "Cannot move to the next step until the session worktree exists.",
3142
+ repairCommand: `jskit session ${paths.sessionId} create_worktree`
3143
+ });
3144
+ }
3145
+ await writeStepRecord(paths, "worktree_created", `Session worktree is ready at ${paths.worktree}.`);
3146
+ await markStatus(paths, SESSION_STATUS.RUNNING);
3147
+ return buildSessionResponse(paths);
3148
+ }
3149
+ if (nextStep === "dependencies_installed") {
3150
+ const installResult = await readTextIfExists(path.join(paths.sessionRoot, DEPENDENCIES_INSTALL_RESULT_FILE));
3151
+ if (!installResult.trim()) {
3152
+ return sessionStepError(paths, {
3153
+ code: "dependencies_not_installed",
3154
+ message: "Cannot move to the next step until dependencies have been installed in the session worktree.",
3155
+ repairCommand: `jskit session ${paths.sessionId} run_npm_install`
3156
+ });
3157
+ }
3158
+ return recordDependenciesInstalled(paths, {
3159
+ message: installResult.trim()
3160
+ });
3161
+ }
3162
+ if (nextStep === "issue_prompt_rendered") {
3163
+ if (!artifacts.issueDefinitionRequested) {
3164
+ return sessionStepError(paths, {
3165
+ code: "issue_prompt_missing",
3166
+ message: "Cannot move to the next step until the issue-definition prompt has been created.",
3167
+ repairCommand: `jskit session ${paths.sessionId} define_issue --prompt "<what should change>"`
3168
+ });
3169
+ }
3170
+ if (!artifacts.issueText) {
3171
+ return sessionStepError(paths, {
3172
+ code: "issue_file_missing",
3173
+ message: "Cannot move to the next step until issue.md exists.",
3174
+ repairCommand: `jskit session ${paths.sessionId} create_issue_file`
3175
+ });
3176
+ }
3177
+ await writeStepRecord(paths, "issue_prompt_rendered", "Issue scoped in Codex terminal.");
3178
+ await writeStepRecord(paths, "issue_created", "Issue files are ready for review and submission.");
3179
+ await markStatus(paths, SESSION_STATUS.RUNNING);
3180
+ return buildSessionResponse(paths);
3181
+ }
3182
+ if (nextStep === "issue_created") {
3183
+ if (!artifacts.issueText) {
3184
+ return sessionStepError(paths, {
3185
+ code: "issue_file_missing",
3186
+ message: "Cannot move to the next step until issue.md exists.",
3187
+ repairCommand: `jskit session ${paths.sessionId} create_issue_file`
3188
+ });
3189
+ }
3190
+ await writeStepRecord(paths, "issue_created", "Issue files are ready for review and submission.");
3191
+ await markStatus(paths, SESSION_STATUS.RUNNING);
3192
+ return buildSessionResponse(paths);
3193
+ }
3194
+ if (nextStep === "issue_submitted") {
3195
+ if (!artifacts.issueUrl) {
3196
+ return sessionStepError(paths, {
3197
+ code: "issue_url_missing",
3198
+ message: "Cannot move to the next step until the GitHub issue exists.",
3199
+ repairCommand: `jskit session ${paths.sessionId} create_issue_on_gh`
3200
+ });
3201
+ }
3202
+ await writeIssueMetadataFiles(paths, {
3203
+ issueTitle: artifacts.issueTitle || titleFromIssue(artifacts.issueText),
3204
+ issueUrl: artifacts.issueUrl
3205
+ });
3206
+ await writeStepRecord(paths, "issue_submitted", `Created GitHub issue ${artifacts.issueUrl}.`);
3207
+ await markStatus(paths, SESSION_STATUS.RUNNING);
3208
+ return buildSessionResponse(paths);
3209
+ }
3210
+ if (nextStep === "plan_made") {
3211
+ if (!artifacts.makePlanRequested) {
3212
+ return sessionStepError(paths, {
3213
+ code: "session_step_not_ready",
3214
+ message: "Current step plan_made is not ready to advance.",
3215
+ repairCommand: `jskit session ${paths.sessionId} make_plan`
3216
+ });
3217
+ }
3218
+ const stepPreconditions = await runNamedPreconditions(paths, STEP_PRECONDITION_NAMES[nextStep] || ["session_exists"]);
3219
+ if (!stepPreconditions.ok) {
3220
+ return sessionStepError(paths, {
3221
+ ...stepPreconditions.error,
3222
+ repairCommand: stepPreconditions.error?.repairCommand || `jskit session ${paths.sessionId}`
3223
+ });
3224
+ }
3225
+ return makePlan(paths, {}, {
3226
+ preconditions: stepPreconditions.preconditions
3227
+ });
3228
+ }
3229
+ if (nextStep === "plan_executed") {
3230
+ if (!artifacts.executePlanRequested) {
3231
+ return sessionStepError(paths, {
3232
+ code: "session_step_not_ready",
3233
+ message: "Current step plan_executed is not ready to advance.",
3234
+ repairCommand: `jskit session ${paths.sessionId} execute_plan`
3235
+ });
3236
+ }
3237
+ const stepPreconditions = await runNamedPreconditions(paths, STEP_PRECONDITION_NAMES[nextStep] || ["session_exists"]);
3238
+ if (!stepPreconditions.ok) {
3239
+ return sessionStepError(paths, {
3240
+ ...stepPreconditions.error,
3241
+ repairCommand: stepPreconditions.error?.repairCommand || `jskit session ${paths.sessionId}`
3242
+ });
3243
+ }
3244
+ return renderPlanExecutionPrompt(paths, {}, {
3245
+ preconditions: stepPreconditions.preconditions
3246
+ });
3247
+ }
3248
+ if (nextStep === "deep_ui_check_run") {
3249
+ const stepPreconditions = await runNamedPreconditions(paths, STEP_PRECONDITION_NAMES[nextStep] || ["session_exists"]);
3250
+ if (!stepPreconditions.ok) {
3251
+ return sessionStepError(paths, {
3252
+ ...stepPreconditions.error,
3253
+ repairCommand: stepPreconditions.error?.repairCommand || `jskit session ${paths.sessionId}`
3254
+ });
3255
+ }
3256
+ const deepUiCheckPrompted = (artifacts.uiChecks || []).some((entry) => {
3257
+ return normalizeText(entry?.stepId) === "deep_ui_check_run" &&
3258
+ normalizeText(entry?.status) === "prompted";
3259
+ });
3260
+ await writeStepRecord(
3261
+ paths,
3262
+ "deep_ui_check_run",
3263
+ deepUiCheckPrompted ? "Run deep UI check completed by Codex." : "Deep UI check skipped."
3264
+ );
3265
+ await markStatus(paths, SESSION_STATUS.RUNNING);
3266
+ return buildSessionResponse(paths, {
3267
+ preconditions: stepPreconditions.preconditions
3268
+ });
3269
+ }
3270
+ if (nextStep === "review_prompt_rendered") {
3271
+ const stepPreconditions = await runNamedPreconditions(paths, STEP_PRECONDITION_NAMES[nextStep] || ["session_exists"]);
3272
+ if (!stepPreconditions.ok) {
3273
+ return sessionStepError(paths, {
3274
+ ...stepPreconditions.error,
3275
+ repairCommand: stepPreconditions.error?.repairCommand || `jskit session ${paths.sessionId}`
3276
+ });
3277
+ }
3278
+ const reviewPrompted = (artifacts.reviewPasses || []).some((entry) => {
3279
+ return normalizeText(entry?.status) === "prompted";
3280
+ });
3281
+ await writeStepRecord(
3282
+ paths,
3283
+ "review_prompt_rendered",
3284
+ reviewPrompted ? "Review/deslop completed by Codex." : "Review/deslop skipped."
3285
+ );
3286
+ await writeStepRecord(
3287
+ paths,
3288
+ "review_changes_accepted",
3289
+ reviewPrompted ? "Review/deslop accepted." : "No review/deslop pass was requested."
3290
+ );
3291
+ await markStatus(paths, SESSION_STATUS.RUNNING);
3292
+ return buildSessionResponse(paths, {
3293
+ preconditions: stepPreconditions.preconditions
3257
3294
  });
3258
3295
  }
3296
+ if (nextStep === "automated_checks_run") {
3297
+ const stepPreconditions = await runNamedPreconditions(paths, STEP_PRECONDITION_NAMES[nextStep] || ["session_exists"]);
3298
+ if (!stepPreconditions.ok) {
3299
+ return sessionStepError(paths, {
3300
+ ...stepPreconditions.error,
3301
+ repairCommand: stepPreconditions.error?.repairCommand || `jskit session ${paths.sessionId}`
3302
+ });
3303
+ }
3304
+ const [command, args] = await doctorCommandForWorktree(paths.worktree);
3305
+ const checkCommand = [command, ...args].join(" ");
3306
+ const automatedChecksPrompted = (artifacts.checks || []).some((entry) => {
3307
+ return normalizeText(entry?.stepId) === "automated_checks_run" &&
3308
+ normalizeText(entry?.status) === "prompted";
3309
+ });
3310
+ if (automatedChecksPrompted) {
3311
+ const checksRoot = path.join(paths.sessionRoot, "checks");
3312
+ await mkdir(checksRoot, { recursive: true });
3313
+ await writeTextFile(
3314
+ path.join(checksRoot, "automated_checks_run.json"),
3315
+ `${JSON.stringify({
3316
+ command: checkCommand,
3317
+ ok: true,
3318
+ status: "completed_by_codex",
3319
+ stepId: "automated_checks_run"
3320
+ }, null, 2)}\n`
3321
+ );
3322
+ }
3323
+ await writeStepRecord(
3324
+ paths,
3325
+ "automated_checks_run",
3326
+ automatedChecksPrompted ? `Run automated checks completed by Codex: ${checkCommand}.` : "Automated checks skipped."
3327
+ );
3328
+ await markStatus(paths, SESSION_STATUS.RUNNING);
3329
+ return buildSessionResponse(paths, {
3330
+ preconditions: stepPreconditions.preconditions
3331
+ });
3332
+ }
3333
+ if (nextStep === "blueprint_updated") {
3334
+ const stepPreconditions = await runNamedPreconditions(paths, STEP_PRECONDITION_NAMES[nextStep] || ["session_exists"]);
3335
+ if (!stepPreconditions.ok) {
3336
+ return sessionStepError(paths, {
3337
+ ...stepPreconditions.error,
3338
+ repairCommand: stepPreconditions.error?.repairCommand || `jskit session ${paths.sessionId}`
3339
+ });
3340
+ }
3341
+ await writeStepRecord(paths, "blueprint_updated", "Blueprint update step completed.");
3342
+ await markStatus(paths, SESSION_STATUS.RUNNING);
3343
+ return buildSessionResponse(paths, {
3344
+ preconditions: stepPreconditions.preconditions,
3345
+ status: SESSION_STATUS.RUNNING
3346
+ });
3347
+ }
3348
+ if (nextStep === "changes_committed") {
3349
+ const stepPreconditions = await runNamedPreconditions(paths, STEP_PRECONDITION_NAMES[nextStep] || ["session_exists"]);
3350
+ if (!stepPreconditions.ok) {
3351
+ return sessionStepError(paths, {
3352
+ ...stepPreconditions.error,
3353
+ repairCommand: stepPreconditions.error?.repairCommand || `jskit session ${paths.sessionId}`
3354
+ });
3355
+ }
3356
+ const commitInfo = await readAcceptedChangesCommit(paths);
3357
+ if (!commitInfo?.commit) {
3358
+ return sessionStepError(paths, {
3359
+ code: "changes_not_committed",
3360
+ message: "Cannot move to the next step until accepted changes have been committed.",
3361
+ repairCommand: `jskit session ${paths.sessionId} commit_changes`
3362
+ });
3363
+ }
3364
+ const warnings = [];
3365
+ if (commitInfo.noChanges === true) {
3366
+ warnings.push({
3367
+ code: "accepted_changes_noop",
3368
+ message: "No accepted worktree changes were found; continuing without a new commit."
3369
+ });
3370
+ }
3371
+ await writeStepRecord(
3372
+ paths,
3373
+ "changes_committed",
3374
+ commitInfo.noChanges === true
3375
+ ? "No accepted worktree changes were found; continued without a new commit."
3376
+ : `Committed accepted changes at ${commitInfo.commit || "unknown"}.`
3377
+ );
3378
+ await markStatus(paths, SESSION_STATUS.RUNNING);
3379
+ return buildSessionResponse(paths, {
3380
+ preconditions: stepPreconditions.preconditions,
3381
+ warnings
3382
+ });
3383
+ }
3384
+ if (nextStep === "final_report_created") {
3385
+ const pullRequestText = await readTrimmedFile(path.join(paths.sessionRoot, "pull_request.md"));
3386
+ if (!pullRequestText) {
3387
+ return sessionStepError(paths, {
3388
+ code: "pull_request_file_missing",
3389
+ message: "Cannot move to the next step until pull_request.md exists.",
3390
+ repairCommand: `jskit session ${paths.sessionId} create_pull_request_file`
3391
+ });
3392
+ }
3393
+ const stepPreconditions = await runNamedPreconditions(paths, STEP_PRECONDITION_NAMES[nextStep] || ["session_exists"]);
3394
+ if (!stepPreconditions.ok) {
3395
+ return sessionStepError(paths, {
3396
+ ...stepPreconditions.error,
3397
+ repairCommand: stepPreconditions.error?.repairCommand || `jskit session ${paths.sessionId}`
3398
+ });
3399
+ }
3400
+ await writeStepRecord(paths, "final_report_created", "Pull request file is ready for review and submission.");
3401
+ await markStatus(paths, SESSION_STATUS.RUNNING);
3402
+ return buildSessionResponse(paths, {
3403
+ preconditions: stepPreconditions.preconditions
3404
+ });
3405
+ }
3406
+ if (nextStep === "pr_created") {
3407
+ const stepPreconditions = await runNamedPreconditions(paths, STEP_PRECONDITION_NAMES[nextStep] || ["session_exists"]);
3408
+ if (!stepPreconditions.ok) {
3409
+ return sessionStepError(paths, {
3410
+ ...stepPreconditions.error,
3411
+ repairCommand: stepPreconditions.error?.repairCommand || `jskit session ${paths.sessionId}`
3412
+ });
3413
+ }
3414
+ await writeStepRecord(
3415
+ paths,
3416
+ "pr_created",
3417
+ artifacts.prUrl
3418
+ ? `Created GitHub pull request ${artifacts.prUrl}.`
3419
+ : "Continued without creating a GitHub pull request."
3420
+ );
3421
+ await markStatus(paths, SESSION_STATUS.RUNNING);
3422
+ return buildSessionResponse(paths, {
3423
+ preconditions: stepPreconditions.preconditions
3424
+ });
3425
+ }
3426
+ if (nextStep === "pr_merge_prepared") {
3427
+ const stepPreconditions = await runNamedPreconditions(paths, STEP_PRECONDITION_NAMES[nextStep] || ["session_exists"]);
3428
+ if (!stepPreconditions.ok) {
3429
+ return sessionStepError(paths, {
3430
+ ...stepPreconditions.error,
3431
+ repairCommand: stepPreconditions.error?.repairCommand || `jskit session ${paths.sessionId}`
3432
+ });
3433
+ }
3434
+ let prOutcome = artifacts.prOutcome || parseJsonObject(await readTextIfExists(path.join(paths.sessionRoot, "pr_outcome.json")));
3435
+ if (!prOutcome?.outcome) {
3436
+ prOutcome = {
3437
+ outcome: "skipped",
3438
+ prUrl: artifacts.prUrl || await readTrimmedFile(path.join(paths.sessionRoot, "pr_url")),
3439
+ reason: "User continued without merging the pull request."
3440
+ };
3441
+ await writePrOutcome(paths, prOutcome);
3442
+ }
3443
+ await writeStepRecord(
3444
+ paths,
3445
+ "pr_merge_prepared",
3446
+ prOutcome.outcome === "merged"
3447
+ ? `Merged PR ${prOutcome.prUrl || artifacts.prUrl || "unknown"}.`
3448
+ : `Merge PR skipped: ${prOutcome.reason || `PR outcome is ${prOutcome.outcome}.`}`
3449
+ );
3450
+ await markStatus(paths, SESSION_STATUS.RUNNING);
3451
+ return buildSessionResponse(paths, {
3452
+ preconditions: stepPreconditions.preconditions
3453
+ });
3454
+ }
3455
+ if (nextStep === "main_checkout_synced") {
3456
+ const stepPreconditions = await runNamedPreconditions(paths, STEP_PRECONDITION_NAMES[nextStep] || ["session_exists"]);
3457
+ if (!stepPreconditions.ok) {
3458
+ return sessionStepError(paths, {
3459
+ ...stepPreconditions.error,
3460
+ repairCommand: stepPreconditions.error?.repairCommand || `jskit session ${paths.sessionId}`
3461
+ });
3462
+ }
3463
+ let mainCheckoutSync = artifacts.mainCheckoutSync || parseJsonObject(await readTextIfExists(path.join(paths.sessionRoot, "main_checkout_sync.json")));
3464
+ if (!mainCheckoutSync?.status) {
3465
+ const prOutcome = artifacts.prOutcome || parseJsonObject(await readTextIfExists(path.join(paths.sessionRoot, "pr_outcome.json"))) || {};
3466
+ const reason = prOutcome.outcome === "merged"
3467
+ ? "User skipped main checkout sync."
3468
+ : prOutcome.outcome
3469
+ ? `PR outcome is ${prOutcome.outcome}; no main checkout sync is required.`
3470
+ : "The pull request was not merged; no main checkout sync is required.";
3471
+ mainCheckoutSync = {
3472
+ branch: prOutcome.baseBranch || "",
3473
+ outcome: prOutcome.outcome || "skipped",
3474
+ reason,
3475
+ status: "skipped"
3476
+ };
3477
+ await writeMainCheckoutSync(paths, mainCheckoutSync);
3478
+ }
3479
+ const message = mainCheckoutSync.status === "synced"
3480
+ ? `Fast-forwarded target checkout branch ${mainCheckoutSync.branch || "unknown"}.`
3481
+ : `Main checkout sync skipped: ${mainCheckoutSync.reason || "No sync was required."}`;
3482
+ await writeStepRecord(paths, "main_checkout_synced", message);
3483
+ await markStatus(paths, SESSION_STATUS.RUNNING);
3484
+ return buildSessionResponse(paths, {
3485
+ preconditions: stepPreconditions.preconditions
3486
+ });
3487
+ }
3488
+ if (nextStep === "session_finished") {
3489
+ return sessionStepError(paths, {
3490
+ code: "finish_session_required",
3491
+ message: "Use the Finish action to complete and archive the session.",
3492
+ repairCommand: `jskit session ${paths.sessionId} finish_session`
3493
+ });
3494
+ }
3495
+ return sessionStepError(paths, {
3496
+ code: "session_step_not_ready",
3497
+ message: `Current step ${nextStep} is not ready to advance.`,
3498
+ repairCommand: `jskit session ${paths.sessionId}`
3499
+ });
3500
+ });
3501
+ }
3502
+
3503
+ async function runSessionStep({
3504
+ targetRoot = process.cwd(),
3505
+ sessionId,
3506
+ options = {}
3507
+ } = {}) {
3508
+ return withExistingSession({ targetRoot, sessionId }, async (paths) => {
3509
+ const artifacts = await readSessionArtifacts(paths);
3510
+ if (artifacts.status === SESSION_STATUS.FINISHED || artifacts.status === SESSION_STATUS.ABANDONED) {
3511
+ return buildSessionResponse(paths, {
3512
+ ok: true,
3513
+ status: artifacts.status
3514
+ });
3515
+ }
3516
+ if (artifacts.workflowVersion !== SESSION_WORKFLOW_VERSION) {
3517
+ return buildSessionResponse(paths, {
3518
+ ok: false,
3519
+ errors: [
3520
+ createError({
3521
+ code: "unsupported_workflow_version",
3522
+ message: `Session ${paths.sessionId} uses workflow version ${artifacts.workflowVersion || "unknown"}, but this JSKIT runtime expects ${SESSION_WORKFLOW_VERSION}.`
3523
+ })
3524
+ ],
3525
+ status: SESSION_STATUS.BLOCKED
3526
+ });
3527
+ }
3528
+ const nextStep = artifacts.nextStep;
3529
+ if (!nextStep) {
3530
+ return finishSession(paths);
3531
+ }
3259
3532
  const runner = STEP_RUNNERS[nextStep];
3260
3533
  if (typeof runner !== "function") {
3261
3534
  return failSession(paths, {
@@ -3264,6 +3537,9 @@ async function runSessionStep({
3264
3537
  status: SESSION_STATUS.FAILED
3265
3538
  });
3266
3539
  }
3540
+ if (skipStepRequested(options)) {
3541
+ return skipCurrentStep(paths, nextStep, options);
3542
+ }
3267
3543
  const stepPreconditions = await runNamedPreconditions(paths, STEP_PRECONDITION_NAMES[nextStep] || ["session_exists"]);
3268
3544
  if (!stepPreconditions.ok) {
3269
3545
  return failSession(paths, {
@@ -3271,7 +3547,6 @@ async function runSessionStep({
3271
3547
  preconditions: stepPreconditions.preconditions
3272
3548
  });
3273
3549
  }
3274
- await appendAgentDecisionsInput(paths, options);
3275
3550
  return runner(paths, options, {
3276
3551
  preconditions: stepPreconditions.preconditions
3277
3552
  });
@@ -3312,7 +3587,7 @@ async function abandonSession({
3312
3587
  }
3313
3588
  await writeTextFile(
3314
3589
  path.join(paths.sessionRoot, "steps", "abandoned"),
3315
- `${timestampForReceipt()}\nAbandoned session ${paths.sessionId}.`
3590
+ `${timestampForStepRecord()}\nAbandoned session ${paths.sessionId}.`
3316
3591
  );
3317
3592
  await markStatus(paths, SESSION_STATUS.ABANDONED);
3318
3593
  await markCurrentStep(paths, "");
@@ -3361,6 +3636,7 @@ export {
3361
3636
  STEP_IDS,
3362
3637
  STEP_PRECONDITION_NAMES,
3363
3638
  abandonSession,
3639
+ advanceSessionStep,
3364
3640
  adoptDependenciesInstalled,
3365
3641
  adoptCodexThreadId,
3366
3642
  buildSessionResponse,
@@ -3369,8 +3645,6 @@ export {
3369
3645
  createSessionId,
3370
3646
  extractIssueTitle,
3371
3647
  extractIssueText,
3372
- extractIssueDetails,
3373
- extractPlanText,
3374
3648
  inspectSession,
3375
3649
  inspectSessionDiff,
3376
3650
  inspectSessionDetails,
@@ -3380,5 +3654,6 @@ export {
3380
3654
  recordDependenciesInstalled,
3381
3655
  rewindSession,
3382
3656
  resolveSessionPaths,
3383
- runSessionStep
3657
+ runSessionStep,
3658
+ runSessionStepAction
3384
3659
  };