@jskit-ai/jskit-cli 0.2.88 → 0.2.90

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 +130 -353
  6. package/src/server/sessionRuntime/io.js +2 -2
  7. package/src/server/sessionRuntime/preconditions.js +40 -144
  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 +504 -295
  23. package/src/server/sessionRuntime.js +1300 -961
  24. package/src/server/sessionRuntime/prompts/issue_details.md +0 -49
  25. package/src/server/sessionRuntime/prompts/new_issue.md +0 -46
@@ -1,3 +1,6 @@
1
+ import {
2
+ createHash
3
+ } from "node:crypto";
1
4
  import {
2
5
  appendFile,
3
6
  mkdir,
@@ -10,13 +13,17 @@ import path from "node:path";
10
13
  import {
11
14
  BLUEPRINT_CODEX_HANDOFF,
12
15
  AUTOMATED_CHECK_REPAIR_CODEX_HANDOFF,
13
- CYCLE_STEP_IDS,
16
+ DEPENDENCIES_INSTALL_RESULT_FILE,
14
17
  DEEP_UI_CHECK_CODEX_HANDOFF,
15
- ISSUE_DETAILS_CODEX_HANDOFF,
18
+ ISSUE_DEFINITION_CODEX_HANDOFF,
19
+ ISSUE_FILE_CODEX_HANDOFF,
20
+ PLAN_CODEX_HANDOFF,
16
21
  PLAN_EXECUTION_CODEX_HANDOFF,
22
+ PR_FILE_CODEX_HANDOFF,
17
23
  PR_MERGE_PREP_CODEX_HANDOFF,
18
24
  REVIEW_PASS_LIMIT,
19
25
  REVIEW_EXECUTION_CODEX_HANDOFF,
26
+ RESOLVE_DESLOP_CODEX_HANDOFF,
20
27
  SESSION_STATUS,
21
28
  SESSION_WORKFLOW_VERSION,
22
29
  STEP_DEFINITION_BY_ID,
@@ -32,7 +39,7 @@ import {
32
39
  runCommand,
33
40
  runGit,
34
41
  runGitInWorktree,
35
- timestampForReceipt,
42
+ timestampForStepRecord,
36
43
  writeTextFile
37
44
  } from "./sessionRuntime/io.js";
38
45
  import {
@@ -54,13 +61,11 @@ import {
54
61
  markStatus,
55
62
  normalizeReviewPassNumber,
56
63
  readActiveCycle,
57
- readReceiptSteps,
64
+ readStepRecords,
58
65
  readReviewPasses,
59
66
  readSessionArtifacts,
60
67
  reviewPassRoot,
61
- writeActiveCycle,
62
- writeCycleReceipt,
63
- writeReceipt
68
+ writeStepRecord
64
69
  } from "./sessionRuntime/responses.js";
65
70
  import {
66
71
  applyPreconditions,
@@ -70,22 +75,20 @@ import {
70
75
  assertBlueprintUpdateSatisfied,
71
76
  assertDeepUiCheckSatisfied,
72
77
  assertDependenciesInstalled,
73
- assertFinalReportExists,
74
78
  assertGhAuth,
75
79
  assertGitCurrentBranch,
76
80
  assertGitRepository,
77
81
  assertGithubOrigin,
78
- assertIssueMetadataExists,
79
82
  assertIssueTextExists,
80
83
  assertIssueUrlExists,
81
84
  assertAutomatedChecksPassed,
82
- assertIssueDetailsExists,
83
85
  assertMainCheckoutSyncSatisfied,
84
- assertPlanTextExists,
85
86
  assertPrUrlExists,
87
+ assertPullRequestFileExists,
86
88
  assertReadyJskitApp,
87
89
  assertSessionExists,
88
90
  assertTargetRootWritable,
91
+ assertUserCheckPassed,
89
92
  assertWorktreeExists,
90
93
  ensureStudioGitExclude,
91
94
  hasWorktree
@@ -173,15 +176,6 @@ function extractMarkedText(value = "", marker = "") {
173
176
  return normalizeText(matches.length > 0 ? matches[matches.length - 1][1] : "");
174
177
  }
175
178
 
176
- function extractMarkedField(value = "", fieldName = "") {
177
- const normalizedFieldName = normalizeText(fieldName);
178
- if (!normalizedFieldName) {
179
- return "";
180
- }
181
- const pattern = new RegExp(`^\\s*${normalizedFieldName.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&")}\\s*:\\s*(.+)$`, "imu");
182
- return normalizeText(pattern.exec(normalizeText(value))?.[1] || "");
183
- }
184
-
185
179
  function extractIssueTitle(value = "") {
186
180
  return extractMarkedText(value, "issue_title");
187
181
  }
@@ -190,100 +184,10 @@ function extractIssueText(value = "") {
190
184
  return extractMarkedText(value, "issue_text") || normalizeText(value);
191
185
  }
192
186
 
193
- function extractPlanText(value = "") {
194
- return extractMarkedText(value, "plan") || normalizeText(value);
195
- }
196
-
197
- function extractIssueDetails(value = "") {
198
- return extractMarkedText(value, "issue_details");
199
- }
200
-
201
- function extractIssueCategory(value = "") {
202
- return extractMarkedText(value, "issue_category");
203
- }
204
-
205
- function extractUiImpact(value = "") {
206
- return extractMarkedText(value, "ui_impact");
207
- }
208
-
209
- function extractAgentDecisions(value = "") {
210
- return extractMarkedText(value, "agent_decisions");
211
- }
212
-
213
- function normalizeIssueCategory(value = "") {
214
- const category = normalizeText(value).toLowerCase();
215
- return ["client", "server", "client_server", "tooling", "unknown"].includes(category)
216
- ? category
217
- : "";
218
- }
219
-
220
- function normalizeUiImpact(value = "") {
221
- const impact = normalizeText(value).toLowerCase();
222
- return ["none", "possible", "definite", "unknown"].includes(impact)
223
- ? impact
224
- : "";
225
- }
226
-
227
187
  async function writePromptArtifact(paths, fileName, prompt) {
228
188
  await writeTextFile(path.join(paths.sessionRoot, "prompts", fileName), prompt);
229
189
  }
230
190
 
231
- async function codexResultPath(paths, stepId) {
232
- if (CYCLE_STEP_IDS.includes(stepId)) {
233
- const activeCycle = await readActiveCycle(paths);
234
- return path.join(cycleRootPath(paths, activeCycle), "codex_results", `${stepId}.md`);
235
- }
236
- return path.join(paths.sessionRoot, "codex_results", `${stepId}.md`);
237
- }
238
-
239
- function codexResponseContractForStep(stepId) {
240
- const contract = STEP_DEFINITION_BY_ID[stepId]?.codex?.responseContract;
241
- return contract && typeof contract === "object" && !Array.isArray(contract) ? contract : null;
242
- }
243
-
244
- async function requireCodexStepResult(paths, stepId, result, preconditions = [], contractOverride = null) {
245
- const contract = contractOverride || codexResponseContractForStep(stepId);
246
- if (contract?.required !== true || !contract.marker) {
247
- return null;
248
- }
249
-
250
- const source = String(result || "");
251
- if (!source.trim()) {
252
- return failSession(paths, {
253
- code: "codex_result_required",
254
- 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.`,
255
- repairCommand: `jskit session ${paths.sessionId} step --codex-result -`,
256
- preconditions
257
- });
258
- }
259
-
260
- const markedResult = extractMarkedText(source, contract.marker);
261
- if (!markedResult) {
262
- return failSession(paths, {
263
- code: "codex_result_marker_missing",
264
- message: `Codex output for ${STEP_DEFINITION_BY_ID[stepId]?.label || stepId} must include [${contract.marker}]... [/${contract.marker}] before JSKIT can advance.`,
265
- repairCommand: `jskit session ${paths.sessionId} step --codex-result -`,
266
- preconditions
267
- });
268
- }
269
- const stepField = normalizeText(contract.stepField);
270
- if (stepField) {
271
- const resultStep = extractMarkedField(markedResult, stepField);
272
- if (resultStep !== stepId) {
273
- return failSession(paths, {
274
- code: "codex_result_step_mismatch",
275
- message: `Codex output for ${STEP_DEFINITION_BY_ID[stepId]?.label || stepId} must include ${stepField}: ${stepId} inside [${contract.marker}] before JSKIT can advance.`,
276
- repairCommand: `jskit session ${paths.sessionId} step --codex-result -`,
277
- preconditions
278
- });
279
- }
280
- }
281
-
282
- await writeTextFile(await codexResultPath(paths, stepId), source);
283
- await appendAgentDecisions(paths, extractAgentDecisions(source));
284
- return null;
285
- }
286
-
287
191
  function commandText(command, args = []) {
288
192
  return [command, ...args].map((part) => {
289
193
  const value = String(part || "");
@@ -297,28 +201,6 @@ function cycleRootPath(paths, cycle) {
297
201
  return path.join(paths.sessionRoot, "cycles", `cycle_${cycle}`);
298
202
  }
299
203
 
300
- function cyclePlanPath(paths, cycle) {
301
- return path.join(cycleRootPath(paths, cycle), "plan.md");
302
- }
303
-
304
- function cyclePlanPromptFileName(cycle) {
305
- return `cycle_${cycle}_plan_request.md`;
306
- }
307
-
308
- function cyclePlanExecutionPromptFileName(cycle) {
309
- return `cycle_${cycle}_plan_execution.md`;
310
- }
311
-
312
- async function readCurrentPlan(paths) {
313
- const activeCycle = await readActiveCycle(paths);
314
- const planPath = cyclePlanPath(paths, activeCycle);
315
- return {
316
- activeCycle,
317
- planPath,
318
- planText: await readTrimmedFile(planPath)
319
- };
320
- }
321
-
322
204
  function commandOutputSummary(output = "") {
323
205
  const normalized = normalizeText(output);
324
206
  if (normalized.length <= 1800) {
@@ -338,7 +220,7 @@ async function appendCommandLog(paths, {
338
220
  return;
339
221
  }
340
222
  const entry = {
341
- at: timestampForReceipt(),
223
+ at: timestampForStepRecord(),
342
224
  command: commandText(command, args),
343
225
  cwd,
344
226
  exitCode: Number.isInteger(result.exitCode) ? result.exitCode : null,
@@ -417,14 +299,14 @@ function packageScriptRepairCommand(paths, command, args) {
417
299
  return `cd ${paths.worktree} && ${command} ${args.join(" ")}`;
418
300
  }
419
301
 
420
- function packageScriptReceiptName(scriptName) {
302
+ function packageScriptRecordName(scriptName) {
421
303
  return normalizeText(scriptName).replace(/[^a-zA-Z0-9._-]+/gu, "_");
422
304
  }
423
305
 
424
- async function writeSessionHookReceipt(paths, scriptName, message) {
306
+ async function writeSessionHookRecord(paths, scriptName, message) {
425
307
  await writeTextFile(
426
- path.join(paths.sessionRoot, "hooks", packageScriptReceiptName(scriptName)),
427
- `${timestampForReceipt()}\n${normalizeText(message) || `${scriptName} completed.`}`
308
+ path.join(paths.sessionRoot, "hooks", packageScriptRecordName(scriptName)),
309
+ `${timestampForStepRecord()}\n${normalizeText(message) || `${scriptName} completed.`}`
428
310
  );
429
311
  }
430
312
 
@@ -462,7 +344,7 @@ async function runOptionalSessionPackageScript(paths, {
462
344
  })
463
345
  };
464
346
  }
465
- await writeSessionHookReceipt(paths, scriptName, result.output || `${scriptName} completed.`);
347
+ await writeSessionHookRecord(paths, scriptName, result.output || `${scriptName} completed.`);
466
348
  return {
467
349
  ok: true,
468
350
  ran: true,
@@ -494,29 +376,6 @@ async function runSessionProvisioningHook(paths, {
494
376
  });
495
377
  }
496
378
 
497
- async function readIssueMetadata(paths) {
498
- const source = await readTextIfExists(path.join(paths.sessionRoot, "issue_metadata.json"));
499
- if (!source) {
500
- return {};
501
- }
502
- try {
503
- const parsed = JSON.parse(source);
504
- return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
505
- } catch {
506
- return {};
507
- }
508
- }
509
-
510
- async function writeIssueMetadata(paths, metadata = {}) {
511
- const existing = await readIssueMetadata(paths);
512
- const next = {
513
- ...existing,
514
- ...metadata
515
- };
516
- await writeTextFile(path.join(paths.sessionRoot, "issue_metadata.json"), `${JSON.stringify(next, null, 2)}\n`);
517
- return next;
518
- }
519
-
520
379
  async function readGithubComments(paths) {
521
380
  const source = await readTextIfExists(path.join(paths.sessionRoot, "github_comments.json"));
522
381
  if (!source) {
@@ -561,7 +420,7 @@ async function commentOnIssueOnce(paths, {
561
420
  }
562
421
  comments[normalizedPurpose] = {
563
422
  bodyFile,
564
- commentedAt: timestampForReceipt(),
423
+ commentedAt: timestampForStepRecord(),
565
424
  issueUrl,
566
425
  purpose: normalizedPurpose
567
426
  };
@@ -572,61 +431,42 @@ async function commentOnIssueOnce(paths, {
572
431
  };
573
432
  }
574
433
 
575
- async function appendAgentDecisions(paths, decisions = "") {
576
- const normalized = normalizeText(decisions);
577
- if (!normalized) {
578
- return;
579
- }
580
- const decisionsPath = path.join(paths.sessionRoot, "agent_decisions.md");
581
- const existing = await readTextIfExists(decisionsPath);
582
- await writeTextFile(
583
- decisionsPath,
584
- `${existing}${existing && !existing.endsWith("\n") ? "\n" : ""}${normalized}\n`
585
- );
586
- }
587
-
588
- async function appendAgentDecisionsInput(paths, options = {}) {
589
- const source = normalizeText(options.agentDecisions || options["agent-decisions"]);
590
- if (!source) {
591
- return;
592
- }
593
- await appendAgentDecisions(paths, extractAgentDecisions(source) || source);
594
- }
595
-
596
- async function recordIssueInAgentDecisions(paths, issueUrl = "") {
597
- const normalizedIssueUrl = normalizeText(issueUrl);
598
- if (!normalizedIssueUrl) {
599
- return;
600
- }
601
- const decisionsPath = path.join(paths.sessionRoot, "agent_decisions.md");
602
- const existing = await readTextIfExists(decisionsPath);
603
- if (existing.includes(`Issue: ${normalizedIssueUrl}`)) {
604
- return;
605
- }
606
- await writeTextFile(
607
- decisionsPath,
608
- `${existing}${existing && !existing.endsWith("\n") ? "\n" : ""}Issue: ${normalizedIssueUrl}\n\n`
609
- );
610
- }
611
-
612
434
  function issueMetadataFromUrl(issueUrl = "") {
613
- 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());
614
436
  if (!match) {
615
437
  return {
616
438
  issueNumber: "",
439
+ issueUrl: normalizeText(issueUrl),
617
440
  owner: "",
618
441
  repository: ""
619
442
  };
620
443
  }
621
444
  return {
622
445
  issueNumber: match[3],
446
+ issueUrl: normalizeText(issueUrl),
623
447
  owner: match[1],
624
448
  repository: match[2]
625
449
  };
626
450
  }
627
451
 
628
- function nextCycleNumber(cycle = "001") {
629
- 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
+ );
630
470
  }
631
471
 
632
472
  async function createSession({
@@ -664,11 +504,9 @@ async function createSession({
664
504
  await ensureStudioGitExclude(initialPaths.targetRoot);
665
505
  await mkdir(initialPaths.sessionRoot, { recursive: true });
666
506
  await writeTextFile(path.join(initialPaths.sessionRoot, "transcript.log"), "");
667
- await writeTextFile(path.join(initialPaths.sessionRoot, "agent_decisions.md"), `# Agent Decisions\n\nSession: ${initialPaths.sessionId}\nCreated: ${now.toISOString()}\n\n`);
668
507
  await writeTextFile(path.join(initialPaths.sessionRoot, "workflow_version"), `${SESSION_WORKFLOW_VERSION}\n`);
669
- await writeActiveCycle(initialPaths, "001");
670
508
  await markStatus(initialPaths, SESSION_STATUS.PENDING);
671
- await writeReceipt(initialPaths, "session_created", `Created JSKIT Studio issue session ${initialPaths.sessionId}.`);
509
+ await markCurrentStep(initialPaths, "worktree_created");
672
510
 
673
511
  return buildSessionResponse(initialPaths, {
674
512
  ok: true,
@@ -759,8 +597,7 @@ function emptySessionDetails(response) {
759
597
  ...response,
760
598
  issueTitle: "",
761
599
  issueText: "",
762
- planText: "",
763
- receipts: [],
600
+ stepRecords: [],
764
601
  transcriptLog: ""
765
602
  };
766
603
  }
@@ -775,12 +612,10 @@ async function inspectSessionDetails({
775
612
  }
776
613
  const { paths, preconditions } = context;
777
614
  const response = await buildSessionResponse(paths, { preconditions });
778
- const { planText } = await readCurrentPlan(paths);
779
-
780
- const [issueText, issueTitle, receipts, transcriptLog] = await Promise.all([
615
+ const [issueText, issueTitle, stepRecords, transcriptLog] = await Promise.all([
781
616
  readTextIfExists(path.join(paths.sessionRoot, "issue.md")),
782
617
  readTrimmedFile(path.join(paths.sessionRoot, "issue_title")),
783
- readReceiptSteps(paths),
618
+ readStepRecords(paths),
784
619
  readTextIfExists(path.join(paths.sessionRoot, "transcript.log"))
785
620
  ]);
786
621
 
@@ -788,8 +623,7 @@ async function inspectSessionDetails({
788
623
  ...response,
789
624
  issueTitle,
790
625
  issueText: issueText.trim(),
791
- planText: planText.trim(),
792
- receipts,
626
+ stepRecords,
793
627
  transcriptLog
794
628
  };
795
629
  }
@@ -822,6 +656,7 @@ async function removeEmptyStaleWorktreeDirectory(paths) {
822
656
 
823
657
  async function createWorktree(paths, _options = {}, context = {}) {
824
658
  const preconditions = context.preconditions || [];
659
+ const completeStep = context.completeStep !== false;
825
660
  const [baseBranchResult, baseCommitResult] = await Promise.all([
826
661
  runGit(paths.targetRoot, ["branch", "--show-current"], { timeout: 15000 }),
827
662
  runGit(paths.targetRoot, ["rev-parse", "--verify", "HEAD"], { timeout: 15000 })
@@ -835,7 +670,9 @@ async function createWorktree(paths, _options = {}, context = {}) {
835
670
  if (baseCommit && !await readTrimmedFile(path.join(paths.sessionRoot, "base_commit"))) {
836
671
  await writeTextFile(path.join(paths.sessionRoot, "base_commit"), `${baseCommit}\n`);
837
672
  }
838
- 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
+ }
839
676
  await markStatus(paths, SESSION_STATUS.RUNNING);
840
677
  return buildSessionResponse(paths, {
841
678
  preconditions
@@ -870,7 +707,9 @@ async function createWorktree(paths, _options = {}, context = {}) {
870
707
  if (baseCommit) {
871
708
  await writeTextFile(path.join(paths.sessionRoot, "base_commit"), `${baseCommit}\n`);
872
709
  }
873
- 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
+ }
874
713
  await markStatus(paths, SESSION_STATUS.RUNNING);
875
714
  return buildSessionResponse(paths, {
876
715
  preconditions
@@ -881,7 +720,21 @@ async function recordDependenciesInstalled(paths, {
881
720
  message = "Installed Node dependencies in the session worktree.",
882
721
  preconditions = []
883
722
  } = {}) {
884
- 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
+ );
885
738
  await markStatus(paths, SESSION_STATUS.RUNNING);
886
739
  return buildSessionResponse(paths, {
887
740
  preconditions
@@ -946,6 +799,7 @@ async function dependencyInstallCommandForWorktree(worktree) {
946
799
 
947
800
  async function installDependencies(paths, _options = {}, context = {}) {
948
801
  const preconditions = context.preconditions || [];
802
+ const completeStep = context.completeStep !== false;
949
803
  const [command, args] = await dependencyInstallCommandForWorktree(paths.worktree);
950
804
  const result = await runLoggedCommand(paths, "dependencies_install", command, args, {
951
805
  cwd: paths.worktree,
@@ -967,7 +821,8 @@ async function installDependencies(paths, _options = {}, context = {}) {
967
821
  return provisionResult.response;
968
822
  }
969
823
  const installMessage = result.output || `Installed Node dependencies in the session worktree with ${command} ${args.join(" ")}.`;
970
- return recordDependenciesInstalled(paths, {
824
+ const recorder = completeStep ? recordDependenciesInstalled : recordDependencyInstallResult;
825
+ return recorder(paths, {
971
826
  message: provisionResult.ran ? `${installMessage}\n${SESSION_PROVISION_PACKAGE_SCRIPT} completed.` : installMessage,
972
827
  preconditions
973
828
  });
@@ -999,18 +854,39 @@ async function adoptDependenciesInstalled({
999
854
  if (!provisionResult.ok) {
1000
855
  return provisionResult.response;
1001
856
  }
1002
- const receiptMessage = provisionResult.ran
857
+ const hookMessage = provisionResult.ran
1003
858
  ? `${normalizeText(message) || "Installed Node dependencies in the session worktree."}\n${SESSION_PROVISION_PACKAGE_SCRIPT} completed.`
1004
859
  : message;
1005
- return recordDependenciesInstalled(paths, {
1006
- message: receiptMessage,
860
+ return recordDependencyInstallResult(paths, {
861
+ message: hookMessage,
1007
862
  preconditions
1008
863
  });
1009
864
  });
1010
865
  }
1011
866
 
1012
- 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 = {}) {
1013
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
+ }
1014
890
  if (!userInput) {
1015
891
  return failSession(paths, {
1016
892
  code: "prompt_required",
@@ -1018,43 +894,18 @@ async function renderIssuePrompt(paths, options = {}) {
1018
894
  repairCommand: `jskit session ${paths.sessionId} step --prompt "<what should change>"`
1019
895
  });
1020
896
  }
1021
- const prompt = await renderPrompt(paths, "new_issue.md", {
1022
- user_input: userInput
1023
- });
1024
- await writePromptArtifact(paths, "issue_draft.md", prompt);
1025
- 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");
1026
900
  await markStatus(paths, SESSION_STATUS.WAITING_FOR_USER);
1027
901
  return buildSessionResponse(paths, {
902
+ codex: ISSUE_DEFINITION_CODEX_HANDOFF,
1028
903
  ok: true,
1029
904
  prompt,
1030
905
  status: SESSION_STATUS.WAITING_FOR_USER
1031
906
  });
1032
907
  }
1033
908
 
1034
- async function draftIssue(paths, options = {}) {
1035
- const issueText = extractIssueText(options.issue);
1036
- if (!issueText) {
1037
- return failSession(paths, {
1038
- code: "issue_required",
1039
- message: "The issue drafting step requires --issue, --issue-file, or --issue -.",
1040
- repairCommand: `jskit session ${paths.sessionId} step --issue -`
1041
- });
1042
- }
1043
- const issueTitle = normalizeText(options.issueTitle) || extractIssueTitle(options.issue);
1044
- if (!issueTitle) {
1045
- return failSession(paths, {
1046
- code: "issue_title_required",
1047
- message: "The issue drafting step requires an approved issue title.",
1048
- repairCommand: `jskit session ${paths.sessionId} step --issue-title "<title>" --issue -`
1049
- });
1050
- }
1051
- await writeTextFile(path.join(paths.sessionRoot, "issue.md"), issueText);
1052
- await writeTextFile(path.join(paths.sessionRoot, "issue_title"), issueTitle);
1053
- await writeReceipt(paths, "issue_drafted", "Saved approved issue text.");
1054
- await markStatus(paths, SESSION_STATUS.RUNNING);
1055
- return buildSessionResponse(paths);
1056
- }
1057
-
1058
909
  function titleFromIssue(issueText) {
1059
910
  const firstMeaningfulLine = String(issueText || "")
1060
911
  .split(/\r?\n/u)
@@ -1065,24 +916,56 @@ function titleFromIssue(issueText) {
1065
916
 
1066
917
  async function createIssue(paths, _options = {}, context = {}) {
1067
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;
1068
936
  const existingIssueUrl = await readTrimmedFile(path.join(paths.sessionRoot, "issue_url"));
1069
937
  const issueText = await readTrimmedFile(path.join(paths.sessionRoot, "issue.md"));
1070
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
+ }
1071
946
  if (existingIssueUrl) {
1072
- await writeIssueMetadata(paths, {
1073
- ...issueMetadataFromUrl(existingIssueUrl),
1074
- issueBody: issueText,
1075
- issueBodyPath: path.join(paths.sessionRoot, "issue.md"),
947
+ await writeIssueMetadataFiles(paths, {
1076
948
  issueTitle,
1077
949
  issueUrl: existingIssueUrl
1078
950
  });
1079
- await recordIssueInAgentDecisions(paths, existingIssueUrl);
1080
- await writeReceipt(paths, "issue_created", `Reused GitHub issue ${existingIssueUrl}.`);
951
+ if (completeStep) {
952
+ await writeStepRecord(paths, "issue_submitted", `Reused GitHub issue ${existingIssueUrl}.`);
953
+ }
1081
954
  await markStatus(paths, SESSION_STATUS.RUNNING);
1082
955
  return buildSessionResponse(paths, {
1083
956
  preconditions
1084
957
  });
1085
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
+ }
1086
969
  const result = await runLoggedCommand(paths, "github_issue_create", "gh", [
1087
970
  "issue",
1088
971
  "create",
@@ -1104,40 +987,30 @@ async function createIssue(paths, _options = {}, context = {}) {
1104
987
  }
1105
988
  const issueUrl = result.stdout.split(/\r?\n/u).map((line) => line.trim()).find(Boolean) || result.stdout;
1106
989
  await writeTextFile(path.join(paths.sessionRoot, "issue_url"), issueUrl);
1107
- await writeIssueMetadata(paths, {
1108
- ...issueMetadataFromUrl(issueUrl),
1109
- issueBody: issueText,
1110
- issueBodyPath: path.join(paths.sessionRoot, "issue.md"),
990
+ await writeIssueMetadataFiles(paths, {
1111
991
  issueTitle,
1112
992
  issueUrl
1113
993
  });
1114
- await recordIssueInAgentDecisions(paths, issueUrl);
1115
- await writeReceipt(paths, "issue_created", `Created GitHub issue ${issueUrl}.`);
994
+ if (completeStep) {
995
+ await writeStepRecord(paths, "issue_submitted", `Created GitHub issue ${issueUrl}.`);
996
+ }
1116
997
  await markStatus(paths, SESSION_STATUS.RUNNING);
1117
998
  return buildSessionResponse(paths, {
1118
999
  preconditions
1119
1000
  });
1120
1001
  }
1121
1002
 
1122
- async function renderIssueDetailsPrompt(paths, _options = {}, context = {}) {
1003
+ async function renderIssueFilePrompt(paths, context = {}) {
1123
1004
  const preconditions = context.preconditions || [];
1124
- const issueText = await readTrimmedFile(path.join(paths.sessionRoot, "issue.md"));
1125
- const issueTitle = await readTrimmedFile(path.join(paths.sessionRoot, "issue_title")) || titleFromIssue(issueText);
1126
- const issueUrl = await readTrimmedFile(path.join(paths.sessionRoot, "issue_url"));
1127
- const issueNumber = issueNumberFromUrl(issueUrl);
1128
- 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", {
1129
1007
  issue_file: path.join(paths.sessionRoot, "issue.md"),
1130
- issue_number: issueNumber,
1131
- issue_text: issueText,
1132
- issue_title: issueTitle,
1133
- issue_url: issueUrl,
1134
- issue_details_file: path.join(paths.sessionRoot, "issue_details.md"),
1135
- worktree: paths.worktree
1008
+ issue_title_file: path.join(paths.sessionRoot, "issue_title")
1136
1009
  });
1137
- await writePromptArtifact(paths, "issue_details.md", prompt);
1010
+ await writeTextFile(issueFileSentinelPath, "true\n");
1138
1011
  await markStatus(paths, SESSION_STATUS.WAITING_FOR_USER);
1139
1012
  return buildSessionResponse(paths, {
1140
- codex: ISSUE_DETAILS_CODEX_HANDOFF,
1013
+ codex: ISSUE_FILE_CODEX_HANDOFF,
1141
1014
  ok: true,
1142
1015
  preconditions,
1143
1016
  prompt,
@@ -1145,145 +1018,47 @@ async function renderIssueDetailsPrompt(paths, _options = {}, context = {}) {
1145
1018
  });
1146
1019
  }
1147
1020
 
1148
- async function saveIssueDetails(paths, options = {}, context = {}) {
1149
- const preconditions = context.preconditions || [];
1150
- const source = normalizeText(options.issueDetails || options["issue-details"]);
1151
- const structuredIssueCategory = normalizeText(options.issueCategory || options["issue-category"]);
1152
- const structuredUiImpact = normalizeText(options.uiImpact || options["ui-impact"]);
1153
- const issueDetails = extractIssueDetails(source) || (
1154
- structuredIssueCategory && structuredUiImpact ? source : ""
1155
- );
1156
- if (!source && !structuredIssueCategory && !structuredUiImpact) {
1157
- return renderIssueDetailsPrompt(paths, options, context);
1158
- }
1159
- if (!issueDetails) {
1160
- return failSession(paths, {
1161
- code: "issue_details_required",
1162
- message: "The details step requires confirmed issue details from Codex or the Studio form.",
1163
- repairCommand: `jskit session ${paths.sessionId} step --issue-details -`,
1164
- preconditions
1165
- });
1166
- }
1167
-
1168
- const issueCategory = normalizeIssueCategory(structuredIssueCategory || extractIssueCategory(source));
1169
- const uiImpact = normalizeUiImpact(structuredUiImpact || extractUiImpact(source));
1170
- if (!issueCategory) {
1171
- return failSession(paths, {
1172
- code: "issue_category_invalid",
1173
- message: "Issue details must include [issue_category]client, server, client_server, tooling, or unknown[/issue_category].",
1174
- repairCommand: `jskit session ${paths.sessionId} step --issue-details -`,
1175
- preconditions
1176
- });
1177
- }
1178
- if (!uiImpact) {
1179
- return failSession(paths, {
1180
- code: "ui_impact_invalid",
1181
- message: "Issue details must include [ui_impact]none, possible, definite, or unknown[/ui_impact].",
1182
- repairCommand: `jskit session ${paths.sessionId} step --issue-details -`,
1183
- preconditions
1184
- });
1185
- }
1186
-
1187
- await writeTextFile(path.join(paths.sessionRoot, "issue_details.md"), issueDetails);
1188
- await writeIssueMetadata(paths, {
1189
- issueCategory,
1190
- uiImpact,
1191
- issueDetailsPath: path.join(paths.sessionRoot, "issue_details.md")
1192
- });
1193
-
1194
- const decisions = extractAgentDecisions(source);
1195
- await appendAgentDecisions(paths, decisions);
1196
-
1197
- const issueUrl = await readTrimmedFile(path.join(paths.sessionRoot, "issue_url"));
1198
- if (issueUrl) {
1199
- const commentResult = await commentOnIssueOnce(paths, {
1200
- bodyFile: path.join(paths.sessionRoot, "issue_details.md"),
1201
- issueUrl,
1202
- purpose: "issue_details"
1203
- });
1204
- if (!commentResult.ok) {
1205
- return failSession(paths, {
1206
- code: "issue_details_comment_failed",
1207
- message: commentResult.output || "Failed to comment the issue details on the GitHub issue.",
1208
- repairCommand: `gh issue comment ${issueUrl} --body-file ${path.join(paths.sessionRoot, "issue_details.md")}`,
1209
- preconditions
1210
- });
1211
- }
1212
- }
1213
-
1214
- await writeReceipt(paths, "issue_details_gathered", "Saved confirmed issue details and recorded the GitHub issue comment.");
1215
- await markStatus(paths, SESSION_STATUS.RUNNING);
1216
- return buildSessionResponse(paths, {
1217
- preconditions
1218
- });
1219
- }
1220
-
1221
- async function makePlan(paths, options = {}, context = {}) {
1021
+ async function makePlan(paths, _options = {}, context = {}) {
1222
1022
  const preconditions = context.preconditions || [];
1223
- const activeCycle = await readActiveCycle(paths);
1023
+ const makePlanSentinelPath = path.join(paths.sessionRoot, "metadata", "make_plan_requested");
1224
1024
  const issueText = await readTrimmedFile(path.join(paths.sessionRoot, "issue.md"));
1225
1025
  const issueTitle = await readTrimmedFile(path.join(paths.sessionRoot, "issue_title")) || titleFromIssue(issueText);
1226
1026
  const issueUrl = await readTrimmedFile(path.join(paths.sessionRoot, "issue_url"));
1227
1027
  const issueNumber = issueNumberFromUrl(issueUrl);
1228
- const issueDetails = await readTrimmedFile(path.join(paths.sessionRoot, "issue_details.md"));
1229
- const planText = extractPlanText(options.plan);
1230
- const agentDecisionsPath = path.join(paths.sessionRoot, "agent_decisions.md");
1231
- const agentDecisionsText = await readTextIfExists(agentDecisionsPath);
1232
- const currentCycleRoot = cycleRootPath(paths, activeCycle);
1233
- const planPath = cyclePlanPath(paths, activeCycle);
1234
- const reworkRequestPath = path.join(currentCycleRoot, "rework_request.md");
1235
- const reworkRequest = await readTextIfExists(reworkRequestPath);
1236
-
1237
- if (!planText) {
1238
- const prompt = await renderPrompt(paths, "plan_issue.md", {
1239
- active_cycle: activeCycle,
1240
- agent_decisions_file: agentDecisionsPath,
1241
- agent_decisions_text: agentDecisionsText,
1242
- app_blueprint_file: path.join(paths.worktree, ".jskit", "APP_BLUEPRINT.md"),
1243
- issue_file: path.join(paths.sessionRoot, "issue.md"),
1244
- issue_number: issueNumber,
1245
- issue_text: issueText,
1246
- issue_title: issueTitle,
1247
- issue_title_file: path.join(paths.sessionRoot, "issue_title"),
1248
- issue_url: issueUrl,
1249
- issue_details_file: path.join(paths.sessionRoot, "issue_details.md"),
1250
- issue_details_text: issueDetails,
1251
- plan_file: planPath,
1252
- plan_source: activeCycle === "001" ? "issue" : "rework",
1253
- rework_request: reworkRequest,
1254
- rework_request_file: reworkRequest ? reworkRequestPath : "",
1255
- worktree: paths.worktree
1256
- });
1257
- await writePromptArtifact(paths, cyclePlanPromptFileName(activeCycle), prompt);
1258
- 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);
1259
1031
  return buildSessionResponse(paths, {
1260
- ok: true,
1261
- preconditions,
1262
- prompt,
1263
- status: SESSION_STATUS.WAITING_FOR_USER
1032
+ preconditions
1264
1033
  });
1265
1034
  }
1266
1035
 
1267
- await mkdir(currentCycleRoot, { recursive: true });
1268
- await writeTextFile(planPath, planText);
1269
- await appendAgentDecisions(paths, extractAgentDecisions(options.plan));
1270
- await writeReceipt(paths, "plan_made", `Saved cycle ${activeCycle} plan.`);
1271
- 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);
1272
1048
  return buildSessionResponse(paths, {
1273
- preconditions
1049
+ codex: PLAN_CODEX_HANDOFF,
1050
+ ok: true,
1051
+ preconditions,
1052
+ prompt,
1053
+ status: SESSION_STATUS.WAITING_FOR_USER
1274
1054
  });
1275
1055
  }
1276
1056
 
1277
- async function renderPlanExecutionPrompt(paths, options = {}, context = {}) {
1057
+ async function renderPlanExecutionPrompt(paths, _options = {}, context = {}) {
1278
1058
  const preconditions = context.preconditions || [];
1279
- const activeCycle = await readActiveCycle(paths);
1280
- const executionPromptPath = path.join(paths.sessionRoot, "prompts", cyclePlanExecutionPromptFileName(activeCycle));
1281
- if (await fileExists(executionPromptPath)) {
1282
- const codexResultFailure = await requireCodexStepResult(paths, "plan_executed", options.codexResult, preconditions);
1283
- if (codexResultFailure) {
1284
- return codexResultFailure;
1285
- }
1286
- 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.");
1287
1062
  await markStatus(paths, SESSION_STATUS.RUNNING);
1288
1063
  return buildSessionResponse(paths, {
1289
1064
  preconditions
@@ -1294,22 +1069,14 @@ async function renderPlanExecutionPrompt(paths, options = {}, context = {}) {
1294
1069
  const issueTitle = await readTrimmedFile(path.join(paths.sessionRoot, "issue_title")) || titleFromIssue(issueText);
1295
1070
  const issueUrl = await readTrimmedFile(path.join(paths.sessionRoot, "issue_url"));
1296
1071
  const issueNumber = issueNumberFromUrl(issueUrl);
1297
- const { planPath, planText } = await readCurrentPlan(paths);
1298
- const issueDetailsPath = path.join(paths.sessionRoot, "issue_details.md");
1299
- const issueDetails = await readTrimmedFile(issueDetailsPath);
1300
- const executionPrompt = await renderPrompt(paths, "execute_plan.md", {
1301
- active_cycle: activeCycle,
1072
+ const executionPrompt = await renderPrompt(paths, "plan_executed.md", {
1302
1073
  issue_file: path.join(paths.sessionRoot, "issue.md"),
1303
1074
  issue_number: issueNumber,
1304
1075
  issue_title: issueTitle,
1305
1076
  issue_url: issueUrl,
1306
- issue_details_file: issueDetailsPath,
1307
- issue_details_text: issueDetails,
1308
- plan_file: planPath,
1309
- plan_text: planText,
1310
1077
  worktree: paths.worktree
1311
1078
  });
1312
- await writePromptArtifact(paths, cyclePlanExecutionPromptFileName(activeCycle), executionPrompt);
1079
+ await writeTextFile(executePlanSentinelPath, "true\n");
1313
1080
  await markStatus(paths, SESSION_STATUS.WAITING_FOR_USER);
1314
1081
  return buildSessionResponse(paths, {
1315
1082
  codex: PLAN_EXECUTION_CODEX_HANDOFF,
@@ -1440,7 +1207,6 @@ async function inspectSessionDiff({
1440
1207
  }
1441
1208
 
1442
1209
  const FIRST_REWINDABLE_STEP_ID = "dependencies_installed";
1443
- const CYCLE_REWIND_TARGET_STEP_ID = "plan_made";
1444
1210
  const REWIND_CLOSED_STATUSES = Object.freeze([
1445
1211
  SESSION_STATUS.ABANDONED,
1446
1212
  SESSION_STATUS.FINISHED
@@ -1462,7 +1228,29 @@ async function removePromptArtifact(paths, fileName) {
1462
1228
  }
1463
1229
 
1464
1230
  async function removeGlobalCodexResult(paths, stepId) {
1465
- 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
+ ]);
1466
1254
  }
1467
1255
 
1468
1256
  async function removeGithubCommentPurpose(paths, purpose) {
@@ -1478,21 +1266,6 @@ async function removeGithubCommentPurpose(paths, purpose) {
1478
1266
  await writeGithubComments(paths, comments);
1479
1267
  }
1480
1268
 
1481
- async function removeIssueDetailsMetadata(paths) {
1482
- const metadata = await readIssueMetadata(paths);
1483
- if (Object.keys(metadata).length === 0) {
1484
- return;
1485
- }
1486
- delete metadata.issueCategory;
1487
- delete metadata.issueDetailsPath;
1488
- delete metadata.uiImpact;
1489
- if (Object.keys(metadata).length === 0) {
1490
- await removeSessionRootFile(paths, "issue_metadata.json");
1491
- return;
1492
- }
1493
- await writeTextFile(path.join(paths.sessionRoot, "issue_metadata.json"), `${JSON.stringify(metadata, null, 2)}\n`);
1494
- }
1495
-
1496
1269
  async function removeCycleDirectories(paths) {
1497
1270
  for (const rootName of ["steps", "cycles"]) {
1498
1271
  const root = path.join(paths.sessionRoot, rootName);
@@ -1511,98 +1284,102 @@ async function removeCycleDirectories(paths) {
1511
1284
  }
1512
1285
  }
1513
1286
 
1514
- async function removeCyclePromptArtifacts(paths) {
1515
- const promptsRoot = path.join(paths.sessionRoot, "prompts");
1516
- let entries = [];
1517
- try {
1518
- entries = await readdir(promptsRoot, { withFileTypes: true });
1519
- } catch {
1520
- entries = [];
1521
- }
1522
- const cyclePromptPattern = /^cycle_\d+_(?:plan_request|plan_execution)\.md$/u;
1523
- const cyclePromptFiles = entries
1524
- .filter((entry) => entry.isFile() && cyclePromptPattern.test(entry.name))
1525
- .map((entry) => entry.name);
1526
- await Promise.all([
1527
- ...cyclePromptFiles.map((fileName) => removePromptArtifact(paths, fileName)),
1528
- removePromptArtifact(paths, "automated_checks_run.md"),
1529
- removePromptArtifact(paths, "deep_ui_check_run.md"),
1530
- removePromptArtifact(paths, "review.md"),
1531
- removePromptArtifact(paths, "user_check.md")
1532
- ]);
1287
+ async function removePlanArtifacts(paths) {
1288
+ await removeSessionPath(paths, "metadata", "make_plan_requested");
1533
1289
  }
1534
1290
 
1535
- async function cancelAllCycleState(paths) {
1291
+ async function removePlanExecutionArtifacts(paths) {
1536
1292
  await Promise.all([
1537
- removeCycleDirectories(paths),
1538
- removeCyclePromptArtifacts(paths),
1539
- removeSessionPath(paths, "checks"),
1540
- removeSessionPath(paths, "ui_checks"),
1541
- removeSessionPath(paths, "review_passes")
1293
+ removeSessionPath(paths, "metadata", "execute_plan_requested"),
1294
+ removeCodexResult(paths, "plan_executed")
1542
1295
  ]);
1543
- await writeActiveCycle(paths, "001");
1544
1296
  }
1545
1297
 
1546
1298
  const STEP_CANCELERS = Object.freeze({
1547
1299
  dependencies_installed: async () => {},
1548
1300
  issue_prompt_rendered: async (paths) => {
1549
- await removePromptArtifact(paths, "issue_draft.md");
1301
+ await removeSessionPath(paths, "metadata", "issue_prompt_rendered_requested");
1550
1302
  },
1551
- issue_drafted: async (paths) => {
1303
+ issue_created: async (paths) => {
1552
1304
  await Promise.all([
1305
+ removeSessionPath(paths, "metadata", "issue_created_requested"),
1553
1306
  removeSessionRootFile(paths, "issue.md"),
1554
1307
  removeSessionRootFile(paths, "issue_title")
1555
1308
  ]);
1556
1309
  },
1557
- issue_created: async (paths) => {
1310
+ issue_submitted: async (paths) => {
1558
1311
  await Promise.all([
1559
1312
  removeSessionRootFile(paths, "issue_url"),
1560
- 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")
1561
1335
  ]);
1562
1336
  },
1563
- issue_details_gathered: async (paths) => {
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")
1344
+ ]);
1345
+ },
1346
+ user_check_completed: async (paths) => {
1564
1347
  await Promise.all([
1565
- removePromptArtifact(paths, "issue_details.md"),
1566
- removeSessionRootFile(paths, "issue_details.md"),
1567
- removeGithubCommentPurpose(paths, "issue_details"),
1568
- removeIssueDetailsMetadata(paths)
1348
+ removePromptArtifact(paths, "user_check_completed"),
1349
+ removeSessionPath(paths, "steps", "user_check_failed")
1569
1350
  ]);
1570
1351
  },
1571
- plan_made: cancelAllCycleState,
1572
- plan_executed: cancelAllCycleState,
1573
- deep_ui_check_run: cancelAllCycleState,
1574
- review_prompt_rendered: cancelAllCycleState,
1575
- review_changes_accepted: cancelAllCycleState,
1576
- automated_checks_run: cancelAllCycleState,
1577
- user_check_completed: cancelAllCycleState,
1578
1352
  changes_committed: async (paths) => {
1579
1353
  await removeSessionRootFile(paths, "changes_committed.json");
1580
1354
  },
1581
1355
  blueprint_updated: async (paths) => {
1582
1356
  await Promise.all([
1583
- removePromptArtifact(paths, "update_blueprint.md"),
1584
- removeGlobalCodexResult(paths, "blueprint_updated")
1357
+ removeSessionPath(paths, "metadata", "blueprint_updated_requested"),
1358
+ removeSessionRootFile(paths, BLUEPRINT_BASELINE_FILE),
1359
+ removeCodexResult(paths, "blueprint_updated")
1585
1360
  ]);
1586
1361
  },
1587
1362
  final_report_created: async (paths) => {
1588
1363
  await Promise.all([
1364
+ removeSessionPath(paths, "metadata", "pull_request_file_requested"),
1365
+ removeSessionRootFile(paths, "pull_request.md"),
1366
+ removeSessionRootFile(paths, "final_report"),
1589
1367
  removeSessionRootFile(paths, "final_report.md"),
1590
1368
  removeGithubCommentPurpose(paths, "final_report")
1591
1369
  ]);
1592
1370
  },
1593
1371
  pr_created: async (paths) => {
1594
1372
  await Promise.all([
1595
- removePromptArtifact(paths, "pr_create_failure.md"),
1373
+ removePromptArtifact(paths, "pr_create_failure"),
1596
1374
  removeSessionRootFile(paths, "pr_body.md"),
1375
+ removeSessionRootFile(paths, "pull_request_body.md"),
1597
1376
  removeSessionRootFile(paths, "pr_url")
1598
1377
  ]);
1599
1378
  },
1600
1379
  pr_merge_prepared: async (paths) => {
1601
- await removePromptArtifact(paths, "prepare_pr_merge.md");
1602
- },
1603
- pr_finalized: async (paths) => {
1604
1380
  await Promise.all([
1605
- removePromptArtifact(paths, "pr_merge_failure.md"),
1381
+ removePromptArtifact(paths, "pr_merge_prepared"),
1382
+ removePromptArtifact(paths, "pr_merge_failure"),
1606
1383
  removeSessionRootFile(paths, "pr_base_branch"),
1607
1384
  removeSessionRootFile(paths, "pr_merge_completed"),
1608
1385
  removeSessionRootFile(paths, "pr_outcome.json")
@@ -1616,27 +1393,18 @@ const STEP_CANCELERS = Object.freeze({
1616
1393
  },
1617
1394
  session_finished: async (paths) => {
1618
1395
  await Promise.all([
1619
- removeSessionRootFile(paths, "final_comment.md")
1396
+ removeSessionRootFile(paths, "final_comment")
1620
1397
  ]);
1621
1398
  }
1622
1399
  });
1623
1400
 
1624
- function targetRequiresCycleReset(stepId) {
1625
- const targetIndex = STEP_IDS.indexOf(stepId);
1626
- const planIndex = STEP_IDS.indexOf(CYCLE_REWIND_TARGET_STEP_ID);
1627
- return targetIndex >= 0 && targetIndex <= planIndex;
1628
- }
1629
-
1630
1401
  function targetIsAllowedRewindStep(stepId) {
1631
1402
  if (!STEP_IDS.includes(stepId)) {
1632
1403
  return false;
1633
1404
  }
1634
- if (stepId === "session_created" || stepId === "worktree_created") {
1405
+ if (stepId === "worktree_created") {
1635
1406
  return false;
1636
1407
  }
1637
- if (CYCLE_STEP_IDS.includes(stepId)) {
1638
- return stepId === CYCLE_REWIND_TARGET_STEP_ID;
1639
- }
1640
1408
  return STEP_IDS.indexOf(stepId) >= STEP_IDS.indexOf(FIRST_REWINDABLE_STEP_ID);
1641
1409
  }
1642
1410
 
@@ -1645,30 +1413,28 @@ function deletedStepIdsForRewindTarget(stepId) {
1645
1413
  return targetIndex < 0 ? [] : STEP_IDS.slice(targetIndex);
1646
1414
  }
1647
1415
 
1648
- async function removeReceiptsForDeletedSteps(paths, deletedStepIds) {
1649
- await Promise.all(deletedStepIds
1650
- .filter((stepId) => !CYCLE_STEP_IDS.includes(stepId))
1651
- .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
+ ]));
1652
1430
  }
1653
1431
 
1654
- async function cancelDeletedStepArtifacts(paths, deletedStepIds, {
1655
- cycleReset = false
1656
- } = {}) {
1657
- const cancelerIds = cycleReset
1658
- ? deletedStepIds.filter((stepId) => !CYCLE_STEP_IDS.includes(stepId)).concat(CYCLE_REWIND_TARGET_STEP_ID)
1659
- : deletedStepIds;
1660
- const calledCycleCancel = new Set();
1661
- for (const stepId of cancelerIds) {
1432
+ async function cancelDeletedStepArtifacts(paths, deletedStepIds) {
1433
+ for (const stepId of deletedStepIds) {
1662
1434
  const canceler = STEP_CANCELERS[stepId];
1663
1435
  if (typeof canceler !== "function") {
1664
1436
  continue;
1665
1437
  }
1666
- if (CYCLE_STEP_IDS.includes(stepId)) {
1667
- if (calledCycleCancel.has(CYCLE_REWIND_TARGET_STEP_ID)) {
1668
- continue;
1669
- }
1670
- calledCycleCancel.add(CYCLE_REWIND_TARGET_STEP_ID);
1671
- }
1672
1438
  await canceler(paths);
1673
1439
  }
1674
1440
  }
@@ -1723,15 +1489,12 @@ async function rewindSession({
1723
1489
  }
1724
1490
 
1725
1491
  if (!targetIsAllowedRewindStep(normalizedStepId)) {
1726
- const cycleHint = CYCLE_STEP_IDS.includes(normalizedStepId)
1727
- ? " Only Plan made can be used as a cycle rewind target; it resets all cycle/rework state."
1728
- : "";
1729
1492
  return buildSessionResponse(paths, {
1730
1493
  ok: false,
1731
1494
  errors: [
1732
1495
  createError({
1733
1496
  code: "rewind_step_not_allowed",
1734
- message: `Cannot rewind session ${paths.sessionId} to ${normalizedStepId || "(missing)"}.${cycleHint}`
1497
+ message: `Cannot rewind session ${paths.sessionId} to ${normalizedStepId || "(missing)"}.`
1735
1498
  })
1736
1499
  ],
1737
1500
  status: currentStatus
@@ -1752,12 +1515,8 @@ async function rewindSession({
1752
1515
  }
1753
1516
 
1754
1517
  const deletedStepIds = deletedStepIdsForRewindTarget(normalizedStepId);
1755
- const cycleReset = targetRequiresCycleReset(normalizedStepId);
1756
- await removeReceiptsForDeletedSteps(paths, deletedStepIds);
1757
- await cancelDeletedStepArtifacts(paths, deletedStepIds, { cycleReset });
1758
- if (cycleReset) {
1759
- await writeActiveCycle(paths, "001");
1760
- }
1518
+ await removeStepRecordsForDeletedSteps(paths, deletedStepIds);
1519
+ await cancelDeletedStepArtifacts(paths, deletedStepIds);
1761
1520
  await markCurrentStep(paths, normalizedStepId);
1762
1521
  await markStatus(paths, SESSION_STATUS.PENDING);
1763
1522
  return buildSessionResponse(paths, {
@@ -1830,6 +1589,79 @@ async function changedFilesInWorktree(paths) {
1830
1589
  ]);
1831
1590
  }
1832
1591
 
1592
+ const BLUEPRINT_RELATIVE_PATH = ".jskit/APP_BLUEPRINT.md";
1593
+ const BLUEPRINT_BASELINE_FILE = "blueprint_update_baseline.json";
1594
+
1595
+ function blueprintBaselinePath(paths) {
1596
+ return path.join(paths.sessionRoot, BLUEPRINT_BASELINE_FILE);
1597
+ }
1598
+
1599
+ function isBlueprintRelativePath(filePath = "") {
1600
+ return normalizeText(filePath) === BLUEPRINT_RELATIVE_PATH;
1601
+ }
1602
+
1603
+ function nonBlueprintChangedFiles(files = []) {
1604
+ return files.filter((file) => !isBlueprintRelativePath(file));
1605
+ }
1606
+
1607
+ async function hashWorktreeFile(paths, filePath) {
1608
+ try {
1609
+ const buffer = await readFile(path.join(paths.worktree, filePath));
1610
+ return createHash("sha256").update(buffer).digest("hex");
1611
+ } catch {
1612
+ return "missing";
1613
+ }
1614
+ }
1615
+
1616
+ async function buildDirtyFileSnapshot(paths, files = []) {
1617
+ const entries = await Promise.all(nonBlueprintChangedFiles(files).map(async (file) => [
1618
+ file,
1619
+ await hashWorktreeFile(paths, file)
1620
+ ]));
1621
+ return Object.fromEntries(entries);
1622
+ }
1623
+
1624
+ async function writeBlueprintBaseline(paths) {
1625
+ const changedFiles = await changedFilesInWorktree(paths);
1626
+ const snapshot = await buildDirtyFileSnapshot(paths, changedFiles);
1627
+ const payload = {
1628
+ changedFiles: Object.keys(snapshot).sort((left, right) => left.localeCompare(right)),
1629
+ files: snapshot,
1630
+ recordedAt: timestampForStepRecord()
1631
+ };
1632
+ await writeTextFile(blueprintBaselinePath(paths), `${JSON.stringify(payload, null, 2)}\n`);
1633
+ return payload;
1634
+ }
1635
+
1636
+ async function readBlueprintBaseline(paths) {
1637
+ return parseJsonObject(await readTextIfExists(blueprintBaselinePath(paths))) || null;
1638
+ }
1639
+
1640
+ async function unexpectedBlueprintStepChanges(paths, changedFiles = []) {
1641
+ const baseline = await readBlueprintBaseline(paths);
1642
+ if (!baseline?.files || typeof baseline.files !== "object" || Array.isArray(baseline.files)) {
1643
+ return nonBlueprintChangedFiles(changedFiles);
1644
+ }
1645
+ const baselineFiles = baseline.files;
1646
+ const currentFiles = new Set(nonBlueprintChangedFiles(changedFiles));
1647
+ const candidates = new Set([
1648
+ ...Object.keys(baselineFiles),
1649
+ ...currentFiles
1650
+ ]);
1651
+ const unexpected = [];
1652
+ for (const file of [...candidates].sort((left, right) => left.localeCompare(right))) {
1653
+ if (!Object.prototype.hasOwnProperty.call(baselineFiles, file)) {
1654
+ unexpected.push(file);
1655
+ continue;
1656
+ }
1657
+ const currentHash = await hashWorktreeFile(paths, file);
1658
+ if (currentHash !== baselineFiles[file]) {
1659
+ unexpected.push(file);
1660
+ }
1661
+ }
1662
+ return unexpected;
1663
+ }
1664
+
1833
1665
  async function changedFilesSinceBase(paths) {
1834
1666
  const baseCommit = await readTrimmedFile(path.join(paths.sessionRoot, "base_commit"));
1835
1667
  const args = baseCommit
@@ -1889,24 +1721,23 @@ async function renderReviewPrompt(paths) {
1889
1721
  const reviewPass = await resolveReviewPassForPrompt(paths);
1890
1722
  await writeCurrentReviewPass(paths, reviewPass);
1891
1723
  const changedFiles = await changedFilesSinceBase(paths);
1892
- const prompt = await renderPrompt(paths, "review_changes.md", {
1724
+ const prompt = await renderPrompt(paths, "review_prompt_rendered.md", {
1893
1725
  changed_files: changedFiles,
1894
1726
  review_pass_limit: String(REVIEW_PASS_LIMIT),
1895
1727
  review_pass_number: reviewPass
1896
1728
  });
1897
1729
  const passRoot = reviewPassRoot(paths, reviewPass);
1898
- await writePromptArtifact(paths, "review.md", prompt);
1730
+ await writePromptArtifact(paths, "review_prompt_rendered", prompt);
1899
1731
  await mkdir(passRoot, { recursive: true });
1900
- await writeTextFile(path.join(passRoot, "prompt.md"), prompt);
1732
+ await writeTextFile(path.join(passRoot, "review_prompt_rendered"), prompt);
1901
1733
  await writeReviewPassJson(paths, reviewPass, "prompt.json", {
1902
1734
  changedFiles: changedFiles.split(/\r?\n/u).filter(Boolean),
1903
1735
  maxPasses: REVIEW_PASS_LIMIT,
1904
1736
  pass: reviewPass,
1905
- promptPath: path.join(passRoot, "prompt.md"),
1737
+ promptPath: path.join(passRoot, "review_prompt_rendered"),
1906
1738
  status: "prompted",
1907
- startedAt: timestampForReceipt()
1739
+ startedAt: timestampForStepRecord()
1908
1740
  });
1909
- await writeReceipt(paths, "review_prompt_rendered", "Started code review.");
1910
1741
  await markStatus(paths, SESSION_STATUS.WAITING_FOR_USER);
1911
1742
  return buildSessionResponse(paths, {
1912
1743
  codex: REVIEW_EXECUTION_CODEX_HANDOFF,
@@ -1915,13 +1746,42 @@ async function renderReviewPrompt(paths) {
1915
1746
  });
1916
1747
  }
1917
1748
 
1918
- 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
+
1919
1779
  const reviewDecisionProvided = Object.hasOwn(options, "reviewFindingsRemaining") ||
1920
1780
  Object.hasOwn(options, "review-findings-remaining");
1921
1781
  if (!reviewDecisionProvided) {
1922
1782
  return failSession(paths, {
1923
1783
  code: "review_decision_required",
1924
- 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.",
1925
1785
  repairCommand: `jskit session ${paths.sessionId} step --review-findings-remaining false`
1926
1786
  });
1927
1787
  }
@@ -1940,64 +1800,36 @@ async function acceptReviewChanges(paths, options = {}) {
1940
1800
  const reviewPass = await readCurrentReviewPass(paths);
1941
1801
  const findingsRemaining = options.reviewFindingsRemaining === true ||
1942
1802
  normalizeText(options["review-findings-remaining"]).toLowerCase() === "true";
1943
- const remainingFindings = normalizeText(options.reviewFindings || options["review-findings"]);
1944
1803
  await writeReviewPassJson(paths, reviewPass, "accepted.json", {
1945
- acceptedAt: timestampForReceipt(),
1804
+ acceptedAt: timestampForStepRecord(),
1946
1805
  changedFiles: status.changedFiles || [],
1947
1806
  findingsRemaining,
1948
- remainingFindings,
1807
+ remainingFindings: "",
1949
1808
  pass: reviewPass,
1950
1809
  status: status.changedFiles?.length ? "accepted" : "no_changes"
1951
1810
  });
1952
- await writeReceipt(paths, "review_changes_accepted", message);
1811
+ await writeStepRecord(paths, "review_changes_accepted", message);
1953
1812
  await markStatus(paths, SESSION_STATUS.RUNNING);
1954
1813
  return buildSessionResponse(paths);
1955
1814
  }
1956
1815
 
1957
1816
  async function runAutomatedChecks(paths, {
1958
- stepId,
1959
- label
1960
- }, options = {}, context = {}) {
1817
+ stepId
1818
+ }, _options = {}, context = {}) {
1961
1819
  const preconditions = context.preconditions || [];
1962
1820
  const [command, args] = await doctorCommandForWorktree(paths.worktree);
1963
- const promptFileName = `${stepId}.md`;
1964
- const promptPath = path.join(paths.sessionRoot, "prompts", promptFileName);
1965
1821
  const checksRoot = path.join(paths.sessionRoot, "checks");
1966
1822
  await mkdir(checksRoot, { recursive: true });
1967
1823
  const checkCommand = [command, ...args].join(" ");
1968
1824
 
1969
- if (await fileExists(promptPath)) {
1970
- const codexResultFailure = await requireCodexStepResult(paths, stepId, options.codexResult, preconditions);
1971
- if (codexResultFailure) {
1972
- return codexResultFailure;
1973
- }
1974
- await writeTextFile(
1975
- path.join(checksRoot, `${stepId}.json`),
1976
- `${JSON.stringify({
1977
- command: checkCommand,
1978
- ok: true,
1979
- promptPath,
1980
- status: "completed_by_codex",
1981
- stepId
1982
- }, null, 2)}\n`
1983
- );
1984
- await writeReceipt(paths, stepId, `${label} completed by Codex: ${checkCommand}.`);
1985
- await markStatus(paths, SESSION_STATUS.RUNNING);
1986
- return buildSessionResponse(paths, {
1987
- preconditions
1988
- });
1989
- }
1990
-
1991
- const prompt = await renderPrompt(paths, "automated_checks.md", {
1825
+ const prompt = await renderPrompt(paths, "automated_checks_run.md", {
1992
1826
  check_command: checkCommand
1993
1827
  });
1994
- await writePromptArtifact(paths, promptFileName, prompt);
1995
1828
  await writeTextFile(
1996
1829
  path.join(checksRoot, `${stepId}.json`),
1997
1830
  `${JSON.stringify({
1998
1831
  command: checkCommand,
1999
1832
  ok: false,
2000
- promptPath,
2001
1833
  status: "prompted",
2002
1834
  stepId
2003
1835
  }, null, 2)}\n`
@@ -2019,86 +1851,26 @@ async function writeUiCheckJson(paths, fileName, payload) {
2019
1851
 
2020
1852
  async function runDeepUiCheck(paths, {
2021
1853
  stepId,
2022
- label,
2023
1854
  phase
2024
- }, options = {}, context = {}) {
1855
+ }, _options = {}, context = {}) {
2025
1856
  const preconditions = context.preconditions || [];
2026
- const issueMetadata = await readIssueMetadata(paths);
2027
- const uiImpact = normalizeUiImpact(issueMetadata.uiImpact) || "unknown";
2028
- const skipRequested = options.skipUiCheck === true || normalizeText(options["skip-ui-check"]).toLowerCase() === "true";
2029
- const skipReason = normalizeText(options.skipReason || options["skip-reason"]);
2030
- const shouldSkip = uiImpact === "none" || skipRequested;
2031
- if (shouldSkip) {
2032
- if (skipRequested && uiImpact !== "possible") {
2033
- return failSession(paths, {
2034
- code: "ui_check_skip_not_allowed",
2035
- message: `Deep UI check can only be manually skipped when uiImpact is possible. Current uiImpact is ${uiImpact}.`,
2036
- repairCommand: `jskit session ${paths.sessionId} step`,
2037
- preconditions
2038
- });
2039
- }
2040
- if (skipRequested && !skipReason) {
2041
- return failSession(paths, {
2042
- code: "ui_check_skip_reason_required",
2043
- message: "Skipping a possible Deep UI check requires --skip-reason.",
2044
- repairCommand: `jskit session ${paths.sessionId} step --skip-ui-check --skip-reason "<reason>"`,
2045
- preconditions
2046
- });
2047
- }
2048
- const reason = uiImpact === "none" ? "uiImpact is none." : skipReason;
2049
- await writeUiCheckJson(paths, stepId, {
2050
- ok: true,
2051
- phase,
2052
- reason,
2053
- status: "skipped",
2054
- stepId,
2055
- uiImpact
2056
- });
2057
- await writeReceipt(paths, stepId, `${label} skipped: ${reason}`);
2058
- await markStatus(paths, SESSION_STATUS.RUNNING);
2059
- return buildSessionResponse(paths, {
2060
- preconditions
2061
- });
2062
- }
2063
-
2064
- const promptFileName = `${stepId}.md`;
2065
- const promptPath = path.join(paths.sessionRoot, "prompts", promptFileName);
2066
- if (await fileExists(promptPath)) {
2067
- const codexResultFailure = await requireCodexStepResult(paths, stepId, options.codexResult, preconditions);
2068
- if (codexResultFailure) {
2069
- return codexResultFailure;
2070
- }
2071
- await writeReceipt(paths, stepId, `${label} completed by Codex.`);
2072
- await markStatus(paths, SESSION_STATUS.RUNNING);
2073
- return buildSessionResponse(paths, {
2074
- preconditions
2075
- });
2076
- }
2077
-
2078
1857
  const issueUrl = await readTrimmedFile(path.join(paths.sessionRoot, "issue_url"));
2079
1858
  const issueText = await readTrimmedFile(path.join(paths.sessionRoot, "issue.md"));
2080
1859
  const issueTitle = await readTrimmedFile(path.join(paths.sessionRoot, "issue_title")) || titleFromIssue(issueText);
2081
- const { planPath } = await readCurrentPlan(paths);
2082
- const prompt = await renderPrompt(paths, "deep_ui_check.md", {
1860
+ const prompt = await renderPrompt(paths, "deep_ui_check_run.md", {
2083
1861
  changed_files: await changedFilesSinceBase(paths),
2084
1862
  issue_file: path.join(paths.sessionRoot, "issue.md"),
2085
1863
  issue_number: issueNumberFromUrl(issueUrl),
2086
1864
  issue_title: issueTitle,
2087
1865
  issue_url: issueUrl,
2088
1866
  phase,
2089
- issue_details_file: path.join(paths.sessionRoot, "issue_details.md"),
2090
- plan_file: planPath,
2091
- ui_impact: uiImpact,
2092
1867
  worktree: paths.worktree
2093
1868
  });
2094
- await writePromptArtifact(paths, promptFileName, prompt);
2095
1869
  await writeUiCheckJson(paths, stepId, {
2096
1870
  ok: true,
2097
1871
  phase,
2098
- promptPath,
2099
1872
  status: "prompted",
2100
- stepId,
2101
- uiImpact
1873
+ stepId
2102
1874
  });
2103
1875
  await markStatus(paths, SESSION_STATUS.WAITING_FOR_USER);
2104
1876
  return buildSessionResponse(paths, {
@@ -2112,54 +1884,30 @@ async function runDeepUiCheck(paths, {
2112
1884
  async function userCheck(paths, options = {}) {
2113
1885
  const result = normalizeText(options.userCheck || options["user-check"]).toLowerCase();
2114
1886
  if (result === "passed" || result === "pass" || result === "ok" || result === "yes") {
2115
- await writeReceipt(paths, "user_check_completed", "User confirmed check passed.");
1887
+ await writeStepRecord(paths, "user_check_completed", "User confirmed check passed.");
2116
1888
  await markStatus(paths, SESSION_STATUS.RUNNING);
2117
1889
  return buildSessionResponse(paths);
2118
1890
  }
2119
1891
  if (result === "failed" || result === "fail" || result === "no") {
2120
- const activeCycle = await readActiveCycle(paths);
2121
- await writeCycleReceipt(paths, "user_check_failed", "User reported that manual verification failed.", {
2122
- cycle: activeCycle
2123
- });
2124
- const reworkNotes = normalizeText(options.reworkNotes || options["rework-notes"]);
2125
- if (!reworkNotes) {
2126
- await markStatus(paths, SESSION_STATUS.BLOCKED);
2127
- return buildSessionResponse(paths, {
2128
- ok: false,
2129
- errors: [
2130
- createError({
2131
- code: "user_check_failed",
2132
- message: "User check failed. Provide rework notes to start a new plan cycle.",
2133
- repairCommand: `jskit session ${paths.sessionId} step --user-check failed --rework-notes -`
2134
- })
2135
- ],
2136
- status: SESSION_STATUS.BLOCKED
2137
- });
2138
- }
2139
- const nextCycle = nextCycleNumber(activeCycle);
2140
- await writeTextFile(path.join(paths.sessionRoot, "cycles", `cycle_${nextCycle}`, "rework_request.md"), `${reworkNotes}\n`);
2141
- await writeActiveCycle(paths, nextCycle);
2142
- await writeCycleReceipt(paths, "cycle_started", `Started rework cycle ${nextCycle}.`, {
2143
- cycle: nextCycle
2144
- });
2145
- await markCurrentStep(paths, "plan_made");
2146
1892
  await markStatus(paths, SESSION_STATUS.RUNNING);
2147
- 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
+ });
2148
1901
  }
2149
1902
  const issueText = await readTrimmedFile(path.join(paths.sessionRoot, "issue.md"));
2150
1903
  const issueUrl = await readTrimmedFile(path.join(paths.sessionRoot, "issue_url"));
2151
1904
  const issueTitle = await readTrimmedFile(path.join(paths.sessionRoot, "issue_title")) || titleFromIssue(issueText);
2152
- const { planPath, planText } = await readCurrentPlan(paths);
2153
- const prompt = await renderPrompt(paths, "user_check.md", {
1905
+ const prompt = await renderPrompt(paths, "user_check_completed.md", {
2154
1906
  issue_file: path.join(paths.sessionRoot, "issue.md"),
2155
1907
  issue_title: issueTitle,
2156
- issue_url: issueUrl,
2157
- issue_details_file: path.join(paths.sessionRoot, "issue_details.md"),
2158
- issue_details_text: await readTrimmedFile(path.join(paths.sessionRoot, "issue_details.md")),
2159
- plan_file: planPath,
2160
- plan_text: planText
1908
+ issue_url: issueUrl
2161
1909
  });
2162
- await writePromptArtifact(paths, "user_check.md", prompt);
1910
+ await writePromptArtifact(paths, "user_check_completed", prompt);
2163
1911
  await markStatus(paths, SESSION_STATUS.WAITING_FOR_USER);
2164
1912
  return buildSessionResponse(paths, {
2165
1913
  prompt,
@@ -2174,21 +1922,15 @@ async function readAcceptedChangesCommit(paths) {
2174
1922
 
2175
1923
  async function commitAcceptedChanges(paths, _options = {}, context = {}) {
2176
1924
  const preconditions = context.preconditions || [];
1925
+ const completeStep = context.completeStep !== false;
2177
1926
  let commitInfo = await readAcceptedChangesCommit(paths);
2178
1927
 
2179
1928
  if (!commitInfo?.commit) {
2180
1929
  const result = await commitWorktree(paths, {
1930
+ allowNoChanges: true,
2181
1931
  message: `Implement JSKIT session ${paths.sessionId}`
2182
1932
  });
2183
1933
  if (!result.ok) {
2184
- if (result.output === "No changes found.") {
2185
- return failSession(paths, {
2186
- code: "accepted_changes_missing",
2187
- message: "No accepted worktree changes found to commit.",
2188
- repairCommand: `git -C ${paths.worktree} status --short`,
2189
- preconditions
2190
- });
2191
- }
2192
1934
  return failSession(paths, {
2193
1935
  code: "accepted_changes_commit_failed",
2194
1936
  message: result.output || "Failed to commit accepted changes.",
@@ -2199,41 +1941,56 @@ async function commitAcceptedChanges(paths, _options = {}, context = {}) {
2199
1941
  commitInfo = {
2200
1942
  changedFiles: result.changedFiles || [],
2201
1943
  commit: await currentHead(paths),
2202
- committedAt: timestampForReceipt()
1944
+ committedAt: timestampForStepRecord(),
1945
+ noChanges: (result.changedFiles || []).length < 1
2203
1946
  };
2204
1947
  await writeTextFile(path.join(paths.sessionRoot, "changes_committed.json"), `${JSON.stringify(commitInfo, null, 2)}\n`);
2205
1948
  }
2206
1949
 
2207
- await writeReceipt(paths, "changes_committed", `Committed accepted changes at ${commitInfo.commit || "unknown"}.`);
1950
+ const warnings = [];
1951
+ if (commitInfo.noChanges === true) {
1952
+ warnings.push({
1953
+ code: "accepted_changes_noop",
1954
+ message: "No accepted worktree changes were found; continuing without a new commit."
1955
+ });
1956
+ }
1957
+ if (!completeStep) {
1958
+ await markStatus(paths, SESSION_STATUS.RUNNING);
1959
+ return buildSessionResponse(paths, {
1960
+ preconditions,
1961
+ warnings
1962
+ });
1963
+ }
1964
+ await writeStepRecord(
1965
+ paths,
1966
+ "changes_committed",
1967
+ commitInfo.noChanges === true
1968
+ ? "No accepted worktree changes were found; continued without a new commit."
1969
+ : `Committed accepted changes at ${commitInfo.commit || "unknown"}.`
1970
+ );
2208
1971
  await markStatus(paths, SESSION_STATUS.RUNNING);
2209
1972
  return buildSessionResponse(paths, {
2210
- preconditions
1973
+ preconditions,
1974
+ warnings
2211
1975
  });
2212
1976
  }
2213
1977
 
2214
- async function updateBlueprint(paths, options = {}, context = {}) {
1978
+ async function updateBlueprint(paths, _options = {}, context = {}) {
2215
1979
  const preconditions = context.preconditions || [];
2216
1980
  const issueText = await readTrimmedFile(path.join(paths.sessionRoot, "issue.md"));
2217
1981
  const issueTitle = await readTrimmedFile(path.join(paths.sessionRoot, "issue_title")) || titleFromIssue(issueText);
2218
1982
  const issueUrl = await readTrimmedFile(path.join(paths.sessionRoot, "issue_url"));
2219
1983
  const issueNumber = issueNumberFromUrl(issueUrl);
2220
- const { planPath } = await readCurrentPlan(paths);
2221
- const issueDetailsPath = path.join(paths.sessionRoot, "issue_details.md");
2222
- const agentDecisionsPath = path.join(paths.sessionRoot, "agent_decisions.md");
2223
- const blueprintPath = path.join(paths.worktree, ".jskit", "APP_BLUEPRINT.md");
2224
- const blueprintPromptPath = path.join(paths.sessionRoot, "prompts", "update_blueprint.md");
1984
+ const blueprintPath = path.join(paths.worktree, BLUEPRINT_RELATIVE_PATH);
1985
+ const blueprintSentinelPath = path.join(paths.sessionRoot, "metadata", "blueprint_updated_requested");
2225
1986
 
2226
- if (await fileExists(blueprintPromptPath)) {
2227
- const codexResultFailure = await requireCodexStepResult(paths, "blueprint_updated", options.codexResult, preconditions);
2228
- if (codexResultFailure) {
2229
- return codexResultFailure;
2230
- }
1987
+ if (context.completeStep !== false && await fileExists(blueprintSentinelPath)) {
2231
1988
  const changedFiles = await changedFilesInWorktree(paths);
2232
- const unexpectedChanges = changedFiles.filter((file) => file !== ".jskit/APP_BLUEPRINT.md");
1989
+ const unexpectedChanges = await unexpectedBlueprintStepChanges(paths, changedFiles);
2233
1990
  if (unexpectedChanges.length > 0) {
2234
1991
  return failSession(paths, {
2235
1992
  code: "blueprint_unexpected_changes",
2236
- message: `The blueprint step changed files outside .jskit/APP_BLUEPRINT.md: ${unexpectedChanges.join(", ")}`,
1993
+ message: `The blueprint step changed files outside ${BLUEPRINT_RELATIVE_PATH}: ${unexpectedChanges.join(", ")}`,
2237
1994
  repairCommand: `git -C ${paths.worktree} status --short`,
2238
1995
  preconditions
2239
1996
  });
@@ -2249,32 +2006,10 @@ async function updateBlueprint(paths, options = {}, context = {}) {
2249
2006
  });
2250
2007
  }
2251
2008
 
2252
- if (changedFiles.includes(".jskit/APP_BLUEPRINT.md")) {
2253
- const addResult = await runGitInWorktree(paths.worktree, ["add", ".jskit/APP_BLUEPRINT.md"], {
2254
- timeout: 15000
2255
- });
2256
- if (!addResult.ok) {
2257
- return failSession(paths, {
2258
- code: "blueprint_stage_failed",
2259
- message: addResult.output || "Failed to stage app blueprint update.",
2260
- repairCommand: `git -C ${paths.worktree} add .jskit/APP_BLUEPRINT.md`,
2261
- preconditions
2262
- });
2263
- }
2264
- const commitResult = await runGitInWorktree(paths.worktree, ["commit", "-m", `Update app blueprint for ${paths.sessionId}`], {
2265
- timeout: 1000 * 60
2266
- });
2267
- if (!commitResult.ok) {
2268
- return failSession(paths, {
2269
- code: "blueprint_commit_failed",
2270
- message: commitResult.output || "Failed to commit app blueprint update.",
2271
- repairCommand: `git -C ${paths.worktree} status --short`,
2272
- preconditions
2273
- });
2274
- }
2275
- await writeReceipt(paths, "blueprint_updated", "Codex updated and JSKIT committed the app blueprint.");
2009
+ if (changedFiles.includes(BLUEPRINT_RELATIVE_PATH)) {
2010
+ await writeStepRecord(paths, "blueprint_updated", "Codex updated the app blueprint; JSKIT will include it in the accepted changes commit.");
2276
2011
  } else {
2277
- 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.");
2278
2013
  }
2279
2014
  await markStatus(paths, SESSION_STATUS.RUNNING);
2280
2015
  return buildSessionResponse(paths, {
@@ -2283,19 +2018,17 @@ async function updateBlueprint(paths, options = {}, context = {}) {
2283
2018
  });
2284
2019
  }
2285
2020
 
2286
- const prompt = await renderPrompt(paths, "update_blueprint.md", {
2287
- agent_decisions_file: agentDecisionsPath,
2021
+ await writeBlueprintBaseline(paths);
2022
+ const prompt = await renderPrompt(paths, "blueprint_updated.md", {
2288
2023
  app_blueprint_file: blueprintPath,
2289
2024
  changed_files: await changedFilesSinceBase(paths),
2290
2025
  issue_file: path.join(paths.sessionRoot, "issue.md"),
2291
2026
  issue_number: issueNumber,
2292
2027
  issue_title: issueTitle,
2293
2028
  issue_url: issueUrl,
2294
- issue_details_file: issueDetailsPath,
2295
- plan_file: planPath,
2296
2029
  worktree: paths.worktree
2297
2030
  });
2298
- await writePromptArtifact(paths, "update_blueprint.md", prompt);
2031
+ await writeTextFile(blueprintSentinelPath, "true\n");
2299
2032
  await markStatus(paths, SESSION_STATUS.WAITING_FOR_USER);
2300
2033
  return buildSessionResponse(paths, {
2301
2034
  codex: BLUEPRINT_CODEX_HANDOFF,
@@ -2386,13 +2119,10 @@ async function readReviewPassSummaries(paths) {
2386
2119
  .join("\n");
2387
2120
  }
2388
2121
 
2389
- async function createFinalReport(paths, _options = {}, context = {}) {
2122
+ async function renderPullRequestFilePrompt(paths, context = {}) {
2390
2123
  const preconditions = context.preconditions || [];
2391
2124
  const issueUrl = await readTrimmedFile(path.join(paths.sessionRoot, "issue_url"));
2392
2125
  const issueTitle = await readTrimmedFile(path.join(paths.sessionRoot, "issue_title"));
2393
- const issueDetails = await readTrimmedFile(path.join(paths.sessionRoot, "issue_details.md"));
2394
- const { planText } = await readCurrentPlan(paths);
2395
- const agentDecisions = await readTextIfExists(path.join(paths.sessionRoot, "agent_decisions.md"));
2396
2126
  const filesChanged = await changedFilesSinceBase(paths);
2397
2127
  const commits = await commitLinesSinceBase(paths);
2398
2128
  const checks = await readCheckSummaries(paths);
@@ -2400,80 +2130,43 @@ async function createFinalReport(paths, _options = {}, context = {}) {
2400
2130
  const reviewPasses = await readReviewPassSummaries(paths);
2401
2131
  const commandLogPath = path.join(paths.sessionRoot, "command_log.jsonl");
2402
2132
  const blueprintStatus = await readTextIfExists(path.join(paths.sessionRoot, "steps", "blueprint_updated"));
2403
- const userCheck = await readTextIfExists(path.join(paths.sessionRoot, "steps", `cycle_${await readActiveCycle(paths)}`, "user_check_completed"));
2404
- const report = [
2405
- `# Final Report: ${issueTitle || paths.sessionId}`,
2406
- "",
2407
- `Issue: ${issueUrl || "(missing)"}`,
2408
- `Session: ${paths.sessionId}`,
2409
- "",
2410
- "## Issue Details",
2411
- "",
2412
- issueDetails || "No issue details recorded.",
2413
- "",
2414
- "## Plan",
2415
- "",
2416
- planText || "No plan recorded.",
2417
- "",
2418
- "## Files Changed",
2419
- "",
2420
- filesChanged || "No changed files detected against the session base.",
2421
- "",
2422
- "## Commits",
2423
- "",
2424
- commits || "No commits detected against the session base.",
2425
- "",
2426
- "## Checks",
2427
- "",
2428
- checks || "No structured checks recorded.",
2429
- "",
2430
- "## UI Checks",
2431
- "",
2432
- uiChecks || "No structured UI checks recorded.",
2433
- "",
2434
- "## Review Passes",
2435
- "",
2436
- reviewPasses || "No structured review passes recorded.",
2437
- "",
2438
- "## Command Log",
2439
- "",
2440
- await fileExists(commandLogPath) ? commandLogPath : "No command log recorded.",
2441
- "",
2442
- "## User Check",
2443
- "",
2444
- userCheck.trim() || "No user check receipt recorded.",
2445
- "",
2446
- "## Blueprint",
2447
- "",
2448
- blueprintStatus.trim() || "No blueprint receipt recorded.",
2449
- "",
2450
- "## Remaining Unverified Gaps",
2451
- "",
2452
- "Review the check and UI check sections above; no additional gaps were recorded by JSKIT.",
2453
- "",
2454
- "## Decisions",
2455
- "",
2456
- agentDecisions.trim() || "No decision log recorded.",
2457
- ""
2458
- ].join("\n");
2459
- const reportPath = path.join(paths.sessionRoot, "final_report.md");
2460
- await writeTextFile(reportPath, report);
2461
- if (issueUrl) {
2462
- const commentResult = await commentOnIssueOnce(paths, {
2463
- bodyFile: reportPath,
2464
- issueUrl,
2465
- purpose: "final_report"
2466
- });
2467
- if (!commentResult.ok) {
2468
- return failSession(paths, {
2469
- code: "final_report_comment_failed",
2470
- message: commentResult.output || "Failed to comment final report on the GitHub issue.",
2471
- repairCommand: `gh issue comment ${issueUrl} --body-file ${reportPath}`,
2472
- preconditions
2473
- });
2474
- }
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);
2475
2168
  }
2476
- 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.");
2477
2170
  await markStatus(paths, SESSION_STATUS.RUNNING);
2478
2171
  return buildSessionResponse(paths, {
2479
2172
  preconditions
@@ -2494,6 +2187,140 @@ function parseJsonObject(value) {
2494
2187
  }
2495
2188
  }
2496
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
+
2497
2324
  async function readPrState(paths, prUrl) {
2498
2325
  const prRef = normalizeText(prUrl);
2499
2326
  const args = prRef
@@ -2597,7 +2424,7 @@ async function removeSessionWorktree(paths) {
2597
2424
 
2598
2425
  async function writePrOutcome(paths, outcome) {
2599
2426
  await writeTextFile(path.join(paths.sessionRoot, "pr_outcome.json"), `${JSON.stringify({
2600
- recordedAt: timestampForReceipt(),
2427
+ recordedAt: timestampForStepRecord(),
2601
2428
  ...outcome
2602
2429
  }, null, 2)}\n`);
2603
2430
  }
@@ -2608,7 +2435,7 @@ function mainCheckoutSyncPath(paths) {
2608
2435
 
2609
2436
  async function writeMainCheckoutSync(paths, payload = {}) {
2610
2437
  await writeTextFile(mainCheckoutSyncPath(paths), `${JSON.stringify({
2611
- recordedAt: timestampForReceipt(),
2438
+ recordedAt: timestampForStepRecord(),
2612
2439
  ...payload
2613
2440
  }, null, 2)}\n`);
2614
2441
  }
@@ -2674,36 +2501,33 @@ async function updateLocalBaseBranch(paths, baseBranch = "") {
2674
2501
  }
2675
2502
 
2676
2503
  async function syncMainCheckout(paths, options = {}, context = {}) {
2504
+ const prUrl = await readTrimmedFile(path.join(paths.sessionRoot, "pr_url"));
2677
2505
  const prOutcome = parseJsonObject(await readTextIfExists(path.join(paths.sessionRoot, "pr_outcome.json")));
2678
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
+ }
2679
2515
  if (!prOutcome?.outcome) {
2680
- return failSession(paths, {
2516
+ return sessionStepError(paths, {
2681
2517
  code: "pr_outcome_missing",
2682
- message: "Cannot sync the main checkout before PR finalization records an outcome.",
2683
- preconditions,
2684
- 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`
2685
2520
  });
2686
2521
  }
2687
2522
 
2688
- const skipRequested = options.skipMainSync === true ||
2689
- normalizeText(options["skip-main-sync"]).toLowerCase() === "true";
2690
- const skipReason = normalizeText(options.skipReason || options["skip-reason"]) ||
2691
- "User skipped main checkout sync.";
2692
- if (skipRequested || prOutcome.outcome !== "merged") {
2693
- const reason = prOutcome.outcome === "merged"
2694
- ? skipReason
2695
- : `PR outcome is ${prOutcome.outcome}; no main checkout sync is required.`;
2696
- await writeMainCheckoutSync(paths, {
2697
- branch: prOutcome.baseBranch || "",
2698
- outcome: prOutcome.outcome,
2699
- reason,
2700
- 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`
2701
2529
  });
2702
- await writeReceipt(paths, "main_checkout_synced", `Main checkout sync skipped: ${reason}`);
2703
- await markStatus(paths, SESSION_STATUS.RUNNING);
2704
- return buildSessionResponse(paths);
2705
2530
  }
2706
-
2707
2531
  const baseBranch = prOutcome.baseBranch || await readTrimmedFile(path.join(paths.sessionRoot, "pr_base_branch"));
2708
2532
  const syncFailure = await updateLocalBaseBranch(paths, baseBranch);
2709
2533
  if (syncFailure) {
@@ -2716,9 +2540,13 @@ async function syncMainCheckout(paths, options = {}, context = {}) {
2716
2540
  outcome: prOutcome.outcome,
2717
2541
  status: "synced"
2718
2542
  });
2719
- 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
+ }
2720
2546
  await markStatus(paths, SESSION_STATUS.RUNNING);
2721
- return buildSessionResponse(paths);
2547
+ return buildSessionResponse(paths, {
2548
+ preconditions
2549
+ });
2722
2550
  }
2723
2551
 
2724
2552
  async function updateHelperMapBeforePr(paths) {
@@ -2802,13 +2630,35 @@ async function updateHelperMapBeforePr(paths) {
2802
2630
  };
2803
2631
  }
2804
2632
 
2805
- async function createPr(paths) {
2806
- const helperMapResult = await updateHelperMapBeforePr(paths);
2807
- if (!helperMapResult.ok) {
2808
- return failSession(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
+ }
2655
+ const helperMapResult = await updateHelperMapBeforePr(paths);
2656
+ if (!helperMapResult.ok) {
2657
+ return failSession(paths, {
2809
2658
  code: helperMapResult.code,
2810
2659
  message: helperMapResult.message,
2811
- repairCommand: helperMapResult.repairCommand
2660
+ repairCommand: helperMapResult.repairCommand,
2661
+ preconditions
2812
2662
  });
2813
2663
  }
2814
2664
 
@@ -2820,34 +2670,30 @@ async function createPr(paths) {
2820
2670
  return failSession(paths, {
2821
2671
  code: "branch_push_failed",
2822
2672
  message: pushResult.output || "Failed to push session branch.",
2823
- repairCommand: `git -C ${paths.worktree} push -u origin HEAD`
2673
+ repairCommand: `git -C ${paths.worktree} push -u origin HEAD`,
2674
+ preconditions
2824
2675
  });
2825
2676
  }
2826
2677
  const existingPrState = await readCurrentBranchPrState(paths);
2827
2678
  if (existingPrState.ok && existingPrState.url && !prStateIsClosed(existingPrState)) {
2828
2679
  await writeTextFile(path.join(paths.sessionRoot, "pr_url"), existingPrState.url);
2829
- 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
+ }
2830
2683
  await markStatus(paths, SESSION_STATUS.RUNNING);
2831
- return buildSessionResponse(paths);
2684
+ return buildSessionResponse(paths, {
2685
+ preconditions
2686
+ });
2832
2687
  }
2833
- const issueUrl = await readTrimmedFile(path.join(paths.sessionRoot, "issue_url"));
2834
2688
  const issueText = await readTrimmedFile(path.join(paths.sessionRoot, "issue.md"));
2835
2689
  const issueTitle = await readTrimmedFile(path.join(paths.sessionRoot, "issue_title")) || titleFromIssue(issueText);
2836
- const issueNumber = issueNumberFromUrl(issueUrl);
2837
- const body = [
2838
- issueNumber ? `Closes #${issueNumber}` : "",
2839
- "",
2840
- issueText
2841
- ].join("\n").trim();
2842
- const bodyPath = path.join(paths.sessionRoot, "pr_body.md");
2843
- await writeTextFile(bodyPath, body);
2844
2690
  const result = await runLoggedCommand(paths, "github_pr_create", "gh", [
2845
2691
  "pr",
2846
2692
  "create",
2847
2693
  "--title",
2848
2694
  issueTitle,
2849
2695
  "--body-file",
2850
- bodyPath
2696
+ pullRequestPath
2851
2697
  ], {
2852
2698
  cwd: paths.worktree,
2853
2699
  timeout: 1000 * 60
@@ -2856,144 +2702,87 @@ async function createPr(paths) {
2856
2702
  const fallbackPrState = await readCurrentBranchPrState(paths);
2857
2703
  if (fallbackPrState.ok && fallbackPrState.url && !prStateIsClosed(fallbackPrState)) {
2858
2704
  await writeTextFile(path.join(paths.sessionRoot, "pr_url"), fallbackPrState.url);
2859
- 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
+ }
2860
2708
  await markStatus(paths, SESSION_STATUS.RUNNING);
2861
- return buildSessionResponse(paths);
2709
+ return buildSessionResponse(paths, {
2710
+ preconditions
2711
+ });
2862
2712
  }
2863
2713
  const prompt = await renderPrompt(paths, "pr_failure.md", {
2864
2714
  doctor_output: result.output
2865
2715
  });
2866
- await writePromptArtifact(paths, "pr_create_failure.md", prompt);
2716
+ await writePromptArtifact(paths, "pr_create_failure", prompt);
2867
2717
  return failSession(paths, {
2868
2718
  code: "pr_create_failed",
2869
2719
  message: result.output || "Failed to create PR.",
2870
2720
  repairCommand: "gh pr create",
2721
+ preconditions,
2871
2722
  prompt
2872
2723
  });
2873
2724
  }
2874
2725
  const prUrl = result.stdout.split(/\r?\n/u).map((line) => line.trim()).find(Boolean) || result.stdout;
2875
2726
  await writeTextFile(path.join(paths.sessionRoot, "pr_url"), prUrl);
2876
- await writeReceipt(paths, "pr_created", `Pushed branch ${paths.branch} and created PR ${prUrl}. ${helperMapResult.message}`);
2877
- await markStatus(paths, SESSION_STATUS.RUNNING);
2878
- return buildSessionResponse(paths);
2879
- }
2880
-
2881
- async function closePrWithoutMerge(paths, prUrl, options = {}) {
2882
- const reason = normalizeText(options.closeReason || options["close-reason"]) || "User skipped merge in JSKIT Studio.";
2883
- const prState = await readPrState(paths, prUrl);
2884
- if (!prState.ok) {
2885
- return failSession(paths, {
2886
- code: "pr_state_failed",
2887
- message: prState.output || "Failed to inspect PR before closing without merge.",
2888
- repairCommand: `gh pr view ${prUrl} --json state,mergedAt,url,baseRefName`
2889
- });
2890
- }
2891
- if (prStateIsMerged(prState)) {
2892
- return failSession(paths, {
2893
- code: "pr_already_merged",
2894
- message: "Cannot finish without merging because the PR is already merged.",
2895
- repairCommand: `jskit session ${paths.sessionId} step`
2896
- });
2897
- }
2898
- if (!prStateIsClosed(prState)) {
2899
- const commentResult = await runLoggedCommand(paths, "github_pr_comment", "gh", ["pr", "comment", prUrl, "--body", `JSKIT session ${paths.sessionId} finished without merging. Reason: ${reason}`], {
2900
- cwd: paths.targetRoot,
2901
- timeout: 1000 * 60
2902
- });
2903
- if (!commentResult.ok) {
2904
- return failSession(paths, {
2905
- code: "pr_comment_failed",
2906
- message: commentResult.output || "Failed to comment on PR before finishing without merge.",
2907
- repairCommand: `gh pr comment ${prUrl} --body "<reason>"`
2908
- });
2909
- }
2727
+ if (completeStep) {
2728
+ await writeStepRecord(paths, "pr_created", `Pushed branch ${paths.branch} and created PR ${prUrl}. ${helperMapResult.message}`);
2910
2729
  }
2911
- const issueUrl = await readTrimmedFile(path.join(paths.sessionRoot, "issue_url"));
2912
- if (issueUrl) {
2913
- await runLoggedCommand(paths, "github_issue_comment", "gh", ["issue", "comment", issueUrl, "--body", `JSKIT session ${paths.sessionId} finished without merging PR ${prUrl}. Reason: ${reason}`], {
2914
- cwd: paths.targetRoot,
2915
- timeout: 1000 * 60
2916
- });
2917
- }
2918
- await writePrOutcome(paths, {
2919
- issueUrl,
2920
- outcome: "closed_without_merge",
2921
- prUrl,
2922
- prState: prState.state,
2923
- reason
2924
- });
2925
- await writeReceipt(paths, "pr_finalized", `Finished without merging PR ${prUrl}; PR left open. Reason: ${reason}`);
2926
2730
  await markStatus(paths, SESSION_STATUS.RUNNING);
2927
- return buildSessionResponse(paths);
2731
+ return buildSessionResponse(paths, {
2732
+ preconditions
2733
+ });
2928
2734
  }
2929
2735
 
2930
2736
  async function preparePrMerge(paths, options = {}, context = {}) {
2931
2737
  const preconditions = context.preconditions || [];
2932
- const prepareMerge = options.prepareMerge === true ||
2933
- normalizeText(options["prepare-merge"]).toLowerCase() === "true";
2934
- const continueToMerge = options.continueToMerge === true ||
2935
- normalizeText(options["continue-to-merge"]).toLowerCase() === "true";
2936
-
2937
- if (prepareMerge) {
2938
- const prUrl = await readTrimmedFile(path.join(paths.sessionRoot, "pr_url"));
2939
- const baseBranch = await readTrimmedFile(path.join(paths.sessionRoot, "pr_base_branch")) ||
2940
- await readTrimmedFile(path.join(paths.sessionRoot, "base_branch")) ||
2941
- await currentTargetBranch(paths.targetRoot);
2942
- const prompt = await renderPrompt(paths, "prepare_pr_merge.md", {
2943
- base_branch: baseBranch,
2944
- final_report_file: path.join(paths.sessionRoot, "final_report.md"),
2945
- issue_url: await readTrimmedFile(path.join(paths.sessionRoot, "issue_url")),
2946
- pr_url: prUrl,
2947
- target_root: paths.targetRoot
2948
- });
2949
- await writePromptArtifact(paths, "prepare_pr_merge.md", prompt);
2950
- await markStatus(paths, SESSION_STATUS.WAITING_FOR_USER);
2951
- return buildSessionResponse(paths, {
2952
- codex: PR_MERGE_PREP_CODEX_HANDOFF,
2953
- ok: true,
2954
- preconditions,
2955
- prompt,
2956
- status: SESSION_STATUS.WAITING_FOR_USER
2957
- });
2958
- }
2959
-
2960
- if (!continueToMerge) {
2961
- return failSession(paths, {
2962
- code: "pr_merge_prepare_decision_required",
2963
- message: "Choose whether to ask Codex to prepare the PR for merge or continue to the merge decision.",
2964
- repairCommand: `jskit session ${paths.sessionId} step --continue-to-merge true`,
2965
- preconditions
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`
2966
2745
  });
2967
2746
  }
2968
-
2969
- await writeReceipt(paths, "pr_merge_prepared", "User continued from PR merge preparation to the merge decision.");
2970
- await markStatus(paths, SESSION_STATUS.RUNNING);
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);
2971
2759
  return buildSessionResponse(paths, {
2972
- preconditions
2760
+ codex: PR_MERGE_PREP_CODEX_HANDOFF,
2761
+ ok: true,
2762
+ preconditions,
2763
+ prompt,
2764
+ status: SESSION_STATUS.WAITING_FOR_USER
2973
2765
  });
2974
2766
  }
2975
2767
 
2976
2768
  async function finalizePr(paths, options = {}, context = {}) {
2977
2769
  const preconditions = context.preconditions || [];
2770
+ const completeStep = context.completeStep !== false;
2978
2771
  const prUrl = await readTrimmedFile(path.join(paths.sessionRoot, "pr_url"));
2979
- const closeWithoutMerge = options.closeWithoutMerge === true ||
2980
- normalizeText(options["close-without-merge"]).toLowerCase() === "true" ||
2981
- options.skipMerge === true ||
2982
- normalizeText(options["skip-merge"]).toLowerCase() === "true";
2983
- if (closeWithoutMerge) {
2984
- const guardResult = await runSessionFinalizationGuard(paths, preconditions);
2985
- if (!guardResult.ok) {
2986
- return guardResult.response;
2987
- }
2988
- return closePrWithoutMerge(paths, prUrl, options);
2989
- }
2990
2772
  const mergePr = options.mergePr === true ||
2991
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
+ }
2992
2781
  if (!mergePr) {
2993
2782
  return failSession(paths, {
2994
2783
  code: "pr_finalize_decision_required",
2995
2784
  message: "Choose whether to merge the PR or skip merge.",
2996
- repairCommand: `jskit session ${paths.sessionId} step --merge-pr true`,
2785
+ repairCommand: `jskit session ${paths.sessionId} merge_pr`,
2997
2786
  preconditions
2998
2787
  });
2999
2788
  }
@@ -3028,7 +2817,7 @@ async function finalizePr(paths, options = {}, context = {}) {
3028
2817
  const prompt = await renderPrompt(paths, "pr_failure.md", {
3029
2818
  doctor_output: mergeResult?.output || existingPrState.output
3030
2819
  });
3031
- await writePromptArtifact(paths, "pr_merge_failure.md", prompt);
2820
+ await writePromptArtifact(paths, "pr_merge_failure", prompt);
3032
2821
  return failSession(paths, {
3033
2822
  code: "pr_merge_failed",
3034
2823
  message: mergeResult?.output || existingPrState.output || "Failed to merge PR.",
@@ -3052,9 +2841,13 @@ async function finalizePr(paths, options = {}, context = {}) {
3052
2841
  });
3053
2842
  await writeTextFile(mergeMarkerPath, `${prUrl}\n`);
3054
2843
  }
3055
- await writeReceipt(paths, "pr_finalized", `Merged PR ${prUrl}.`);
2844
+ if (completeStep) {
2845
+ await writeStepRecord(paths, "pr_merge_prepared", `Merged PR ${prUrl}.`);
2846
+ }
3056
2847
  await markStatus(paths, SESSION_STATUS.RUNNING);
3057
- return buildSessionResponse(paths);
2848
+ return buildSessionResponse(paths, {
2849
+ preconditions
2850
+ });
3058
2851
  }
3059
2852
 
3060
2853
  async function finishSession(paths) {
@@ -3075,14 +2868,15 @@ async function finishSession(paths) {
3075
2868
  session_id: paths.sessionId,
3076
2869
  transcript_log: path.join(paths.completedSessionRoot, "transcript.log")
3077
2870
  });
3078
- await writeTextFile(path.join(paths.sessionRoot, "final_comment.md"), prompt);
2871
+ const finalCommentPath = path.join(paths.sessionRoot, "final_comment");
2872
+ await writeTextFile(finalCommentPath, prompt);
3079
2873
  if (issueUrl) {
3080
- 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], {
3081
2875
  cwd: paths.targetRoot,
3082
2876
  timeout: 1000 * 60
3083
2877
  });
3084
2878
  }
3085
- 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"}.`);
3086
2880
  await markStatus(paths, SESSION_STATUS.FINISHED);
3087
2881
  await markCurrentStep(paths, "");
3088
2882
  const archivedPaths = await archiveSession(paths, "completed");
@@ -3095,17 +2889,14 @@ const STEP_RUNNERS = Object.freeze({
3095
2889
  worktree_created: createWorktree,
3096
2890
  dependencies_installed: installDependencies,
3097
2891
  issue_prompt_rendered: renderIssuePrompt,
3098
- issue_drafted: draftIssue,
3099
2892
  issue_created: createIssue,
3100
- issue_details_gathered: saveIssueDetails,
2893
+ issue_submitted: submitIssue,
3101
2894
  plan_made: makePlan,
3102
2895
  plan_executed: renderPlanExecutionPrompt,
3103
2896
  automated_checks_run: (paths, options, context) => runAutomatedChecks(paths, {
3104
- label: "Automated checks",
3105
2897
  stepId: "automated_checks_run"
3106
2898
  }, options, context),
3107
2899
  deep_ui_check_run: (paths, options, context) => runDeepUiCheck(paths, {
3108
- label: "Deep UI check",
3109
2900
  phase: "pre_review",
3110
2901
  stepId: "deep_ui_check_run"
3111
2902
  }, options, context),
@@ -3114,10 +2905,9 @@ const STEP_RUNNERS = Object.freeze({
3114
2905
  user_check_completed: userCheck,
3115
2906
  changes_committed: commitAcceptedChanges,
3116
2907
  blueprint_updated: updateBlueprint,
3117
- final_report_created: createFinalReport,
2908
+ final_report_created: createPullRequestFile,
3118
2909
  pr_created: createPr,
3119
2910
  pr_merge_prepared: preparePrMerge,
3120
- pr_finalized: finalizePr,
3121
2911
  main_checkout_synced: syncMainCheckout,
3122
2912
  session_finished: finishSession
3123
2913
  });
@@ -3126,21 +2916,19 @@ const PRECONDITION_RUNNERS = Object.freeze({
3126
2916
  accepted_changes_committed: assertAcceptedChangesCommitted,
3127
2917
  active_cycle_exists: assertActiveCycleExists,
3128
2918
  active_cycle_user_check_passed: assertActiveCycleUserCheckPassed,
2919
+ user_check_passed: assertUserCheckPassed,
3129
2920
  blueprint_update_satisfied: assertBlueprintUpdateSatisfied,
3130
2921
  deep_ui_check_satisfied: assertDeepUiCheckSatisfied,
3131
2922
  dependencies_installed: assertDependenciesInstalled,
3132
- final_report_exists: assertFinalReportExists,
2923
+ pull_request_file_exists: assertPullRequestFileExists,
3133
2924
  git_current_branch: (paths) => assertGitCurrentBranch(paths.targetRoot),
3134
2925
  git_repository: (paths) => assertGitRepository(paths.targetRoot),
3135
2926
  github_auth: (paths) => assertGhAuth(paths.targetRoot),
3136
2927
  github_origin: (paths) => assertGithubOrigin(paths.targetRoot),
3137
- issue_metadata_exists: assertIssueMetadataExists,
3138
2928
  issue_text_exists: assertIssueTextExists,
3139
2929
  issue_url_exists: assertIssueUrlExists,
3140
2930
  automated_checks_passed: assertAutomatedChecksPassed,
3141
- issue_details_exists: assertIssueDetailsExists,
3142
2931
  main_checkout_sync_satisfied: assertMainCheckoutSyncSatisfied,
3143
- plan_text_exists: assertPlanTextExists,
3144
2932
  pr_url_exists: assertPrUrlExists,
3145
2933
  ready_jskit_app: assertReadyJskitApp,
3146
2934
  session_exists: assertSessionExists,
@@ -3156,10 +2944,171 @@ async function runNamedPreconditions(paths, names = []) {
3156
2944
  );
3157
2945
  }
3158
2946
 
3159
- 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({
3160
3061
  targetRoot = process.cwd(),
3161
3062
  sessionId,
3063
+ action,
3162
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
3163
3112
  } = {}) {
3164
3113
  return withExistingSession({ targetRoot, sessionId }, async (paths) => {
3165
3114
  const artifacts = await readSessionArtifacts(paths);
@@ -3185,13 +3134,401 @@ async function runSessionStep({
3185
3134
  if (!nextStep) {
3186
3135
  return finishSession(paths);
3187
3136
  }
3188
- if (nextStep === "session_created") {
3189
- return failSession(paths, {
3190
- code: "session_not_initialized",
3191
- message: "Session exists but is missing its creation receipt.",
3192
- 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
3294
+ });
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`
3193
3493
  });
3194
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
+ }
3195
3532
  const runner = STEP_RUNNERS[nextStep];
3196
3533
  if (typeof runner !== "function") {
3197
3534
  return failSession(paths, {
@@ -3200,6 +3537,9 @@ async function runSessionStep({
3200
3537
  status: SESSION_STATUS.FAILED
3201
3538
  });
3202
3539
  }
3540
+ if (skipStepRequested(options)) {
3541
+ return skipCurrentStep(paths, nextStep, options);
3542
+ }
3203
3543
  const stepPreconditions = await runNamedPreconditions(paths, STEP_PRECONDITION_NAMES[nextStep] || ["session_exists"]);
3204
3544
  if (!stepPreconditions.ok) {
3205
3545
  return failSession(paths, {
@@ -3207,7 +3547,6 @@ async function runSessionStep({
3207
3547
  preconditions: stepPreconditions.preconditions
3208
3548
  });
3209
3549
  }
3210
- await appendAgentDecisionsInput(paths, options);
3211
3550
  return runner(paths, options, {
3212
3551
  preconditions: stepPreconditions.preconditions
3213
3552
  });
@@ -3248,7 +3587,7 @@ async function abandonSession({
3248
3587
  }
3249
3588
  await writeTextFile(
3250
3589
  path.join(paths.sessionRoot, "steps", "abandoned"),
3251
- `${timestampForReceipt()}\nAbandoned session ${paths.sessionId}.`
3590
+ `${timestampForStepRecord()}\nAbandoned session ${paths.sessionId}.`
3252
3591
  );
3253
3592
  await markStatus(paths, SESSION_STATUS.ABANDONED);
3254
3593
  await markCurrentStep(paths, "");
@@ -3297,6 +3636,7 @@ export {
3297
3636
  STEP_IDS,
3298
3637
  STEP_PRECONDITION_NAMES,
3299
3638
  abandonSession,
3639
+ advanceSessionStep,
3300
3640
  adoptDependenciesInstalled,
3301
3641
  adoptCodexThreadId,
3302
3642
  buildSessionResponse,
@@ -3305,8 +3645,6 @@ export {
3305
3645
  createSessionId,
3306
3646
  extractIssueTitle,
3307
3647
  extractIssueText,
3308
- extractIssueDetails,
3309
- extractPlanText,
3310
3648
  inspectSession,
3311
3649
  inspectSessionDiff,
3312
3650
  inspectSessionDetails,
@@ -3316,5 +3654,6 @@ export {
3316
3654
  recordDependenciesInstalled,
3317
3655
  rewindSession,
3318
3656
  resolveSessionPaths,
3319
- runSessionStep
3657
+ runSessionStep,
3658
+ runSessionStepAction
3320
3659
  };