@jskit-ai/jskit-cli 0.2.84 → 0.2.85

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jskit-ai/jskit-cli",
3
- "version": "0.2.84",
3
+ "version": "0.2.85",
4
4
  "description": "Bundle and package orchestration CLI for JSKIT apps.",
5
5
  "type": "module",
6
6
  "files": [
@@ -20,9 +20,9 @@
20
20
  "test": "node --test"
21
21
  },
22
22
  "dependencies": {
23
- "@jskit-ai/jskit-catalog": "0.1.83",
24
- "@jskit-ai/kernel": "0.1.75",
25
- "@jskit-ai/shell-web": "0.1.74",
23
+ "@jskit-ai/jskit-catalog": "0.1.84",
24
+ "@jskit-ai/kernel": "0.1.76",
25
+ "@jskit-ai/shell-web": "0.1.75",
26
26
  "@vue/compiler-sfc": "^3.5.29",
27
27
  "ts-morph": "^28.0.0"
28
28
  },
@@ -51,7 +51,7 @@ const APP_COMMAND_DEFINITIONS = Object.freeze({
51
51
  options: Object.freeze([
52
52
  Object.freeze({
53
53
  label: "--command <shell-command>",
54
- description: "Targeted Playwright command to run, for example: npx playwright test tests/e2e/contacts.spec.ts -g filters."
54
+ description: "Targeted UI verification command to run. If it uses Playwright against a local app, the command/environment must start or reuse a reachable app server first."
55
55
  }),
56
56
  Object.freeze({
57
57
  label: "--feature <label>",
@@ -68,6 +68,7 @@ const APP_COMMAND_DEFINITIONS = Object.freeze({
68
68
  ]),
69
69
  defaults: Object.freeze([
70
70
  "Requires a git working tree so the receipt can record the currently changed UI files.",
71
+ "Does not start the app server; make --command self-contained for UI tests that need one.",
71
72
  "Writes .jskit/verification/ui.json after the command succeeds.",
72
73
  "Doctor expects the receipt to match the current dirty UI file set, or the same --against <base-ref> delta when used."
73
74
  ])
@@ -7,6 +7,7 @@ import {
7
7
  inspectSessionDiff,
8
8
  inspectSessionDetails,
9
9
  listSessions,
10
+ rewindSession,
10
11
  runSessionStep
11
12
  } from "../sessionRuntime.js";
12
13
 
@@ -270,7 +271,7 @@ async function resolveStepInputs({
270
271
  fileOption: "close-reason-file",
271
272
  inlineOptions,
272
273
  io,
273
- repairCommand: `jskit session ${sessionId} step --close-without-merge --close-reason "<reason>"`,
274
+ repairCommand: `jskit session ${sessionId} step --skip-merge --close-reason "<reason>"`,
274
275
  cwd,
275
276
  sessionId,
276
277
  stdinOption: "-",
@@ -312,10 +313,14 @@ async function resolveStepInputs({
312
313
  function normalizeStepOptions(inlineOptions = {}) {
313
314
  const options = {
314
315
  ...inlineOptions,
315
- closeWithoutMerge: inlineOptions["close-without-merge"] === "true" || inlineOptions.closeWithoutMerge === true,
316
+ closeWithoutMerge: inlineOptions["close-without-merge"] === "true" ||
317
+ inlineOptions.closeWithoutMerge === true ||
318
+ inlineOptions["skip-merge"] === "true" ||
319
+ inlineOptions.skipMerge === true,
316
320
  mergePr: inlineOptions["merge-pr"] === "true" || inlineOptions.mergePr === true,
317
321
  prompt: inlineOptions.prompt,
318
322
  reviewFindings: inlineOptions["review-findings"] || inlineOptions.reviewFindings,
323
+ skipMainSync: inlineOptions["skip-main-sync"] === "true" || inlineOptions.skipMainSync === true,
319
324
  skipUiCheck: inlineOptions["skip-ui-check"] === "true" || inlineOptions.skipUiCheck === true,
320
325
  userCheck: inlineOptions["user-check"] || inlineOptions.userCheck
321
326
  };
@@ -395,6 +400,12 @@ function createSessionCommands() {
395
400
  targetRoot: cwd,
396
401
  sessionId: first
397
402
  });
403
+ } else if (second === "rewind") {
404
+ payload = await rewindSession({
405
+ targetRoot: cwd,
406
+ sessionId: first,
407
+ stepId: inlineOptions.step
408
+ });
398
409
  } else if (second === "adopt-codex-thread") {
399
410
  payload = await adoptCodexThreadId({
400
411
  targetRoot: cwd,
@@ -145,6 +145,14 @@ function parseArgs(argv, { createCliError } = {}) {
145
145
  options.inlineOptions["close-without-merge"] = "true";
146
146
  continue;
147
147
  }
148
+ if (token === "--skip-merge") {
149
+ options.inlineOptions["skip-merge"] = "true";
150
+ continue;
151
+ }
152
+ if (token === "--skip-main-sync") {
153
+ options.inlineOptions["skip-main-sync"] = "true";
154
+ continue;
155
+ }
148
156
 
149
157
  if (token.startsWith("--")) {
150
158
  const withoutPrefix = token.slice(2);
@@ -212,8 +212,8 @@ const COMMAND_DESCRIPTORS = Object.freeze({
212
212
  description: "Create a session, inspect a session, or run a session subcommand."
213
213
  }),
214
214
  Object.freeze({
215
- name: "[step|abandon|adopt-codex-thread]",
216
- description: "Run the next step, inspect a diff, abandon a session, or attach a Codex thread id."
215
+ name: "[step|diff|rewind|abandon|adopt-codex-thread]",
216
+ description: "Run the next step, inspect a diff, rewind a session, abandon a session, or attach a Codex thread id."
217
217
  })
218
218
  ]),
219
219
  defaults: Object.freeze([
@@ -227,13 +227,17 @@ const COMMAND_DESCRIPTORS = Object.freeze({
227
227
  "Use --issue-details - to read confirmed issue details from stdin.",
228
228
  "Use --plan - to read the approved implementation plan from stdin.",
229
229
  "Use --codex-result - after Codex prompt steps to read the final marked Codex result from stdin.",
230
+ "Use rewind --step <step_id> to delete a completed step and later JSKIT-owned session artifacts; only plan_made is allowed inside the repeatable cycle.",
230
231
  "Use --rework-notes - with --user-check failed to start the next plan cycle.",
231
232
  "Use --agent-decisions - to append session-local decision log entries from implementation, UI review, verification, or repair phases.",
232
233
  "Use --review-findings-remaining true --review-findings \"<findings>\" when an accepted review pass needs another pass.",
233
234
  "Use --review-findings-remaining false only when the review/deslop loop is done.",
234
235
  "Use --skip-ui-check --skip-reason \"<reason>\" only when uiImpact is possible and the Deep UI Check is intentionally skipped.",
236
+ "Use --prepare-merge true at PR merge preparation to render the Codex prep prompt.",
237
+ "Use --continue-to-merge true when the user decides to advance from preparation to merge decision.",
235
238
  "Use --merge-pr true at PR finalization to merge the pull request.",
236
- "Use --close-without-merge --close-reason \"<reason>\" at PR finalization to complete the session without merging.",
239
+ "Use --skip-merge at PR finalization to complete the session without merging.",
240
+ "Use --skip-main-sync at Main checkout synced to explicitly leave the main checkout untouched.",
237
241
  "Run the blueprint step once to render its Codex prompt, then again after Codex updates .jskit/APP_BLUEPRINT.md."
238
242
  ]),
239
243
  examples: Object.freeze([
@@ -252,15 +256,19 @@ const COMMAND_DESCRIPTORS = Object.freeze({
252
256
  "jskit session 2026-05-11_21-42-08 step --review-findings-remaining false",
253
257
  "jskit session 2026-05-11_21-42-08 step --skip-ui-check --skip-reason \"No user-facing UI changes\"",
254
258
  "jskit session 2026-05-11_21-42-08 step",
259
+ "jskit session 2026-05-11_21-42-08 step --prepare-merge true",
260
+ "jskit session 2026-05-11_21-42-08 step --continue-to-merge true",
255
261
  "jskit session 2026-05-11_21-42-08 step --merge-pr true",
256
- "jskit session 2026-05-11_21-42-08 step --close-without-merge --close-reason \"Prototype kept for reference\"",
262
+ "jskit session 2026-05-11_21-42-08 step --skip-merge",
263
+ "jskit session 2026-05-11_21-42-08 step --skip-main-sync",
257
264
  "jskit session 2026-05-11_21-42-08 step --user-check failed --rework-notes -",
265
+ "jskit session 2026-05-11_21-42-08 rewind --step plan_made --json",
258
266
  "jskit session 2026-05-11_21-42-08 diff --json"
259
267
  ])
260
268
  })
261
269
  ]),
262
270
  fullUse:
263
- "jskit session [create|<sessionId>] [step|diff|abandon|adopt-codex-thread] [--prompt <text>] [--issue-title <text>|--issue-title-file <path>] [--issue <text>|--issue-file <path>] [--issue-details <text>|--issue-details-file <path>] [--plan <text>|--plan-file <path>] [--codex-result <text>|--codex-result-file <path>] [--agent-decisions <text>|--agent-decisions-file <path>] [--review-findings-remaining true --review-findings <text>|--review-findings-remaining false] [--skip-ui-check --skip-reason <text>] [--merge-pr true|--close-without-merge --close-reason <text>] [--user-check <passed|failed>] [--rework-notes <text>|--rework-notes-file <path>] [--codex-thread-id <id>] [--abandoned|--completed|--all] [--json]",
271
+ "jskit session [create|<sessionId>] [step|diff|rewind|abandon|adopt-codex-thread] [--step <step_id>] [--prompt <text>] [--issue-title <text>|--issue-title-file <path>] [--issue <text>|--issue-file <path>] [--issue-details <text>|--issue-details-file <path>] [--plan <text>|--plan-file <path>] [--codex-result <text>|--codex-result-file <path>] [--agent-decisions <text>|--agent-decisions-file <path>] [--review-findings-remaining true --review-findings <text>|--review-findings-remaining false] [--skip-ui-check --skip-reason <text>] [--prepare-merge true|--continue-to-merge true|--merge-pr true|--skip-merge|--skip-main-sync] [--user-check <passed|failed>] [--rework-notes <text>|--rework-notes-file <path>] [--codex-thread-id <id>] [--abandoned|--completed|--all] [--json]",
264
272
  showHelpOnBareInvocation: false,
265
273
  handlerName: "commandSession",
266
274
  allowedFlagKeys: Object.freeze(["json", "abandoned", "completed", "all"]),
@@ -263,6 +263,12 @@ const BLUEPRINT_CODEX_HANDOFF = codexHandoff([], {
263
263
  promptActionLabel: "Update blueprint",
264
264
  responseContract: JSKIT_STEP_RESULT_CONTRACT
265
265
  });
266
+ const PR_MERGE_PREP_CODEX_HANDOFF = codexHandoff([], {
267
+ autoInject: true,
268
+ promptActionLabel: "Get Codex ready to merge PR",
269
+ promptWaitingText: "Codex is preparing the PR for merge. Continue only when you decide the PR is ready.",
270
+ responseContract: null
271
+ });
266
272
 
267
273
  function defineStep({
268
274
  buttonLabel,
@@ -497,23 +503,53 @@ const STEP_DEFINITIONS = Object.freeze([
497
503
  label: "Branch pushed, PR created",
498
504
  preconditions: ["session_exists", "worktree_exists", "dependencies_installed", "ready_jskit_app", "issue_metadata_exists", "active_cycle_exists", "automated_checks_passed", "deep_ui_check_satisfied", "active_cycle_user_check_passed", "accepted_changes_committed", "blueprint_update_satisfied", "final_report_exists"]
499
505
  }),
506
+ defineStep({
507
+ buttonLabel: "Continue to merge",
508
+ codex: PR_MERGE_PREP_CODEX_HANDOFF,
509
+ description: "User can ask Codex to prepare the pull request for merge, then explicitly continue to the merge decision.",
510
+ id: "pr_merge_prepared",
511
+ label: "PR merge prepared",
512
+ preconditions: ["session_exists", "pr_url_exists", "worktree_exists"],
513
+ requiresExplicitRun: true,
514
+ submitOptions: Object.freeze({
515
+ continueToMerge: true
516
+ }),
517
+ utilityActions: Object.freeze([
518
+ Object.freeze({
519
+ id: "prepare_pr_merge",
520
+ kind: "codex_prompt",
521
+ label: "Get Codex ready to merge PR",
522
+ submitOptions: Object.freeze({
523
+ prepareMerge: true
524
+ })
525
+ })
526
+ ])
527
+ }),
500
528
  defineStep({
501
529
  buttonLabel: "Merge PR",
502
- description: "User chooses whether JSKIT merges the pull request or finishes without merge; JSKIT then removes the session worktree.",
530
+ description: "User chooses whether JSKIT merges the pull request or skips merge; JSKIT records the PR outcome.",
503
531
  id: "pr_finalized",
504
- label: "PR finalized, worktree removed",
532
+ label: "PR finalized",
505
533
  preconditions: ["session_exists", "pr_url_exists", "worktree_exists"],
506
534
  requiresExplicitRun: true,
507
535
  submitOptions: Object.freeze({
508
536
  mergePr: true
509
537
  })
510
538
  }),
539
+ defineStep({
540
+ buttonLabel: "Sync main checkout",
541
+ description: "JSKIT fast-forwards the main checkout after a merged PR, or records an explicit skip before cleanup.",
542
+ id: "main_checkout_synced",
543
+ label: "Main checkout synced",
544
+ preconditions: ["session_exists", "worktree_exists"],
545
+ requiresExplicitRun: true
546
+ }),
511
547
  defineStep({
512
548
  buttonLabel: "Finish session",
513
- description: "JSKIT writes the final receipt and archives the completed session.",
549
+ description: "JSKIT removes the session worktree, writes the final receipt, and archives the completed session.",
514
550
  id: "session_finished",
515
- label: "Session finished",
516
- preconditions: ["session_exists"]
551
+ label: "Worktree removed, session finished",
552
+ preconditions: ["session_exists", "main_checkout_sync_satisfied"]
517
553
  })
518
554
  ]);
519
555
 
@@ -553,6 +589,7 @@ export {
553
589
  DEEP_UI_CHECK_CODEX_HANDOFF,
554
590
  AUTOMATED_CHECK_REPAIR_CODEX_HANDOFF,
555
591
  BLUEPRINT_CODEX_HANDOFF,
592
+ PR_MERGE_PREP_CODEX_HANDOFF,
556
593
  JSKIT_CLI_SHELL_COMMAND,
557
594
  JSKIT_CLI_SHELL_RULE,
558
595
  SESSION_STATE_RELATIVE_PATH
@@ -710,6 +710,33 @@ async function assertPrUrlExists(paths) {
710
710
  };
711
711
  }
712
712
 
713
+ async function assertMainCheckoutSyncSatisfied(paths) {
714
+ const receiptPath = path.join(paths.sessionRoot, "steps", "main_checkout_synced");
715
+ if (await pathExists(receiptPath)) {
716
+ return {
717
+ ok: true,
718
+ precondition: createPrecondition({
719
+ id: "main_checkout_sync_satisfied",
720
+ ok: true,
721
+ message: "Main checkout sync has been completed or skipped."
722
+ })
723
+ };
724
+ }
725
+ return {
726
+ ok: false,
727
+ error: createError({
728
+ code: "main_checkout_sync_required",
729
+ message: "Main checkout sync must be completed or explicitly skipped before session cleanup.",
730
+ repairCommand: jskitCommand(`session ${paths.sessionId} step`)
731
+ }),
732
+ precondition: createPrecondition({
733
+ id: "main_checkout_sync_satisfied",
734
+ ok: false,
735
+ message: "Main checkout sync has been completed or skipped."
736
+ })
737
+ };
738
+ }
739
+
713
740
  export {
714
741
  applyPreconditions,
715
742
  assertAcceptedChangesCommitted,
@@ -728,6 +755,7 @@ export {
728
755
  assertIssueUrlExists,
729
756
  assertAutomatedChecksPassed,
730
757
  assertIssueDetailsExists,
758
+ assertMainCheckoutSyncSatisfied,
731
759
  assertPlanTextExists,
732
760
  assertPrUrlExists,
733
761
  assertReadyJskitApp,
@@ -34,6 +34,8 @@ Use Playwright for a meaningful route check when possible. If login is required,
34
34
 
35
35
  `npx --no-install jskit app verify-ui --command "<playwright command>" --feature "<label>" --auth-mode <mode>`
36
36
 
37
+ Important: `npx --no-install jskit app verify-ui` does not start the app server. Before running it, make sure the app is reachable by Playwright. If a local server is needed, inspect `.jskit/config/testrun_command` when present and use the app's documented server command, or start the server explicitly and wait for it before invoking `verify-ui`. Do not first run a bare Playwright command against a stopped server.
38
+
37
39
  Do not create commits, branches, issues, pull requests, merges, or worktree cleanup. JSKIT session owns those steps.
38
40
 
39
41
  If this pass makes UI fixes, intentionally skips UI work after inspection, changes a design direction, or leaves a meaningful UI verification gap, include concise decision entries with reasons in this exact marker block:
@@ -41,6 +41,7 @@ After making changes:
41
41
  - Review for repeated code, unnecessary helpers, fragmented functions, placeholder work, missing states, broken route wiring, ownership mistakes, and weak JSKIT reuse.
42
42
  - Run the smallest relevant checks you can run safely in the worktree.
43
43
  - For changed user-facing UI, run or clearly identify the Playwright verification path. When possible, record UI verification with `npx --no-install jskit app verify-ui --command "<playwright command>" --feature "<label>" --auth-mode <mode>`.
44
+ - `npx --no-install jskit app verify-ui` does not start the app server. Before using it for Playwright, make sure the app is reachable. If a local server is needed, inspect `.jskit/config/testrun_command` when present and use the app's documented server command, or start the server explicitly and wait for it before invoking `verify-ui`. Do not first run a bare Playwright command against a stopped server.
44
45
  - Summarize changed files and checks run.
45
46
  - If implementation deviated from the approved plan, generator choices, package ownership, helper reuse, UI verification path, or data ownership, include concise decision entries with reasons in this exact marker block:
46
47
 
@@ -0,0 +1,22 @@
1
+ Prepare the JSKIT session pull request for merge.
2
+
3
+ Session: {{session_id}}
4
+ Worktree: {{worktree}}
5
+ Target root: {{target_root}}
6
+ Branch: {{branch}}
7
+ Base branch: {{base_branch}}
8
+ Pull request: {{pr_url}}
9
+ Issue: {{issue_url}}
10
+ Final report: {{final_report_file}}
11
+
12
+ Your job is to get the pull request into a state where the user can decide whether to press the JSKIT Merge PR button.
13
+
14
+ Required boundaries:
15
+
16
+ - Inspect the pull request state, checks, and branch status.
17
+ - Resolve merge conflicts or failing checks if the fix is clear and in scope.
18
+ - Commit and push any preparation changes to the session branch.
19
+ - Do not merge the pull request.
20
+ - Do not remove the worktree, archive the session, update the local base branch, or write JSKIT receipts. JSKIT owns deterministic cleanup after the user decides.
21
+
22
+ When you are done, summarize what you checked, what changed, and whether you believe the PR is ready for the user to merge.
@@ -51,6 +51,7 @@ Use four passes:
51
51
  - run the smallest relevant verification commands for the changed scope
52
52
  - any changed user-facing UI should be exercised with Playwright when possible
53
53
  - UI verification should normally be recorded through `npx --no-install jskit app verify-ui`
54
+ - `npx --no-install jskit app verify-ui` does not start the app server; if Playwright needs a local server, inspect `.jskit/config/testrun_command` when present and start or wrap the documented server command before invoking `verify-ui`
54
55
  - if login is required, use the chosen local test-auth path instead of live external auth
55
56
  - if there is no usable local auth bootstrap path, record it as a blocking testability gap
56
57
 
@@ -241,6 +241,7 @@ const PROMPT_ARTIFACT_BY_STEP_ID = Object.freeze({
241
241
  deep_ui_check_run: "deep_ui_check_run.md",
242
242
  automated_checks_run: "automated_checks_run.md",
243
243
  blueprint_updated: "update_blueprint.md",
244
+ pr_merge_prepared: "prepare_pr_merge.md",
244
245
  user_check_completed: "user_check.md"
245
246
  });
246
247
 
@@ -589,7 +590,8 @@ function buildStepDefinitions() {
589
590
  function stepIsRetryableWhenBlocked(stepId) {
590
591
  return [
591
592
  "automated_checks_run",
592
- "deep_ui_check_run"
593
+ "deep_ui_check_run",
594
+ "main_checkout_synced"
593
595
  ].includes(normalizeStepId(stepId));
594
596
  }
595
597
 
@@ -681,17 +683,12 @@ function buildCurrentStepAction(stepId, artifacts = {}) {
681
683
  }
682
684
  if (step.id === "pr_finalized") {
683
685
  alternateActions.push({
684
- id: "close_without_merge",
685
- helpText: "Leave the PR open, record the reason, and remove the session worktree without merging.",
686
+ id: "skip_merge",
687
+ helpText: "Leave the PR open and record the skipped-merge outcome; final cleanup removes the session worktree.",
686
688
  input: {
687
- formatHint: "markdown",
688
- label: "Why is this session finishing without merge?",
689
- multiline: true,
690
- name: "closeReason",
691
- required: true,
692
- type: "text"
689
+ type: "none"
693
690
  },
694
- label: "Finish without merge",
691
+ label: "Skip merge",
695
692
  presentation: "secondary",
696
693
  submitOptions: {
697
694
  closeWithoutMerge: true
@@ -699,6 +696,21 @@ function buildCurrentStepAction(stepId, artifacts = {}) {
699
696
  targetStep: "pr_finalized"
700
697
  });
701
698
  }
699
+ if (step.id === "main_checkout_synced") {
700
+ alternateActions.push({
701
+ id: "skip_main_checkout_sync",
702
+ helpText: "Leave the main checkout untouched and continue with session cleanup.",
703
+ input: {
704
+ type: "none"
705
+ },
706
+ label: "Skip sync",
707
+ presentation: "secondary",
708
+ submitOptions: {
709
+ skipMainSync: true
710
+ },
711
+ targetStep: "main_checkout_synced"
712
+ });
713
+ }
702
714
  const dynamicButtonLabel = (() => {
703
715
  if (step.id === "plan_executed" && planExecutionPrompted && !planExecutionSubmitted) {
704
716
  return "Go to next step";
@@ -712,6 +724,9 @@ function buildCurrentStepAction(stepId, artifacts = {}) {
712
724
  if (step.id === "plan_made" && planReworkMode && !artifacts.prompt && hasActiveReworkRequest) {
713
725
  return "Get Codex to create revised plan";
714
726
  }
727
+ if (step.id === "main_checkout_synced" && artifacts.prOutcome?.outcome && artifacts.prOutcome.outcome !== "merged") {
728
+ return "Record no sync needed";
729
+ }
715
730
  return buttonLabel;
716
731
  })();
717
732
  const dynamicDescription = (() => {
@@ -727,6 +742,9 @@ function buildCurrentStepAction(stepId, artifacts = {}) {
727
742
  if (step.id === "plan_made" && planReworkMode && hasActiveReworkRequest) {
728
743
  return "Codex writes a revised implementation plan from the user's rework notes for this cycle.";
729
744
  }
745
+ if (step.id === "main_checkout_synced" && artifacts.prOutcome?.outcome && artifacts.prOutcome.outcome !== "merged") {
746
+ return "The PR was not merged, so JSKIT will record main checkout sync as skipped before cleanup.";
747
+ }
730
748
  return step.description;
731
749
  })();
732
750
  const dynamicUtilityActions = (() => {
@@ -795,7 +813,8 @@ async function readSessionArtifacts(paths) {
795
813
  baseCommit,
796
814
  issueMetadataText,
797
815
  planExecutionReceipt,
798
- prOutcomeText
816
+ prOutcomeText,
817
+ mainCheckoutSyncText
799
818
  ] = await Promise.all([
800
819
  readTrimmedFile(path.join(paths.sessionRoot, "status")),
801
820
  readTrimmedFile(path.join(paths.sessionRoot, "current_step")),
@@ -814,7 +833,8 @@ async function readSessionArtifacts(paths) {
814
833
  readTrimmedFile(path.join(paths.sessionRoot, "base_commit")),
815
834
  readTextIfExists(path.join(paths.sessionRoot, "issue_metadata.json")),
816
835
  readTextIfExists(path.join(cycleStepsRoot(paths, activeCycle), "plan_executed")),
817
- readTextIfExists(path.join(paths.sessionRoot, "pr_outcome.json"))
836
+ readTextIfExists(path.join(paths.sessionRoot, "pr_outcome.json")),
837
+ readTextIfExists(path.join(paths.sessionRoot, "main_checkout_sync.json"))
818
838
  ]);
819
839
  let issueMetadata = null;
820
840
  if (issueMetadataText) {
@@ -842,6 +862,15 @@ async function readSessionArtifacts(paths) {
842
862
  prOutcome = null;
843
863
  }
844
864
  }
865
+ let mainCheckoutSync = null;
866
+ if (mainCheckoutSyncText) {
867
+ try {
868
+ const parsed = JSON.parse(mainCheckoutSyncText);
869
+ mainCheckoutSync = parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : null;
870
+ } catch {
871
+ mainCheckoutSync = null;
872
+ }
873
+ }
845
874
  const cycles = await readCycles(paths, activeCycle);
846
875
  const checks = await readStructuredChecks(paths);
847
876
  const uiChecks = await readStructuredUiChecks(paths);
@@ -858,7 +887,7 @@ async function readSessionArtifacts(paths) {
858
887
  const helperMapPath = path.join(appRootForArtifacts, ".jskit", "helper-map.md");
859
888
  const currentStep = normalizeStepId(rawCurrentStep);
860
889
  let completedSteps = await readCompletedSteps(paths);
861
- const worktreeRemovalCompleted = completedSteps.includes("pr_finalized");
890
+ const worktreeRemovalCompleted = completedSteps.includes("session_finished");
862
891
  const worktreeReceiptInvalid = !worktreeReady &&
863
892
  completedSteps.includes("worktree_created") &&
864
893
  !worktreeRemovalCompleted &&
@@ -919,6 +948,7 @@ async function readSessionArtifacts(paths) {
919
948
  nextStep,
920
949
  prUrl,
921
950
  prOutcome,
951
+ mainCheckoutSync,
922
952
  planExecution: {
923
953
  prompted: planExecutionPromptExists,
924
954
  promptPath: planExecutionPromptExists ? planExecutionPromptPath : "",
@@ -1001,6 +1031,7 @@ async function buildSessionResponse(paths, {
1001
1031
  helperMapExists: artifacts.helperMapExists === true,
1002
1032
  prUrl: artifacts.prUrl || "",
1003
1033
  prOutcome: cloneContractValue(artifacts.prOutcome || null),
1034
+ mainCheckoutSync: cloneContractValue(artifacts.mainCheckoutSync || null),
1004
1035
  preconditions,
1005
1036
  errors: [
1006
1037
  createError({
@@ -1067,6 +1098,7 @@ async function buildSessionResponse(paths, {
1067
1098
  helperMapExists: artifacts.helperMapExists === true,
1068
1099
  prUrl: artifacts.prUrl || "",
1069
1100
  prOutcome: cloneContractValue(artifacts.prOutcome || null),
1101
+ mainCheckoutSync: cloneContractValue(artifacts.mainCheckoutSync || null),
1070
1102
  preconditions,
1071
1103
  errors,
1072
1104
  archive: responsePaths.archive || (resolvedStatus === SESSION_STATUS.FINISHED ? "completed" : resolvedStatus === SESSION_STATUS.ABANDONED ? "abandoned" : "active"),
@@ -3,6 +3,7 @@ import {
3
3
  mkdir,
4
4
  readFile,
5
5
  readdir,
6
+ rm,
6
7
  rmdir
7
8
  } from "node:fs/promises";
8
9
  import path from "node:path";
@@ -13,6 +14,7 @@ import {
13
14
  DEEP_UI_CHECK_CODEX_HANDOFF,
14
15
  ISSUE_DETAILS_CODEX_HANDOFF,
15
16
  PLAN_EXECUTION_CODEX_HANDOFF,
17
+ PR_MERGE_PREP_CODEX_HANDOFF,
16
18
  REVIEW_PASS_LIMIT,
17
19
  REVIEW_EXECUTION_CODEX_HANDOFF,
18
20
  SESSION_STATUS,
@@ -78,6 +80,7 @@ import {
78
80
  assertIssueUrlExists,
79
81
  assertAutomatedChecksPassed,
80
82
  assertIssueDetailsExists,
83
+ assertMainCheckoutSyncSatisfied,
81
84
  assertPlanTextExists,
82
85
  assertPrUrlExists,
83
86
  assertReadyJskitApp,
@@ -96,6 +99,9 @@ import {
96
99
  HELPER_MAP_MARKDOWN_RELATIVE_PATH
97
100
  } from "./helperMapPaths.js";
98
101
 
102
+ const SESSION_PROVISION_PACKAGE_SCRIPT = "jskit:provision-session";
103
+ const SESSION_FINALIZATION_GUARD_PACKAGE_SCRIPT = "jskit:finalization-guard";
104
+
99
105
  function invalidSessionIdError(sessionId = "") {
100
106
  return createError({
101
107
  code: "invalid_session_id",
@@ -355,6 +361,125 @@ async function runLoggedCommand(paths, kind, command, args = [], options = {}) {
355
361
  return result;
356
362
  }
357
363
 
364
+ async function readWorktreePackageJson(worktree) {
365
+ const source = await readTextIfExists(path.join(worktree, "package.json"));
366
+ if (!source) {
367
+ return {};
368
+ }
369
+ try {
370
+ const parsed = JSON.parse(source);
371
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
372
+ } catch {
373
+ return {};
374
+ }
375
+ }
376
+
377
+ function packageScriptRunArgs(packageManager, scriptName) {
378
+ if (packageManager === "pnpm") {
379
+ return ["pnpm", ["run", scriptName]];
380
+ }
381
+ if (packageManager === "yarn") {
382
+ return ["yarn", ["run", scriptName]];
383
+ }
384
+ if (packageManager === "bun") {
385
+ return ["bun", ["run", scriptName]];
386
+ }
387
+ return ["npm", ["run", "--silent", scriptName]];
388
+ }
389
+
390
+ async function packageScriptCommandForWorktree(worktree, scriptName, {
391
+ preferredPackageManager = ""
392
+ } = {}) {
393
+ const packageJson = await readWorktreePackageJson(worktree);
394
+ const script = packageJson?.scripts?.[scriptName];
395
+ if (typeof script !== "string" || !normalizeText(script)) {
396
+ return null;
397
+ }
398
+ const packageManager = preferredPackageManager || (await dependencyInstallCommandForWorktree(worktree))[0];
399
+ const [command, args] = packageScriptRunArgs(packageManager, scriptName);
400
+ return {
401
+ args,
402
+ command
403
+ };
404
+ }
405
+
406
+ function sessionPackageScriptEnv(paths, scriptName) {
407
+ return {
408
+ JSKIT_SESSION_ID: paths.sessionId,
409
+ JSKIT_SESSION_PACKAGE_SCRIPT: scriptName,
410
+ JSKIT_SESSION_ROOT: paths.sessionRoot,
411
+ JSKIT_TARGET_ROOT: paths.targetRoot,
412
+ JSKIT_WORKTREE_ROOT: paths.worktree
413
+ };
414
+ }
415
+
416
+ function packageScriptRepairCommand(paths, command, args) {
417
+ return `cd ${paths.worktree} && ${command} ${args.join(" ")}`;
418
+ }
419
+
420
+ function packageScriptReceiptName(scriptName) {
421
+ return normalizeText(scriptName).replace(/[^a-zA-Z0-9._-]+/gu, "_");
422
+ }
423
+
424
+ async function writeSessionHookReceipt(paths, scriptName, message) {
425
+ await writeTextFile(
426
+ path.join(paths.sessionRoot, "hooks", packageScriptReceiptName(scriptName)),
427
+ `${timestampForReceipt()}\n${normalizeText(message) || `${scriptName} completed.`}`
428
+ );
429
+ }
430
+
431
+ async function runOptionalSessionPackageScript(paths, {
432
+ failureCode,
433
+ failureMessage,
434
+ kind,
435
+ preferredPackageManager = "",
436
+ preconditions = [],
437
+ scriptName,
438
+ timeout = 1000 * 60 * 10
439
+ } = {}) {
440
+ const scriptCommand = await packageScriptCommandForWorktree(paths.worktree, scriptName, {
441
+ preferredPackageManager
442
+ });
443
+ if (!scriptCommand) {
444
+ return {
445
+ ok: true,
446
+ ran: false
447
+ };
448
+ }
449
+ const result = await runLoggedCommand(paths, kind, scriptCommand.command, scriptCommand.args, {
450
+ cwd: paths.worktree,
451
+ env: sessionPackageScriptEnv(paths, scriptName),
452
+ timeout
453
+ });
454
+ if (!result.ok) {
455
+ return {
456
+ ok: false,
457
+ response: await failSession(paths, {
458
+ code: failureCode,
459
+ message: result.output || failureMessage,
460
+ preconditions,
461
+ repairCommand: packageScriptRepairCommand(paths, scriptCommand.command, scriptCommand.args)
462
+ })
463
+ };
464
+ }
465
+ await writeSessionHookReceipt(paths, scriptName, result.output || `${scriptName} completed.`);
466
+ return {
467
+ ok: true,
468
+ ran: true,
469
+ result
470
+ };
471
+ }
472
+
473
+ async function runSessionFinalizationGuard(paths, preconditions = []) {
474
+ return runOptionalSessionPackageScript(paths, {
475
+ failureCode: "session_finalization_guard_failed",
476
+ failureMessage: `${SESSION_FINALIZATION_GUARD_PACKAGE_SCRIPT} failed in the session worktree.`,
477
+ kind: "session_finalization_guard",
478
+ preconditions,
479
+ scriptName: SESSION_FINALIZATION_GUARD_PACKAGE_SCRIPT
480
+ });
481
+ }
482
+
358
483
  async function readIssueMetadata(paths) {
359
484
  const source = await readTextIfExists(path.join(paths.sessionRoot, "issue_metadata.json"));
360
485
  if (!source) {
@@ -820,8 +945,20 @@ async function installDependencies(paths, _options = {}, context = {}) {
820
945
  preconditions
821
946
  });
822
947
  }
948
+ const provisionResult = await runOptionalSessionPackageScript(paths, {
949
+ failureCode: "session_provision_failed",
950
+ failureMessage: `${SESSION_PROVISION_PACKAGE_SCRIPT} failed in the session worktree.`,
951
+ kind: "session_provision",
952
+ preferredPackageManager: command,
953
+ preconditions,
954
+ scriptName: SESSION_PROVISION_PACKAGE_SCRIPT
955
+ });
956
+ if (!provisionResult.ok) {
957
+ return provisionResult.response;
958
+ }
959
+ const installMessage = result.output || `Installed Node dependencies in the session worktree with ${command} ${args.join(" ")}.`;
823
960
  return recordDependenciesInstalled(paths, {
824
- message: result.output || `Installed Node dependencies in the session worktree with ${command} ${args.join(" ")}.`,
961
+ message: provisionResult.ran ? `${installMessage}\n${SESSION_PROVISION_PACKAGE_SCRIPT} completed.` : installMessage,
825
962
  preconditions
826
963
  });
827
964
  }
@@ -1282,6 +1419,333 @@ async function inspectSessionDiff({
1282
1419
  });
1283
1420
  }
1284
1421
 
1422
+ const FIRST_REWINDABLE_STEP_ID = "dependencies_installed";
1423
+ const CYCLE_REWIND_TARGET_STEP_ID = "plan_made";
1424
+ const REWIND_CLOSED_STATUSES = Object.freeze([
1425
+ SESSION_STATUS.ABANDONED,
1426
+ SESSION_STATUS.FINISHED
1427
+ ]);
1428
+
1429
+ async function removeSessionPath(paths, ...parts) {
1430
+ await rm(path.join(paths.sessionRoot, ...parts), {
1431
+ force: true,
1432
+ recursive: true
1433
+ });
1434
+ }
1435
+
1436
+ async function removeSessionRootFile(paths, fileName) {
1437
+ await removeSessionPath(paths, fileName);
1438
+ }
1439
+
1440
+ async function removePromptArtifact(paths, fileName) {
1441
+ await removeSessionPath(paths, "prompts", fileName);
1442
+ }
1443
+
1444
+ async function removeGlobalCodexResult(paths, stepId) {
1445
+ await removeSessionPath(paths, "codex_results", `${stepId}.md`);
1446
+ }
1447
+
1448
+ async function removeGithubCommentPurpose(paths, purpose) {
1449
+ const comments = await readGithubComments(paths);
1450
+ if (!Object.hasOwn(comments, purpose)) {
1451
+ return;
1452
+ }
1453
+ delete comments[purpose];
1454
+ if (Object.keys(comments).length === 0) {
1455
+ await removeSessionRootFile(paths, "github_comments.json");
1456
+ return;
1457
+ }
1458
+ await writeGithubComments(paths, comments);
1459
+ }
1460
+
1461
+ async function removeIssueDetailsMetadata(paths) {
1462
+ const metadata = await readIssueMetadata(paths);
1463
+ if (Object.keys(metadata).length === 0) {
1464
+ return;
1465
+ }
1466
+ delete metadata.issueCategory;
1467
+ delete metadata.issueDetailsPath;
1468
+ delete metadata.uiImpact;
1469
+ if (Object.keys(metadata).length === 0) {
1470
+ await removeSessionRootFile(paths, "issue_metadata.json");
1471
+ return;
1472
+ }
1473
+ await writeTextFile(path.join(paths.sessionRoot, "issue_metadata.json"), `${JSON.stringify(metadata, null, 2)}\n`);
1474
+ }
1475
+
1476
+ async function removeCycleDirectories(paths) {
1477
+ for (const rootName of ["steps", "cycles"]) {
1478
+ const root = path.join(paths.sessionRoot, rootName);
1479
+ let entries = [];
1480
+ try {
1481
+ entries = await readdir(root, { withFileTypes: true });
1482
+ } catch {
1483
+ entries = [];
1484
+ }
1485
+ await Promise.all(entries
1486
+ .filter((entry) => entry.isDirectory() && /^cycle_\d+$/u.test(entry.name))
1487
+ .map((entry) => rm(path.join(root, entry.name), {
1488
+ force: true,
1489
+ recursive: true
1490
+ })));
1491
+ }
1492
+ }
1493
+
1494
+ async function removeCyclePromptArtifacts(paths) {
1495
+ const promptsRoot = path.join(paths.sessionRoot, "prompts");
1496
+ let entries = [];
1497
+ try {
1498
+ entries = await readdir(promptsRoot, { withFileTypes: true });
1499
+ } catch {
1500
+ entries = [];
1501
+ }
1502
+ const cyclePromptPattern = /^cycle_\d+_(?:plan_request|plan_execution)\.md$/u;
1503
+ const cyclePromptFiles = entries
1504
+ .filter((entry) => entry.isFile() && cyclePromptPattern.test(entry.name))
1505
+ .map((entry) => entry.name);
1506
+ await Promise.all([
1507
+ ...cyclePromptFiles.map((fileName) => removePromptArtifact(paths, fileName)),
1508
+ removePromptArtifact(paths, "automated_checks_run.md"),
1509
+ removePromptArtifact(paths, "deep_ui_check_run.md"),
1510
+ removePromptArtifact(paths, "review.md"),
1511
+ removePromptArtifact(paths, "user_check.md")
1512
+ ]);
1513
+ }
1514
+
1515
+ async function cancelAllCycleState(paths) {
1516
+ await Promise.all([
1517
+ removeCycleDirectories(paths),
1518
+ removeCyclePromptArtifacts(paths),
1519
+ removeSessionPath(paths, "checks"),
1520
+ removeSessionPath(paths, "ui_checks"),
1521
+ removeSessionPath(paths, "review_passes")
1522
+ ]);
1523
+ await writeActiveCycle(paths, "001");
1524
+ }
1525
+
1526
+ const STEP_CANCELERS = Object.freeze({
1527
+ dependencies_installed: async () => {},
1528
+ issue_prompt_rendered: async (paths) => {
1529
+ await removePromptArtifact(paths, "issue_draft.md");
1530
+ },
1531
+ issue_drafted: async (paths) => {
1532
+ await Promise.all([
1533
+ removeSessionRootFile(paths, "issue.md"),
1534
+ removeSessionRootFile(paths, "issue_title")
1535
+ ]);
1536
+ },
1537
+ issue_created: async (paths) => {
1538
+ await Promise.all([
1539
+ removeSessionRootFile(paths, "issue_url"),
1540
+ removeSessionRootFile(paths, "issue_metadata.json")
1541
+ ]);
1542
+ },
1543
+ issue_details_gathered: async (paths) => {
1544
+ await Promise.all([
1545
+ removePromptArtifact(paths, "issue_details.md"),
1546
+ removeSessionRootFile(paths, "issue_details.md"),
1547
+ removeGithubCommentPurpose(paths, "issue_details"),
1548
+ removeIssueDetailsMetadata(paths)
1549
+ ]);
1550
+ },
1551
+ plan_made: cancelAllCycleState,
1552
+ plan_executed: cancelAllCycleState,
1553
+ deep_ui_check_run: cancelAllCycleState,
1554
+ review_prompt_rendered: cancelAllCycleState,
1555
+ review_changes_accepted: cancelAllCycleState,
1556
+ automated_checks_run: cancelAllCycleState,
1557
+ user_check_completed: cancelAllCycleState,
1558
+ changes_committed: async (paths) => {
1559
+ await removeSessionRootFile(paths, "changes_committed.json");
1560
+ },
1561
+ blueprint_updated: async (paths) => {
1562
+ await Promise.all([
1563
+ removePromptArtifact(paths, "update_blueprint.md"),
1564
+ removeGlobalCodexResult(paths, "blueprint_updated")
1565
+ ]);
1566
+ },
1567
+ final_report_created: async (paths) => {
1568
+ await Promise.all([
1569
+ removeSessionRootFile(paths, "final_report.md"),
1570
+ removeGithubCommentPurpose(paths, "final_report")
1571
+ ]);
1572
+ },
1573
+ pr_created: async (paths) => {
1574
+ await Promise.all([
1575
+ removePromptArtifact(paths, "pr_create_failure.md"),
1576
+ removeSessionRootFile(paths, "pr_body.md"),
1577
+ removeSessionRootFile(paths, "pr_url")
1578
+ ]);
1579
+ },
1580
+ pr_merge_prepared: async (paths) => {
1581
+ await removePromptArtifact(paths, "prepare_pr_merge.md");
1582
+ },
1583
+ pr_finalized: async (paths) => {
1584
+ await Promise.all([
1585
+ removePromptArtifact(paths, "pr_merge_failure.md"),
1586
+ removeSessionRootFile(paths, "pr_base_branch"),
1587
+ removeSessionRootFile(paths, "pr_merge_completed"),
1588
+ removeSessionRootFile(paths, "pr_outcome.json")
1589
+ ]);
1590
+ },
1591
+ main_checkout_synced: async (paths) => {
1592
+ await Promise.all([
1593
+ removeSessionRootFile(paths, "local_base_updated"),
1594
+ removeSessionRootFile(paths, "main_checkout_sync.json")
1595
+ ]);
1596
+ },
1597
+ session_finished: async (paths) => {
1598
+ await Promise.all([
1599
+ removeSessionRootFile(paths, "final_comment.md")
1600
+ ]);
1601
+ }
1602
+ });
1603
+
1604
+ function targetRequiresCycleReset(stepId) {
1605
+ const targetIndex = STEP_IDS.indexOf(stepId);
1606
+ const planIndex = STEP_IDS.indexOf(CYCLE_REWIND_TARGET_STEP_ID);
1607
+ return targetIndex >= 0 && targetIndex <= planIndex;
1608
+ }
1609
+
1610
+ function targetIsAllowedRewindStep(stepId) {
1611
+ if (!STEP_IDS.includes(stepId)) {
1612
+ return false;
1613
+ }
1614
+ if (stepId === "session_created" || stepId === "worktree_created") {
1615
+ return false;
1616
+ }
1617
+ if (CYCLE_STEP_IDS.includes(stepId)) {
1618
+ return stepId === CYCLE_REWIND_TARGET_STEP_ID;
1619
+ }
1620
+ return STEP_IDS.indexOf(stepId) >= STEP_IDS.indexOf(FIRST_REWINDABLE_STEP_ID);
1621
+ }
1622
+
1623
+ function deletedStepIdsForRewindTarget(stepId) {
1624
+ const targetIndex = STEP_IDS.indexOf(stepId);
1625
+ return targetIndex < 0 ? [] : STEP_IDS.slice(targetIndex);
1626
+ }
1627
+
1628
+ async function removeReceiptsForDeletedSteps(paths, deletedStepIds) {
1629
+ await Promise.all(deletedStepIds
1630
+ .filter((stepId) => !CYCLE_STEP_IDS.includes(stepId))
1631
+ .map((stepId) => removeSessionPath(paths, "steps", stepId)));
1632
+ }
1633
+
1634
+ async function cancelDeletedStepArtifacts(paths, deletedStepIds, {
1635
+ cycleReset = false
1636
+ } = {}) {
1637
+ const cancelerIds = cycleReset
1638
+ ? deletedStepIds.filter((stepId) => !CYCLE_STEP_IDS.includes(stepId)).concat(CYCLE_REWIND_TARGET_STEP_ID)
1639
+ : deletedStepIds;
1640
+ const calledCycleCancel = new Set();
1641
+ for (const stepId of cancelerIds) {
1642
+ const canceler = STEP_CANCELERS[stepId];
1643
+ if (typeof canceler !== "function") {
1644
+ continue;
1645
+ }
1646
+ if (CYCLE_STEP_IDS.includes(stepId)) {
1647
+ if (calledCycleCancel.has(CYCLE_REWIND_TARGET_STEP_ID)) {
1648
+ continue;
1649
+ }
1650
+ calledCycleCancel.add(CYCLE_REWIND_TARGET_STEP_ID);
1651
+ }
1652
+ await canceler(paths);
1653
+ }
1654
+ }
1655
+
1656
+ async function rewindSession({
1657
+ targetRoot = process.cwd(),
1658
+ sessionId,
1659
+ stepId
1660
+ } = {}) {
1661
+ return withExistingSession({ targetRoot, sessionId }, async (paths) => {
1662
+ const artifacts = await readSessionArtifacts(paths);
1663
+ const normalizedStepId = normalizeText(stepId);
1664
+ const currentStatus = artifacts.status || SESSION_STATUS.PENDING;
1665
+
1666
+ if (paths.archive && paths.archive !== "active") {
1667
+ return buildSessionResponse(paths, {
1668
+ ok: false,
1669
+ errors: [
1670
+ createError({
1671
+ code: "session_archived_read_only",
1672
+ message: `Session ${paths.sessionId} is archived and cannot be rewound.`
1673
+ })
1674
+ ],
1675
+ status: currentStatus
1676
+ });
1677
+ }
1678
+
1679
+ if (REWIND_CLOSED_STATUSES.includes(currentStatus)) {
1680
+ return buildSessionResponse(paths, {
1681
+ ok: false,
1682
+ errors: [
1683
+ createError({
1684
+ code: "session_closed_read_only",
1685
+ message: `Session ${paths.sessionId} is ${currentStatus} and cannot be rewound.`
1686
+ })
1687
+ ],
1688
+ status: currentStatus
1689
+ });
1690
+ }
1691
+
1692
+ if (artifacts.workflowVersion !== SESSION_WORKFLOW_VERSION) {
1693
+ return buildSessionResponse(paths, {
1694
+ ok: false,
1695
+ errors: [
1696
+ createError({
1697
+ code: "unsupported_workflow_version",
1698
+ message: `Session ${paths.sessionId} uses workflow version ${artifacts.workflowVersion || "unknown"}, but this JSKIT runtime expects ${SESSION_WORKFLOW_VERSION}.`
1699
+ })
1700
+ ],
1701
+ status: SESSION_STATUS.BLOCKED
1702
+ });
1703
+ }
1704
+
1705
+ if (!targetIsAllowedRewindStep(normalizedStepId)) {
1706
+ const cycleHint = CYCLE_STEP_IDS.includes(normalizedStepId)
1707
+ ? " Only Plan made can be used as a cycle rewind target; it resets all cycle/rework state."
1708
+ : "";
1709
+ return buildSessionResponse(paths, {
1710
+ ok: false,
1711
+ errors: [
1712
+ createError({
1713
+ code: "rewind_step_not_allowed",
1714
+ message: `Cannot rewind session ${paths.sessionId} to ${normalizedStepId || "(missing)"}.${cycleHint}`
1715
+ })
1716
+ ],
1717
+ status: currentStatus
1718
+ });
1719
+ }
1720
+
1721
+ if (!artifacts.completedSteps.includes(normalizedStepId)) {
1722
+ return buildSessionResponse(paths, {
1723
+ ok: false,
1724
+ errors: [
1725
+ createError({
1726
+ code: "rewind_step_not_completed",
1727
+ message: `Cannot rewind session ${paths.sessionId} to ${normalizedStepId} because that step is not completed.`
1728
+ })
1729
+ ],
1730
+ status: currentStatus
1731
+ });
1732
+ }
1733
+
1734
+ const deletedStepIds = deletedStepIdsForRewindTarget(normalizedStepId);
1735
+ const cycleReset = targetRequiresCycleReset(normalizedStepId);
1736
+ await removeReceiptsForDeletedSteps(paths, deletedStepIds);
1737
+ await cancelDeletedStepArtifacts(paths, deletedStepIds, { cycleReset });
1738
+ if (cycleReset) {
1739
+ await writeActiveCycle(paths, "001");
1740
+ }
1741
+ await markCurrentStep(paths, normalizedStepId);
1742
+ await markStatus(paths, SESSION_STATUS.PENDING);
1743
+ return buildSessionResponse(paths, {
1744
+ status: SESSION_STATUS.PENDING
1745
+ });
1746
+ });
1747
+ }
1748
+
1285
1749
  async function commitWorktree(paths, {
1286
1750
  message,
1287
1751
  allowNoChanges = false
@@ -2118,6 +2582,17 @@ async function writePrOutcome(paths, outcome) {
2118
2582
  }, null, 2)}\n`);
2119
2583
  }
2120
2584
 
2585
+ function mainCheckoutSyncPath(paths) {
2586
+ return path.join(paths.sessionRoot, "main_checkout_sync.json");
2587
+ }
2588
+
2589
+ async function writeMainCheckoutSync(paths, payload = {}) {
2590
+ await writeTextFile(mainCheckoutSyncPath(paths), `${JSON.stringify({
2591
+ recordedAt: timestampForReceipt(),
2592
+ ...payload
2593
+ }, null, 2)}\n`);
2594
+ }
2595
+
2121
2596
  async function assertTargetRootCanUpdateBase(paths, branch) {
2122
2597
  const cleanFailure = await assertTargetRootCleanForBaseUpdate(paths);
2123
2598
  if (cleanFailure) {
@@ -2136,50 +2611,6 @@ async function assertTargetRootCanUpdateBase(paths, branch) {
2136
2611
  return null;
2137
2612
  }
2138
2613
 
2139
- async function assertTargetBaseCanFastForward(paths, branch) {
2140
- const branchFailure = await assertTargetRootCanUpdateBase(paths, branch);
2141
- if (branchFailure) {
2142
- return branchFailure;
2143
- }
2144
-
2145
- const fetchResult = await runLoggedCommand(paths, "git_fetch_origin", "git", ["fetch", "origin"], {
2146
- cwd: paths.targetRoot,
2147
- timeout: 1000 * 60 * 5
2148
- });
2149
- if (!fetchResult.ok) {
2150
- return failSession(paths, {
2151
- code: "target_fetch_failed",
2152
- message: fetchResult.output || `Failed to fetch origin before checking local ${branch}.`,
2153
- repairCommand: `git -C ${paths.targetRoot} fetch origin`
2154
- });
2155
- }
2156
-
2157
- const remoteBranch = `origin/${branch}`;
2158
- const remoteExists = await runGit(paths.targetRoot, ["rev-parse", "--verify", remoteBranch], {
2159
- timeout: 15000
2160
- });
2161
- if (!remoteExists.ok) {
2162
- return failSession(paths, {
2163
- code: "target_remote_branch_missing",
2164
- message: `Remote base branch ${remoteBranch} does not exist; JSKIT cannot safely update local ${branch} after merge.`,
2165
- repairCommand: `git -C ${paths.targetRoot} fetch origin`
2166
- });
2167
- }
2168
-
2169
- const ancestor = await runGit(paths.targetRoot, ["merge-base", "--is-ancestor", branch, remoteBranch], {
2170
- timeout: 15000
2171
- });
2172
- if (!ancestor.ok) {
2173
- return failSession(paths, {
2174
- code: "target_branch_not_fast_forwardable",
2175
- message: `Local ${branch} is not an ancestor of ${remoteBranch}; JSKIT will not merge the PR while the local base branch has diverged.`,
2176
- repairCommand: `git -C ${paths.targetRoot} pull --ff-only origin ${branch}`
2177
- });
2178
- }
2179
-
2180
- return null;
2181
- }
2182
-
2183
2614
  async function updateLocalBaseBranch(paths, baseBranch = "") {
2184
2615
  const branch = normalizeText(baseBranch) || await currentTargetBranch(paths.targetRoot);
2185
2616
  if (!branch) {
@@ -2222,6 +2653,54 @@ async function updateLocalBaseBranch(paths, baseBranch = "") {
2222
2653
  return null;
2223
2654
  }
2224
2655
 
2656
+ async function syncMainCheckout(paths, options = {}, context = {}) {
2657
+ const prOutcome = parseJsonObject(await readTextIfExists(path.join(paths.sessionRoot, "pr_outcome.json")));
2658
+ const preconditions = context.preconditions || [];
2659
+ if (!prOutcome?.outcome) {
2660
+ return failSession(paths, {
2661
+ code: "pr_outcome_missing",
2662
+ message: "Cannot sync the main checkout before PR finalization records an outcome.",
2663
+ preconditions,
2664
+ repairCommand: `jskit session ${paths.sessionId} step`
2665
+ });
2666
+ }
2667
+
2668
+ const skipRequested = options.skipMainSync === true ||
2669
+ normalizeText(options["skip-main-sync"]).toLowerCase() === "true";
2670
+ const skipReason = normalizeText(options.skipReason || options["skip-reason"]) ||
2671
+ "User skipped main checkout sync.";
2672
+ if (skipRequested || prOutcome.outcome !== "merged") {
2673
+ const reason = prOutcome.outcome === "merged"
2674
+ ? skipReason
2675
+ : `PR outcome is ${prOutcome.outcome}; no main checkout sync is required.`;
2676
+ await writeMainCheckoutSync(paths, {
2677
+ branch: prOutcome.baseBranch || "",
2678
+ outcome: prOutcome.outcome,
2679
+ reason,
2680
+ status: "skipped"
2681
+ });
2682
+ await writeReceipt(paths, "main_checkout_synced", `Main checkout sync skipped: ${reason}`);
2683
+ await markStatus(paths, SESSION_STATUS.RUNNING);
2684
+ return buildSessionResponse(paths);
2685
+ }
2686
+
2687
+ const baseBranch = prOutcome.baseBranch || await readTrimmedFile(path.join(paths.sessionRoot, "pr_base_branch"));
2688
+ const syncFailure = await updateLocalBaseBranch(paths, baseBranch);
2689
+ if (syncFailure) {
2690
+ return syncFailure;
2691
+ }
2692
+
2693
+ const branch = normalizeText(baseBranch) || await currentTargetBranch(paths.targetRoot);
2694
+ await writeMainCheckoutSync(paths, {
2695
+ branch,
2696
+ outcome: prOutcome.outcome,
2697
+ status: "synced"
2698
+ });
2699
+ await writeReceipt(paths, "main_checkout_synced", `Fast-forwarded target checkout branch ${branch}.`);
2700
+ await markStatus(paths, SESSION_STATUS.RUNNING);
2701
+ return buildSessionResponse(paths);
2702
+ }
2703
+
2225
2704
  async function updateHelperMapBeforePr(paths) {
2226
2705
  let helperMapPayload;
2227
2706
  try {
@@ -2380,14 +2859,7 @@ async function createPr(paths) {
2380
2859
  }
2381
2860
 
2382
2861
  async function closePrWithoutMerge(paths, prUrl, options = {}) {
2383
- const reason = normalizeText(options.closeReason || options["close-reason"]);
2384
- if (!reason) {
2385
- return failSession(paths, {
2386
- code: "close_without_merge_reason_required",
2387
- message: "Finishing without merging requires --close-reason.",
2388
- repairCommand: `jskit session ${paths.sessionId} step --close-without-merge --close-reason "<reason>"`
2389
- });
2390
- }
2862
+ const reason = normalizeText(options.closeReason || options["close-reason"]) || "User skipped merge in JSKIT Studio.";
2391
2863
  const prState = await readPrState(paths, prUrl);
2392
2864
  if (!prState.ok) {
2393
2865
  return failSession(paths, {
@@ -2423,10 +2895,6 @@ async function closePrWithoutMerge(paths, prUrl, options = {}) {
2423
2895
  timeout: 1000 * 60
2424
2896
  });
2425
2897
  }
2426
- const removeFailure = await removeSessionWorktree(paths);
2427
- if (removeFailure) {
2428
- return removeFailure;
2429
- }
2430
2898
  await writePrOutcome(paths, {
2431
2899
  issueUrl,
2432
2900
  outcome: "closed_without_merge",
@@ -2434,16 +2902,69 @@ async function closePrWithoutMerge(paths, prUrl, options = {}) {
2434
2902
  prState: prState.state,
2435
2903
  reason
2436
2904
  });
2437
- await writeReceipt(paths, "pr_finalized", `Finished without merging PR ${prUrl}; PR left open and worktree removed ${paths.worktree}. Reason: ${reason}`);
2905
+ await writeReceipt(paths, "pr_finalized", `Finished without merging PR ${prUrl}; PR left open. Reason: ${reason}`);
2438
2906
  await markStatus(paths, SESSION_STATUS.RUNNING);
2439
2907
  return buildSessionResponse(paths);
2440
2908
  }
2441
2909
 
2442
- async function finalizePr(paths, options = {}) {
2910
+ async function preparePrMerge(paths, options = {}, context = {}) {
2911
+ const preconditions = context.preconditions || [];
2912
+ const prepareMerge = options.prepareMerge === true ||
2913
+ normalizeText(options["prepare-merge"]).toLowerCase() === "true";
2914
+ const continueToMerge = options.continueToMerge === true ||
2915
+ normalizeText(options["continue-to-merge"]).toLowerCase() === "true";
2916
+
2917
+ if (prepareMerge) {
2918
+ const prUrl = await readTrimmedFile(path.join(paths.sessionRoot, "pr_url"));
2919
+ const baseBranch = await readTrimmedFile(path.join(paths.sessionRoot, "pr_base_branch")) ||
2920
+ await readTrimmedFile(path.join(paths.sessionRoot, "base_branch")) ||
2921
+ await currentTargetBranch(paths.targetRoot);
2922
+ const prompt = await renderPrompt(paths, "prepare_pr_merge.md", {
2923
+ base_branch: baseBranch,
2924
+ final_report_file: path.join(paths.sessionRoot, "final_report.md"),
2925
+ issue_url: await readTrimmedFile(path.join(paths.sessionRoot, "issue_url")),
2926
+ pr_url: prUrl,
2927
+ target_root: paths.targetRoot
2928
+ });
2929
+ await writePromptArtifact(paths, "prepare_pr_merge.md", prompt);
2930
+ await markStatus(paths, SESSION_STATUS.WAITING_FOR_USER);
2931
+ return buildSessionResponse(paths, {
2932
+ codex: PR_MERGE_PREP_CODEX_HANDOFF,
2933
+ ok: true,
2934
+ preconditions,
2935
+ prompt,
2936
+ status: SESSION_STATUS.WAITING_FOR_USER
2937
+ });
2938
+ }
2939
+
2940
+ if (!continueToMerge) {
2941
+ return failSession(paths, {
2942
+ code: "pr_merge_prepare_decision_required",
2943
+ message: "Choose whether to ask Codex to prepare the PR for merge or continue to the merge decision.",
2944
+ repairCommand: `jskit session ${paths.sessionId} step --continue-to-merge true`,
2945
+ preconditions
2946
+ });
2947
+ }
2948
+
2949
+ await writeReceipt(paths, "pr_merge_prepared", "User continued from PR merge preparation to the merge decision.");
2950
+ await markStatus(paths, SESSION_STATUS.RUNNING);
2951
+ return buildSessionResponse(paths, {
2952
+ preconditions
2953
+ });
2954
+ }
2955
+
2956
+ async function finalizePr(paths, options = {}, context = {}) {
2957
+ const preconditions = context.preconditions || [];
2443
2958
  const prUrl = await readTrimmedFile(path.join(paths.sessionRoot, "pr_url"));
2444
2959
  const closeWithoutMerge = options.closeWithoutMerge === true ||
2445
- normalizeText(options["close-without-merge"]).toLowerCase() === "true";
2960
+ normalizeText(options["close-without-merge"]).toLowerCase() === "true" ||
2961
+ options.skipMerge === true ||
2962
+ normalizeText(options["skip-merge"]).toLowerCase() === "true";
2446
2963
  if (closeWithoutMerge) {
2964
+ const guardResult = await runSessionFinalizationGuard(paths, preconditions);
2965
+ if (!guardResult.ok) {
2966
+ return guardResult.response;
2967
+ }
2447
2968
  return closePrWithoutMerge(paths, prUrl, options);
2448
2969
  }
2449
2970
  const mergePr = options.mergePr === true ||
@@ -2451,10 +2972,15 @@ async function finalizePr(paths, options = {}) {
2451
2972
  if (!mergePr) {
2452
2973
  return failSession(paths, {
2453
2974
  code: "pr_finalize_decision_required",
2454
- message: "Choose whether to merge the PR or finish without merging.",
2455
- repairCommand: `jskit session ${paths.sessionId} step --merge-pr true`
2975
+ message: "Choose whether to merge the PR or skip merge.",
2976
+ repairCommand: `jskit session ${paths.sessionId} step --merge-pr true`,
2977
+ preconditions
2456
2978
  });
2457
2979
  }
2980
+ const guardResult = await runSessionFinalizationGuard(paths, preconditions);
2981
+ if (!guardResult.ok) {
2982
+ return guardResult.response;
2983
+ }
2458
2984
  const mergeMarkerPath = path.join(paths.sessionRoot, "pr_merge_completed");
2459
2985
  const baseBranchPath = path.join(paths.sessionRoot, "pr_base_branch");
2460
2986
  const mergeAlreadyCompleted = await readTrimmedFile(mergeMarkerPath);
@@ -2465,10 +2991,6 @@ async function finalizePr(paths, options = {}) {
2465
2991
  if (baseBranch) {
2466
2992
  await writeTextFile(baseBranchPath, `${baseBranch}\n`);
2467
2993
  }
2468
- const baseFailure = await assertTargetBaseCanFastForward(paths, baseBranch);
2469
- if (baseFailure) {
2470
- return baseFailure;
2471
- }
2472
2994
  let prMerged = prStateIsMerged(existingPrState);
2473
2995
  let mergeResult = null;
2474
2996
  if (!prMerged) {
@@ -2491,6 +3013,7 @@ async function finalizePr(paths, options = {}) {
2491
3013
  code: "pr_merge_failed",
2492
3014
  message: mergeResult?.output || existingPrState.output || "Failed to merge PR.",
2493
3015
  repairCommand: `gh pr merge ${prUrl} --merge --delete-branch`,
3016
+ preconditions,
2494
3017
  prompt
2495
3018
  });
2496
3019
  }
@@ -2509,15 +3032,7 @@ async function finalizePr(paths, options = {}) {
2509
3032
  });
2510
3033
  await writeTextFile(mergeMarkerPath, `${prUrl}\n`);
2511
3034
  }
2512
- const updateFailure = await updateLocalBaseBranch(paths, baseBranch);
2513
- if (updateFailure) {
2514
- return updateFailure;
2515
- }
2516
- const removeFailure = await removeSessionWorktree(paths);
2517
- if (removeFailure) {
2518
- return removeFailure;
2519
- }
2520
- await writeReceipt(paths, "pr_finalized", `Merged PR ${prUrl}, updated local ${baseBranch || "base branch"}, and removed worktree ${paths.worktree}.`);
3035
+ await writeReceipt(paths, "pr_finalized", `Merged PR ${prUrl}.`);
2521
3036
  await markStatus(paths, SESSION_STATUS.RUNNING);
2522
3037
  return buildSessionResponse(paths);
2523
3038
  }
@@ -2527,6 +3042,10 @@ async function finishSession(paths) {
2527
3042
  const issueUrl = await readTrimmedFile(path.join(paths.sessionRoot, "issue_url"));
2528
3043
  const codexThreadId = await readTrimmedFile(path.join(paths.sessionRoot, "codex_thread_id"));
2529
3044
  const prOutcome = parseJsonObject(await readTextIfExists(path.join(paths.sessionRoot, "pr_outcome.json")));
3045
+ const removeFailure = await removeSessionWorktree(paths);
3046
+ if (removeFailure) {
3047
+ return removeFailure;
3048
+ }
2530
3049
  const prompt = await renderPrompt(paths, "final_comment.md", {
2531
3050
  codex_thread_id: codexThreadId,
2532
3051
  issue_url: issueUrl,
@@ -2543,7 +3062,7 @@ async function finishSession(paths) {
2543
3062
  timeout: 1000 * 60
2544
3063
  });
2545
3064
  }
2546
- await writeReceipt(paths, "session_finished", `Finished session ${paths.sessionId} with PR outcome ${prOutcome?.outcome || "unknown"}.`);
3065
+ await writeReceipt(paths, "session_finished", `Removed worktree ${paths.worktree} and finished session ${paths.sessionId} with PR outcome ${prOutcome?.outcome || "unknown"}.`);
2547
3066
  await markStatus(paths, SESSION_STATUS.FINISHED);
2548
3067
  await markCurrentStep(paths, "");
2549
3068
  const archivedPaths = await archiveSession(paths, "completed");
@@ -2577,7 +3096,9 @@ const STEP_RUNNERS = Object.freeze({
2577
3096
  blueprint_updated: updateBlueprint,
2578
3097
  final_report_created: createFinalReport,
2579
3098
  pr_created: createPr,
3099
+ pr_merge_prepared: preparePrMerge,
2580
3100
  pr_finalized: finalizePr,
3101
+ main_checkout_synced: syncMainCheckout,
2581
3102
  session_finished: finishSession
2582
3103
  });
2583
3104
 
@@ -2598,6 +3119,7 @@ const PRECONDITION_RUNNERS = Object.freeze({
2598
3119
  issue_url_exists: assertIssueUrlExists,
2599
3120
  automated_checks_passed: assertAutomatedChecksPassed,
2600
3121
  issue_details_exists: assertIssueDetailsExists,
3122
+ main_checkout_sync_satisfied: assertMainCheckoutSyncSatisfied,
2601
3123
  plan_text_exists: assertPlanTextExists,
2602
3124
  pr_url_exists: assertPrUrlExists,
2603
3125
  ready_jskit_app: assertReadyJskitApp,
@@ -2772,6 +3294,7 @@ export {
2772
3294
  listSessions,
2773
3295
  renderTemplate,
2774
3296
  recordDependenciesInstalled,
3297
+ rewindSession,
2775
3298
  resolveSessionPaths,
2776
3299
  runSessionStep
2777
3300
  };