@jskit-ai/jskit-cli 0.2.84 → 0.2.86

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.86",
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.85",
24
+ "@jskit-ai/kernel": "0.1.77",
25
+ "@jskit-ai/shell-web": "0.1.76",
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,139 @@ 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
+
483
+ async function runSessionProvisioningHook(paths, {
484
+ preferredPackageManager = "",
485
+ preconditions = []
486
+ } = {}) {
487
+ return runOptionalSessionPackageScript(paths, {
488
+ failureCode: "session_provision_failed",
489
+ failureMessage: `${SESSION_PROVISION_PACKAGE_SCRIPT} failed in the session worktree.`,
490
+ kind: "session_provision",
491
+ preferredPackageManager,
492
+ preconditions,
493
+ scriptName: SESSION_PROVISION_PACKAGE_SCRIPT
494
+ });
495
+ }
496
+
358
497
  async function readIssueMetadata(paths) {
359
498
  const source = await readTextIfExists(path.join(paths.sessionRoot, "issue_metadata.json"));
360
499
  if (!source) {
@@ -820,8 +959,16 @@ async function installDependencies(paths, _options = {}, context = {}) {
820
959
  preconditions
821
960
  });
822
961
  }
962
+ const provisionResult = await runSessionProvisioningHook(paths, {
963
+ preferredPackageManager: command,
964
+ preconditions
965
+ });
966
+ if (!provisionResult.ok) {
967
+ return provisionResult.response;
968
+ }
969
+ const installMessage = result.output || `Installed Node dependencies in the session worktree with ${command} ${args.join(" ")}.`;
823
970
  return recordDependenciesInstalled(paths, {
824
- message: result.output || `Installed Node dependencies in the session worktree with ${command} ${args.join(" ")}.`,
971
+ message: provisionResult.ran ? `${installMessage}\n${SESSION_PROVISION_PACKAGE_SCRIPT} completed.` : installMessage,
825
972
  preconditions
826
973
  });
827
974
  }
@@ -832,6 +979,7 @@ async function adoptDependenciesInstalled({
832
979
  message = ""
833
980
  } = {}) {
834
981
  return withExistingSession({ targetRoot, sessionId }, async (paths, context = {}) => {
982
+ const preconditions = context.preconditions || [];
835
983
  const artifacts = await readSessionArtifacts(paths);
836
984
  if (artifacts.nextStep !== "dependencies_installed") {
837
985
  return buildSessionResponse(paths, {
@@ -842,12 +990,21 @@ async function adoptDependenciesInstalled({
842
990
  message: `Cannot record dependencies for ${paths.sessionId}; current step is ${artifacts.nextStep || "complete"}.`
843
991
  })
844
992
  ],
845
- preconditions: context.preconditions || []
993
+ preconditions
846
994
  });
847
995
  }
996
+ const provisionResult = await runSessionProvisioningHook(paths, {
997
+ preconditions
998
+ });
999
+ if (!provisionResult.ok) {
1000
+ return provisionResult.response;
1001
+ }
1002
+ const receiptMessage = provisionResult.ran
1003
+ ? `${normalizeText(message) || "Installed Node dependencies in the session worktree."}\n${SESSION_PROVISION_PACKAGE_SCRIPT} completed.`
1004
+ : message;
848
1005
  return recordDependenciesInstalled(paths, {
849
- message,
850
- preconditions: context.preconditions || []
1006
+ message: receiptMessage,
1007
+ preconditions
851
1008
  });
852
1009
  });
853
1010
  }
@@ -1282,6 +1439,333 @@ async function inspectSessionDiff({
1282
1439
  });
1283
1440
  }
1284
1441
 
1442
+ const FIRST_REWINDABLE_STEP_ID = "dependencies_installed";
1443
+ const CYCLE_REWIND_TARGET_STEP_ID = "plan_made";
1444
+ const REWIND_CLOSED_STATUSES = Object.freeze([
1445
+ SESSION_STATUS.ABANDONED,
1446
+ SESSION_STATUS.FINISHED
1447
+ ]);
1448
+
1449
+ async function removeSessionPath(paths, ...parts) {
1450
+ await rm(path.join(paths.sessionRoot, ...parts), {
1451
+ force: true,
1452
+ recursive: true
1453
+ });
1454
+ }
1455
+
1456
+ async function removeSessionRootFile(paths, fileName) {
1457
+ await removeSessionPath(paths, fileName);
1458
+ }
1459
+
1460
+ async function removePromptArtifact(paths, fileName) {
1461
+ await removeSessionPath(paths, "prompts", fileName);
1462
+ }
1463
+
1464
+ async function removeGlobalCodexResult(paths, stepId) {
1465
+ await removeSessionPath(paths, "codex_results", `${stepId}.md`);
1466
+ }
1467
+
1468
+ async function removeGithubCommentPurpose(paths, purpose) {
1469
+ const comments = await readGithubComments(paths);
1470
+ if (!Object.hasOwn(comments, purpose)) {
1471
+ return;
1472
+ }
1473
+ delete comments[purpose];
1474
+ if (Object.keys(comments).length === 0) {
1475
+ await removeSessionRootFile(paths, "github_comments.json");
1476
+ return;
1477
+ }
1478
+ await writeGithubComments(paths, comments);
1479
+ }
1480
+
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
+ async function removeCycleDirectories(paths) {
1497
+ for (const rootName of ["steps", "cycles"]) {
1498
+ const root = path.join(paths.sessionRoot, rootName);
1499
+ let entries = [];
1500
+ try {
1501
+ entries = await readdir(root, { withFileTypes: true });
1502
+ } catch {
1503
+ entries = [];
1504
+ }
1505
+ await Promise.all(entries
1506
+ .filter((entry) => entry.isDirectory() && /^cycle_\d+$/u.test(entry.name))
1507
+ .map((entry) => rm(path.join(root, entry.name), {
1508
+ force: true,
1509
+ recursive: true
1510
+ })));
1511
+ }
1512
+ }
1513
+
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
+ ]);
1533
+ }
1534
+
1535
+ async function cancelAllCycleState(paths) {
1536
+ await Promise.all([
1537
+ removeCycleDirectories(paths),
1538
+ removeCyclePromptArtifacts(paths),
1539
+ removeSessionPath(paths, "checks"),
1540
+ removeSessionPath(paths, "ui_checks"),
1541
+ removeSessionPath(paths, "review_passes")
1542
+ ]);
1543
+ await writeActiveCycle(paths, "001");
1544
+ }
1545
+
1546
+ const STEP_CANCELERS = Object.freeze({
1547
+ dependencies_installed: async () => {},
1548
+ issue_prompt_rendered: async (paths) => {
1549
+ await removePromptArtifact(paths, "issue_draft.md");
1550
+ },
1551
+ issue_drafted: async (paths) => {
1552
+ await Promise.all([
1553
+ removeSessionRootFile(paths, "issue.md"),
1554
+ removeSessionRootFile(paths, "issue_title")
1555
+ ]);
1556
+ },
1557
+ issue_created: async (paths) => {
1558
+ await Promise.all([
1559
+ removeSessionRootFile(paths, "issue_url"),
1560
+ removeSessionRootFile(paths, "issue_metadata.json")
1561
+ ]);
1562
+ },
1563
+ issue_details_gathered: async (paths) => {
1564
+ await Promise.all([
1565
+ removePromptArtifact(paths, "issue_details.md"),
1566
+ removeSessionRootFile(paths, "issue_details.md"),
1567
+ removeGithubCommentPurpose(paths, "issue_details"),
1568
+ removeIssueDetailsMetadata(paths)
1569
+ ]);
1570
+ },
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
+ changes_committed: async (paths) => {
1579
+ await removeSessionRootFile(paths, "changes_committed.json");
1580
+ },
1581
+ blueprint_updated: async (paths) => {
1582
+ await Promise.all([
1583
+ removePromptArtifact(paths, "update_blueprint.md"),
1584
+ removeGlobalCodexResult(paths, "blueprint_updated")
1585
+ ]);
1586
+ },
1587
+ final_report_created: async (paths) => {
1588
+ await Promise.all([
1589
+ removeSessionRootFile(paths, "final_report.md"),
1590
+ removeGithubCommentPurpose(paths, "final_report")
1591
+ ]);
1592
+ },
1593
+ pr_created: async (paths) => {
1594
+ await Promise.all([
1595
+ removePromptArtifact(paths, "pr_create_failure.md"),
1596
+ removeSessionRootFile(paths, "pr_body.md"),
1597
+ removeSessionRootFile(paths, "pr_url")
1598
+ ]);
1599
+ },
1600
+ pr_merge_prepared: async (paths) => {
1601
+ await removePromptArtifact(paths, "prepare_pr_merge.md");
1602
+ },
1603
+ pr_finalized: async (paths) => {
1604
+ await Promise.all([
1605
+ removePromptArtifact(paths, "pr_merge_failure.md"),
1606
+ removeSessionRootFile(paths, "pr_base_branch"),
1607
+ removeSessionRootFile(paths, "pr_merge_completed"),
1608
+ removeSessionRootFile(paths, "pr_outcome.json")
1609
+ ]);
1610
+ },
1611
+ main_checkout_synced: async (paths) => {
1612
+ await Promise.all([
1613
+ removeSessionRootFile(paths, "local_base_updated"),
1614
+ removeSessionRootFile(paths, "main_checkout_sync.json")
1615
+ ]);
1616
+ },
1617
+ session_finished: async (paths) => {
1618
+ await Promise.all([
1619
+ removeSessionRootFile(paths, "final_comment.md")
1620
+ ]);
1621
+ }
1622
+ });
1623
+
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
+ function targetIsAllowedRewindStep(stepId) {
1631
+ if (!STEP_IDS.includes(stepId)) {
1632
+ return false;
1633
+ }
1634
+ if (stepId === "session_created" || stepId === "worktree_created") {
1635
+ return false;
1636
+ }
1637
+ if (CYCLE_STEP_IDS.includes(stepId)) {
1638
+ return stepId === CYCLE_REWIND_TARGET_STEP_ID;
1639
+ }
1640
+ return STEP_IDS.indexOf(stepId) >= STEP_IDS.indexOf(FIRST_REWINDABLE_STEP_ID);
1641
+ }
1642
+
1643
+ function deletedStepIdsForRewindTarget(stepId) {
1644
+ const targetIndex = STEP_IDS.indexOf(stepId);
1645
+ return targetIndex < 0 ? [] : STEP_IDS.slice(targetIndex);
1646
+ }
1647
+
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)));
1652
+ }
1653
+
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) {
1662
+ const canceler = STEP_CANCELERS[stepId];
1663
+ if (typeof canceler !== "function") {
1664
+ continue;
1665
+ }
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
+ await canceler(paths);
1673
+ }
1674
+ }
1675
+
1676
+ async function rewindSession({
1677
+ targetRoot = process.cwd(),
1678
+ sessionId,
1679
+ stepId
1680
+ } = {}) {
1681
+ return withExistingSession({ targetRoot, sessionId }, async (paths) => {
1682
+ const artifacts = await readSessionArtifacts(paths);
1683
+ const normalizedStepId = normalizeText(stepId);
1684
+ const currentStatus = artifacts.status || SESSION_STATUS.PENDING;
1685
+
1686
+ if (paths.archive && paths.archive !== "active") {
1687
+ return buildSessionResponse(paths, {
1688
+ ok: false,
1689
+ errors: [
1690
+ createError({
1691
+ code: "session_archived_read_only",
1692
+ message: `Session ${paths.sessionId} is archived and cannot be rewound.`
1693
+ })
1694
+ ],
1695
+ status: currentStatus
1696
+ });
1697
+ }
1698
+
1699
+ if (REWIND_CLOSED_STATUSES.includes(currentStatus)) {
1700
+ return buildSessionResponse(paths, {
1701
+ ok: false,
1702
+ errors: [
1703
+ createError({
1704
+ code: "session_closed_read_only",
1705
+ message: `Session ${paths.sessionId} is ${currentStatus} and cannot be rewound.`
1706
+ })
1707
+ ],
1708
+ status: currentStatus
1709
+ });
1710
+ }
1711
+
1712
+ if (artifacts.workflowVersion !== SESSION_WORKFLOW_VERSION) {
1713
+ return buildSessionResponse(paths, {
1714
+ ok: false,
1715
+ errors: [
1716
+ createError({
1717
+ code: "unsupported_workflow_version",
1718
+ message: `Session ${paths.sessionId} uses workflow version ${artifacts.workflowVersion || "unknown"}, but this JSKIT runtime expects ${SESSION_WORKFLOW_VERSION}.`
1719
+ })
1720
+ ],
1721
+ status: SESSION_STATUS.BLOCKED
1722
+ });
1723
+ }
1724
+
1725
+ 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
+ return buildSessionResponse(paths, {
1730
+ ok: false,
1731
+ errors: [
1732
+ createError({
1733
+ code: "rewind_step_not_allowed",
1734
+ message: `Cannot rewind session ${paths.sessionId} to ${normalizedStepId || "(missing)"}.${cycleHint}`
1735
+ })
1736
+ ],
1737
+ status: currentStatus
1738
+ });
1739
+ }
1740
+
1741
+ if (!artifacts.completedSteps.includes(normalizedStepId)) {
1742
+ return buildSessionResponse(paths, {
1743
+ ok: false,
1744
+ errors: [
1745
+ createError({
1746
+ code: "rewind_step_not_completed",
1747
+ message: `Cannot rewind session ${paths.sessionId} to ${normalizedStepId} because that step is not completed.`
1748
+ })
1749
+ ],
1750
+ status: currentStatus
1751
+ });
1752
+ }
1753
+
1754
+ 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
+ }
1761
+ await markCurrentStep(paths, normalizedStepId);
1762
+ await markStatus(paths, SESSION_STATUS.PENDING);
1763
+ return buildSessionResponse(paths, {
1764
+ status: SESSION_STATUS.PENDING
1765
+ });
1766
+ });
1767
+ }
1768
+
1285
1769
  async function commitWorktree(paths, {
1286
1770
  message,
1287
1771
  allowNoChanges = false
@@ -2118,6 +2602,17 @@ async function writePrOutcome(paths, outcome) {
2118
2602
  }, null, 2)}\n`);
2119
2603
  }
2120
2604
 
2605
+ function mainCheckoutSyncPath(paths) {
2606
+ return path.join(paths.sessionRoot, "main_checkout_sync.json");
2607
+ }
2608
+
2609
+ async function writeMainCheckoutSync(paths, payload = {}) {
2610
+ await writeTextFile(mainCheckoutSyncPath(paths), `${JSON.stringify({
2611
+ recordedAt: timestampForReceipt(),
2612
+ ...payload
2613
+ }, null, 2)}\n`);
2614
+ }
2615
+
2121
2616
  async function assertTargetRootCanUpdateBase(paths, branch) {
2122
2617
  const cleanFailure = await assertTargetRootCleanForBaseUpdate(paths);
2123
2618
  if (cleanFailure) {
@@ -2136,50 +2631,6 @@ async function assertTargetRootCanUpdateBase(paths, branch) {
2136
2631
  return null;
2137
2632
  }
2138
2633
 
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
2634
  async function updateLocalBaseBranch(paths, baseBranch = "") {
2184
2635
  const branch = normalizeText(baseBranch) || await currentTargetBranch(paths.targetRoot);
2185
2636
  if (!branch) {
@@ -2222,6 +2673,54 @@ async function updateLocalBaseBranch(paths, baseBranch = "") {
2222
2673
  return null;
2223
2674
  }
2224
2675
 
2676
+ async function syncMainCheckout(paths, options = {}, context = {}) {
2677
+ const prOutcome = parseJsonObject(await readTextIfExists(path.join(paths.sessionRoot, "pr_outcome.json")));
2678
+ const preconditions = context.preconditions || [];
2679
+ if (!prOutcome?.outcome) {
2680
+ return failSession(paths, {
2681
+ 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`
2685
+ });
2686
+ }
2687
+
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"
2701
+ });
2702
+ await writeReceipt(paths, "main_checkout_synced", `Main checkout sync skipped: ${reason}`);
2703
+ await markStatus(paths, SESSION_STATUS.RUNNING);
2704
+ return buildSessionResponse(paths);
2705
+ }
2706
+
2707
+ const baseBranch = prOutcome.baseBranch || await readTrimmedFile(path.join(paths.sessionRoot, "pr_base_branch"));
2708
+ const syncFailure = await updateLocalBaseBranch(paths, baseBranch);
2709
+ if (syncFailure) {
2710
+ return syncFailure;
2711
+ }
2712
+
2713
+ const branch = normalizeText(baseBranch) || await currentTargetBranch(paths.targetRoot);
2714
+ await writeMainCheckoutSync(paths, {
2715
+ branch,
2716
+ outcome: prOutcome.outcome,
2717
+ status: "synced"
2718
+ });
2719
+ await writeReceipt(paths, "main_checkout_synced", `Fast-forwarded target checkout branch ${branch}.`);
2720
+ await markStatus(paths, SESSION_STATUS.RUNNING);
2721
+ return buildSessionResponse(paths);
2722
+ }
2723
+
2225
2724
  async function updateHelperMapBeforePr(paths) {
2226
2725
  let helperMapPayload;
2227
2726
  try {
@@ -2380,14 +2879,7 @@ async function createPr(paths) {
2380
2879
  }
2381
2880
 
2382
2881
  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
- }
2882
+ const reason = normalizeText(options.closeReason || options["close-reason"]) || "User skipped merge in JSKIT Studio.";
2391
2883
  const prState = await readPrState(paths, prUrl);
2392
2884
  if (!prState.ok) {
2393
2885
  return failSession(paths, {
@@ -2423,10 +2915,6 @@ async function closePrWithoutMerge(paths, prUrl, options = {}) {
2423
2915
  timeout: 1000 * 60
2424
2916
  });
2425
2917
  }
2426
- const removeFailure = await removeSessionWorktree(paths);
2427
- if (removeFailure) {
2428
- return removeFailure;
2429
- }
2430
2918
  await writePrOutcome(paths, {
2431
2919
  issueUrl,
2432
2920
  outcome: "closed_without_merge",
@@ -2434,16 +2922,69 @@ async function closePrWithoutMerge(paths, prUrl, options = {}) {
2434
2922
  prState: prState.state,
2435
2923
  reason
2436
2924
  });
2437
- await writeReceipt(paths, "pr_finalized", `Finished without merging PR ${prUrl}; PR left open and worktree removed ${paths.worktree}. Reason: ${reason}`);
2925
+ await writeReceipt(paths, "pr_finalized", `Finished without merging PR ${prUrl}; PR left open. Reason: ${reason}`);
2438
2926
  await markStatus(paths, SESSION_STATUS.RUNNING);
2439
2927
  return buildSessionResponse(paths);
2440
2928
  }
2441
2929
 
2442
- async function finalizePr(paths, options = {}) {
2930
+ async function preparePrMerge(paths, options = {}, context = {}) {
2931
+ 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
2966
+ });
2967
+ }
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);
2971
+ return buildSessionResponse(paths, {
2972
+ preconditions
2973
+ });
2974
+ }
2975
+
2976
+ async function finalizePr(paths, options = {}, context = {}) {
2977
+ const preconditions = context.preconditions || [];
2443
2978
  const prUrl = await readTrimmedFile(path.join(paths.sessionRoot, "pr_url"));
2444
2979
  const closeWithoutMerge = options.closeWithoutMerge === true ||
2445
- normalizeText(options["close-without-merge"]).toLowerCase() === "true";
2980
+ normalizeText(options["close-without-merge"]).toLowerCase() === "true" ||
2981
+ options.skipMerge === true ||
2982
+ normalizeText(options["skip-merge"]).toLowerCase() === "true";
2446
2983
  if (closeWithoutMerge) {
2984
+ const guardResult = await runSessionFinalizationGuard(paths, preconditions);
2985
+ if (!guardResult.ok) {
2986
+ return guardResult.response;
2987
+ }
2447
2988
  return closePrWithoutMerge(paths, prUrl, options);
2448
2989
  }
2449
2990
  const mergePr = options.mergePr === true ||
@@ -2451,10 +2992,15 @@ async function finalizePr(paths, options = {}) {
2451
2992
  if (!mergePr) {
2452
2993
  return failSession(paths, {
2453
2994
  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`
2995
+ message: "Choose whether to merge the PR or skip merge.",
2996
+ repairCommand: `jskit session ${paths.sessionId} step --merge-pr true`,
2997
+ preconditions
2456
2998
  });
2457
2999
  }
3000
+ const guardResult = await runSessionFinalizationGuard(paths, preconditions);
3001
+ if (!guardResult.ok) {
3002
+ return guardResult.response;
3003
+ }
2458
3004
  const mergeMarkerPath = path.join(paths.sessionRoot, "pr_merge_completed");
2459
3005
  const baseBranchPath = path.join(paths.sessionRoot, "pr_base_branch");
2460
3006
  const mergeAlreadyCompleted = await readTrimmedFile(mergeMarkerPath);
@@ -2465,10 +3011,6 @@ async function finalizePr(paths, options = {}) {
2465
3011
  if (baseBranch) {
2466
3012
  await writeTextFile(baseBranchPath, `${baseBranch}\n`);
2467
3013
  }
2468
- const baseFailure = await assertTargetBaseCanFastForward(paths, baseBranch);
2469
- if (baseFailure) {
2470
- return baseFailure;
2471
- }
2472
3014
  let prMerged = prStateIsMerged(existingPrState);
2473
3015
  let mergeResult = null;
2474
3016
  if (!prMerged) {
@@ -2491,6 +3033,7 @@ async function finalizePr(paths, options = {}) {
2491
3033
  code: "pr_merge_failed",
2492
3034
  message: mergeResult?.output || existingPrState.output || "Failed to merge PR.",
2493
3035
  repairCommand: `gh pr merge ${prUrl} --merge --delete-branch`,
3036
+ preconditions,
2494
3037
  prompt
2495
3038
  });
2496
3039
  }
@@ -2509,15 +3052,7 @@ async function finalizePr(paths, options = {}) {
2509
3052
  });
2510
3053
  await writeTextFile(mergeMarkerPath, `${prUrl}\n`);
2511
3054
  }
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}.`);
3055
+ await writeReceipt(paths, "pr_finalized", `Merged PR ${prUrl}.`);
2521
3056
  await markStatus(paths, SESSION_STATUS.RUNNING);
2522
3057
  return buildSessionResponse(paths);
2523
3058
  }
@@ -2527,6 +3062,10 @@ async function finishSession(paths) {
2527
3062
  const issueUrl = await readTrimmedFile(path.join(paths.sessionRoot, "issue_url"));
2528
3063
  const codexThreadId = await readTrimmedFile(path.join(paths.sessionRoot, "codex_thread_id"));
2529
3064
  const prOutcome = parseJsonObject(await readTextIfExists(path.join(paths.sessionRoot, "pr_outcome.json")));
3065
+ const removeFailure = await removeSessionWorktree(paths);
3066
+ if (removeFailure) {
3067
+ return removeFailure;
3068
+ }
2530
3069
  const prompt = await renderPrompt(paths, "final_comment.md", {
2531
3070
  codex_thread_id: codexThreadId,
2532
3071
  issue_url: issueUrl,
@@ -2543,7 +3082,7 @@ async function finishSession(paths) {
2543
3082
  timeout: 1000 * 60
2544
3083
  });
2545
3084
  }
2546
- await writeReceipt(paths, "session_finished", `Finished session ${paths.sessionId} with PR outcome ${prOutcome?.outcome || "unknown"}.`);
3085
+ await writeReceipt(paths, "session_finished", `Removed worktree ${paths.worktree} and finished session ${paths.sessionId} with PR outcome ${prOutcome?.outcome || "unknown"}.`);
2547
3086
  await markStatus(paths, SESSION_STATUS.FINISHED);
2548
3087
  await markCurrentStep(paths, "");
2549
3088
  const archivedPaths = await archiveSession(paths, "completed");
@@ -2577,7 +3116,9 @@ const STEP_RUNNERS = Object.freeze({
2577
3116
  blueprint_updated: updateBlueprint,
2578
3117
  final_report_created: createFinalReport,
2579
3118
  pr_created: createPr,
3119
+ pr_merge_prepared: preparePrMerge,
2580
3120
  pr_finalized: finalizePr,
3121
+ main_checkout_synced: syncMainCheckout,
2581
3122
  session_finished: finishSession
2582
3123
  });
2583
3124
 
@@ -2598,6 +3139,7 @@ const PRECONDITION_RUNNERS = Object.freeze({
2598
3139
  issue_url_exists: assertIssueUrlExists,
2599
3140
  automated_checks_passed: assertAutomatedChecksPassed,
2600
3141
  issue_details_exists: assertIssueDetailsExists,
3142
+ main_checkout_sync_satisfied: assertMainCheckoutSyncSatisfied,
2601
3143
  plan_text_exists: assertPlanTextExists,
2602
3144
  pr_url_exists: assertPrUrlExists,
2603
3145
  ready_jskit_app: assertReadyJskitApp,
@@ -2772,6 +3314,7 @@ export {
2772
3314
  listSessions,
2773
3315
  renderTemplate,
2774
3316
  recordDependenciesInstalled,
3317
+ rewindSession,
2775
3318
  resolveSessionPaths,
2776
3319
  runSessionStep
2777
3320
  };