@jskit-ai/jskit-cli 0.2.81 → 0.2.82

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (27) hide show
  1. package/package.json +6 -4
  2. package/src/server/appBlueprint.js +1 -1
  3. package/src/server/commandHandlers/helperMap.js +104 -0
  4. package/src/server/commandHandlers/session.js +110 -3
  5. package/src/server/commandHandlers/show.js +169 -34
  6. package/src/server/core/argParser.js +8 -0
  7. package/src/server/core/commandCatalog.js +58 -2
  8. package/src/server/core/createCommandHandlers.js +4 -1
  9. package/src/server/helperMap.js +463 -0
  10. package/src/server/helperMapPaths.js +7 -0
  11. package/src/server/sessionRuntime/appReadiness.js +55 -0
  12. package/src/server/sessionRuntime/constants.js +217 -78
  13. package/src/server/sessionRuntime/preconditions.js +382 -5
  14. package/src/server/sessionRuntime/promptRenderer.js +15 -2
  15. package/src/server/sessionRuntime/prompts/automated_checks.md +42 -0
  16. package/src/server/sessionRuntime/prompts/deep_ui_check.md +53 -0
  17. package/src/server/sessionRuntime/prompts/doctor_failure.md +11 -2
  18. package/src/server/sessionRuntime/prompts/execute_plan.md +32 -6
  19. package/src/server/sessionRuntime/prompts/final_comment.md +3 -1
  20. package/src/server/sessionRuntime/prompts/issue_details.md +52 -0
  21. package/src/server/sessionRuntime/prompts/new_issue.md +15 -2
  22. package/src/server/sessionRuntime/prompts/plan_issue.md +40 -9
  23. package/src/server/sessionRuntime/prompts/review_changes.md +46 -5
  24. package/src/server/sessionRuntime/prompts/update_blueprint.md +36 -0
  25. package/src/server/sessionRuntime/prompts/user_check.md +15 -1
  26. package/src/server/sessionRuntime/responses.js +776 -56
  27. package/src/server/sessionRuntime.js +1658 -123
@@ -1,4 +1,5 @@
1
1
  import {
2
+ appendFile,
2
3
  mkdir,
3
4
  readFile,
4
5
  readdir,
@@ -6,14 +7,21 @@ import {
6
7
  } from "node:fs/promises";
7
8
  import path from "node:path";
8
9
  import {
10
+ BLUEPRINT_CODEX_HANDOFF,
11
+ AUTOMATED_CHECK_REPAIR_CODEX_HANDOFF,
12
+ DEEP_UI_CHECK_CODEX_HANDOFF,
13
+ ISSUE_DETAILS_CODEX_HANDOFF,
9
14
  PLAN_EXECUTION_CODEX_HANDOFF,
15
+ REVIEW_PASS_LIMIT,
10
16
  REVIEW_EXECUTION_CODEX_HANDOFF,
11
17
  SESSION_STATUS,
18
+ SESSION_WORKFLOW_VERSION,
12
19
  STEP_DEFINITIONS,
13
20
  STEP_IDS,
14
21
  STEP_PRECONDITION_NAMES
15
22
  } from "./sessionRuntime/constants.js";
16
23
  import {
24
+ fileExists,
17
25
  normalizeText,
18
26
  readTextIfExists,
19
27
  readTrimmedFile,
@@ -40,19 +48,37 @@ import {
40
48
  failSession,
41
49
  markCurrentStep,
42
50
  markStatus,
51
+ normalizeReviewPassNumber,
52
+ readActiveCycle,
43
53
  readReceiptSteps,
54
+ readReviewPasses,
44
55
  readSessionArtifacts,
56
+ reviewPassRoot,
57
+ writeActiveCycle,
58
+ writeCycleReceipt,
45
59
  writeReceipt
46
60
  } from "./sessionRuntime/responses.js";
47
61
  import {
48
62
  applyPreconditions,
63
+ assertAcceptedChangesCommitted,
64
+ assertActiveCycleExists,
65
+ assertActiveCycleUserCheckPassed,
66
+ assertBlueprintUpdateSatisfied,
67
+ assertDeepUiCheckSatisfied,
68
+ assertDependenciesInstalled,
69
+ assertFinalReportExists,
49
70
  assertGhAuth,
50
71
  assertGitCurrentBranch,
51
72
  assertGitRepository,
52
73
  assertGithubOrigin,
74
+ assertIssueMetadataExists,
53
75
  assertIssueTextExists,
54
76
  assertIssueUrlExists,
77
+ assertAutomatedChecksPassed,
78
+ assertIssueDetailsExists,
79
+ assertPlanTextExists,
55
80
  assertPrUrlExists,
81
+ assertReadyJskitApp,
56
82
  assertSessionExists,
57
83
  assertTargetRootWritable,
58
84
  assertWorktreeExists,
@@ -63,6 +89,10 @@ import {
63
89
  renderPrompt,
64
90
  renderTemplate
65
91
  } from "./sessionRuntime/promptRenderer.js";
92
+ import {
93
+ HELPER_MAP_JSON_RELATIVE_PATH,
94
+ HELPER_MAP_MARKDOWN_RELATIVE_PATH
95
+ } from "./helperMapPaths.js";
66
96
 
67
97
  function invalidSessionIdError(sessionId = "") {
68
98
  return createError({
@@ -130,9 +160,9 @@ function extractMarkedText(value = "", marker = "") {
130
160
  if (!normalizedMarker) {
131
161
  return "";
132
162
  }
133
- const pattern = new RegExp(`\\[${normalizedMarker}\\]([\\s\\S]*?)\\[/${normalizedMarker}\\]`, "u");
134
- const match = pattern.exec(text);
135
- return normalizeText(match ? match[1] : "");
163
+ const pattern = new RegExp(`\\[${normalizedMarker}\\]([\\s\\S]*?)\\[/${normalizedMarker}\\]`, "gu");
164
+ const matches = [...text.matchAll(pattern)];
165
+ return normalizeText(matches.length > 0 ? matches[matches.length - 1][1] : "");
136
166
  }
137
167
 
138
168
  function extractIssueTitle(value = "") {
@@ -147,10 +177,252 @@ function extractPlanText(value = "") {
147
177
  return extractMarkedText(value, "plan") || normalizeText(value);
148
178
  }
149
179
 
180
+ function extractIssueDetails(value = "") {
181
+ return extractMarkedText(value, "issue_details");
182
+ }
183
+
184
+ function extractIssueCategory(value = "") {
185
+ return extractMarkedText(value, "issue_category");
186
+ }
187
+
188
+ function extractUiImpact(value = "") {
189
+ return extractMarkedText(value, "ui_impact");
190
+ }
191
+
192
+ function extractAgentDecisions(value = "") {
193
+ return extractMarkedText(value, "agent_decisions");
194
+ }
195
+
196
+ function normalizeIssueCategory(value = "") {
197
+ const category = normalizeText(value).toLowerCase();
198
+ return ["client", "server", "client_server", "tooling", "unknown"].includes(category)
199
+ ? category
200
+ : "";
201
+ }
202
+
203
+ function normalizeUiImpact(value = "") {
204
+ const impact = normalizeText(value).toLowerCase();
205
+ return ["none", "possible", "definite", "unknown"].includes(impact)
206
+ ? impact
207
+ : "";
208
+ }
209
+
150
210
  async function writePromptArtifact(paths, fileName, prompt) {
151
211
  await writeTextFile(path.join(paths.sessionRoot, "prompts", fileName), prompt);
152
212
  }
153
213
 
214
+ function commandText(command, args = []) {
215
+ return [command, ...args].map((part) => {
216
+ const value = String(part || "");
217
+ return /^[A-Za-z0-9_./:=@,+-]+$/u.test(value)
218
+ ? value
219
+ : `'${value.replaceAll("'", "'\\''")}'`;
220
+ }).join(" ");
221
+ }
222
+
223
+ function cycleRootPath(paths, cycle) {
224
+ return path.join(paths.sessionRoot, "cycles", `cycle_${cycle}`);
225
+ }
226
+
227
+ function cyclePlanPath(paths, cycle) {
228
+ return path.join(cycleRootPath(paths, cycle), "plan.md");
229
+ }
230
+
231
+ function cyclePlanPromptFileName(cycle) {
232
+ return `cycle_${cycle}_plan_request.md`;
233
+ }
234
+
235
+ function cyclePlanExecutionPromptFileName(cycle) {
236
+ return `cycle_${cycle}_plan_execution.md`;
237
+ }
238
+
239
+ async function readCurrentPlan(paths) {
240
+ const activeCycle = await readActiveCycle(paths);
241
+ const planPath = cyclePlanPath(paths, activeCycle);
242
+ return {
243
+ activeCycle,
244
+ planPath,
245
+ planText: await readTrimmedFile(planPath)
246
+ };
247
+ }
248
+
249
+ function commandOutputSummary(output = "") {
250
+ const normalized = normalizeText(output);
251
+ if (normalized.length <= 1800) {
252
+ return normalized;
253
+ }
254
+ return normalized.slice(-1800);
255
+ }
256
+
257
+ async function appendCommandLog(paths, {
258
+ args = [],
259
+ command,
260
+ cwd = "",
261
+ kind = "command",
262
+ result
263
+ } = {}) {
264
+ if (!paths?.sessionRoot || !command || !result) {
265
+ return;
266
+ }
267
+ const entry = {
268
+ at: timestampForReceipt(),
269
+ command: commandText(command, args),
270
+ cwd,
271
+ exitCode: Number.isInteger(result.exitCode) ? result.exitCode : null,
272
+ kind,
273
+ ok: result.ok === true,
274
+ outputSummary: commandOutputSummary(result.output)
275
+ };
276
+ await appendFile(path.join(paths.sessionRoot, "command_log.jsonl"), `${JSON.stringify(entry)}\n`, "utf8");
277
+ }
278
+
279
+ async function runLoggedCommand(paths, kind, command, args = [], options = {}) {
280
+ const result = await runCommand(command, args, options);
281
+ await appendCommandLog(paths, {
282
+ args,
283
+ command,
284
+ cwd: options.cwd || "",
285
+ kind,
286
+ result
287
+ });
288
+ return result;
289
+ }
290
+
291
+ async function readIssueMetadata(paths) {
292
+ const source = await readTextIfExists(path.join(paths.sessionRoot, "issue_metadata.json"));
293
+ if (!source) {
294
+ return {};
295
+ }
296
+ try {
297
+ const parsed = JSON.parse(source);
298
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
299
+ } catch {
300
+ return {};
301
+ }
302
+ }
303
+
304
+ async function writeIssueMetadata(paths, metadata = {}) {
305
+ const existing = await readIssueMetadata(paths);
306
+ const next = {
307
+ ...existing,
308
+ ...metadata
309
+ };
310
+ await writeTextFile(path.join(paths.sessionRoot, "issue_metadata.json"), `${JSON.stringify(next, null, 2)}\n`);
311
+ return next;
312
+ }
313
+
314
+ async function readGithubComments(paths) {
315
+ const source = await readTextIfExists(path.join(paths.sessionRoot, "github_comments.json"));
316
+ if (!source) {
317
+ return {};
318
+ }
319
+ const parsed = parseJsonObject(source);
320
+ return parsed || {};
321
+ }
322
+
323
+ async function writeGithubComments(paths, comments = {}) {
324
+ await writeTextFile(path.join(paths.sessionRoot, "github_comments.json"), `${JSON.stringify(comments, null, 2)}\n`);
325
+ }
326
+
327
+ async function commentOnIssueOnce(paths, {
328
+ bodyFile,
329
+ issueUrl,
330
+ purpose
331
+ }) {
332
+ const normalizedPurpose = normalizeText(purpose);
333
+ if (!issueUrl || !normalizedPurpose) {
334
+ return {
335
+ ok: true,
336
+ skipped: true
337
+ };
338
+ }
339
+ const comments = await readGithubComments(paths);
340
+ if (comments[normalizedPurpose]) {
341
+ return {
342
+ ok: true,
343
+ skipped: true
344
+ };
345
+ }
346
+ const result = await runLoggedCommand(paths, "github_issue_comment", "gh", ["issue", "comment", issueUrl, "--body-file", bodyFile], {
347
+ cwd: paths.targetRoot,
348
+ timeout: 1000 * 60
349
+ });
350
+ if (!result.ok) {
351
+ return {
352
+ ok: false,
353
+ output: result.output
354
+ };
355
+ }
356
+ comments[normalizedPurpose] = {
357
+ bodyFile,
358
+ commentedAt: timestampForReceipt(),
359
+ issueUrl,
360
+ purpose: normalizedPurpose
361
+ };
362
+ await writeGithubComments(paths, comments);
363
+ return {
364
+ ok: true,
365
+ skipped: false
366
+ };
367
+ }
368
+
369
+ async function appendAgentDecisions(paths, decisions = "") {
370
+ const normalized = normalizeText(decisions);
371
+ if (!normalized) {
372
+ return;
373
+ }
374
+ const decisionsPath = path.join(paths.sessionRoot, "agent_decisions.md");
375
+ const existing = await readTextIfExists(decisionsPath);
376
+ await writeTextFile(
377
+ decisionsPath,
378
+ `${existing}${existing && !existing.endsWith("\n") ? "\n" : ""}${normalized}\n`
379
+ );
380
+ }
381
+
382
+ async function appendAgentDecisionsInput(paths, options = {}) {
383
+ const source = normalizeText(options.agentDecisions || options["agent-decisions"]);
384
+ if (!source) {
385
+ return;
386
+ }
387
+ await appendAgentDecisions(paths, extractAgentDecisions(source) || source);
388
+ }
389
+
390
+ async function recordIssueInAgentDecisions(paths, issueUrl = "") {
391
+ const normalizedIssueUrl = normalizeText(issueUrl);
392
+ if (!normalizedIssueUrl) {
393
+ return;
394
+ }
395
+ const decisionsPath = path.join(paths.sessionRoot, "agent_decisions.md");
396
+ const existing = await readTextIfExists(decisionsPath);
397
+ if (existing.includes(`Issue: ${normalizedIssueUrl}`)) {
398
+ return;
399
+ }
400
+ await writeTextFile(
401
+ decisionsPath,
402
+ `${existing}${existing && !existing.endsWith("\n") ? "\n" : ""}Issue: ${normalizedIssueUrl}\n\n`
403
+ );
404
+ }
405
+
406
+ function issueMetadataFromUrl(issueUrl = "") {
407
+ const match = /^https:\/\/github\.com\/([^/\s]+)\/([^/\s]+)\/issues\/(\d+)(?:\b|$)/u.exec(normalizeText(issueUrl));
408
+ if (!match) {
409
+ return {
410
+ issueNumber: "",
411
+ owner: "",
412
+ repository: ""
413
+ };
414
+ }
415
+ return {
416
+ issueNumber: match[3],
417
+ owner: match[1],
418
+ repository: match[2]
419
+ };
420
+ }
421
+
422
+ function nextCycleNumber(cycle = "001") {
423
+ return String(Number.parseInt(String(cycle || "1"), 10) + 1).padStart(3, "0");
424
+ }
425
+
154
426
  async function createSession({
155
427
  targetRoot = process.cwd(),
156
428
  sessionId = "",
@@ -186,6 +458,9 @@ async function createSession({
186
458
  await ensureStudioGitExclude(initialPaths.targetRoot);
187
459
  await mkdir(initialPaths.sessionRoot, { recursive: true });
188
460
  await writeTextFile(path.join(initialPaths.sessionRoot, "transcript.log"), "");
461
+ await writeTextFile(path.join(initialPaths.sessionRoot, "agent_decisions.md"), `# Agent Decisions\n\nSession: ${initialPaths.sessionId}\nCreated: ${now.toISOString()}\n\n`);
462
+ await writeTextFile(path.join(initialPaths.sessionRoot, "workflow_version"), `${SESSION_WORKFLOW_VERSION}\n`);
463
+ await writeActiveCycle(initialPaths, "001");
189
464
  await markStatus(initialPaths, SESSION_STATUS.PENDING);
190
465
  await writeReceipt(initialPaths, "session_created", `Created JSKIT Studio issue session ${initialPaths.sessionId}.`);
191
466
 
@@ -294,11 +569,11 @@ async function inspectSessionDetails({
294
569
  }
295
570
  const { paths, preconditions } = context;
296
571
  const response = await buildSessionResponse(paths, { preconditions });
572
+ const { planText } = await readCurrentPlan(paths);
297
573
 
298
- const [issueText, issueTitle, planText, receipts, transcriptLog] = await Promise.all([
574
+ const [issueText, issueTitle, receipts, transcriptLog] = await Promise.all([
299
575
  readTextIfExists(path.join(paths.sessionRoot, "issue.md")),
300
576
  readTrimmedFile(path.join(paths.sessionRoot, "issue_title")),
301
- readTextIfExists(path.join(paths.sessionRoot, "plan.md")),
302
577
  readReceiptSteps(paths),
303
578
  readTextIfExists(path.join(paths.sessionRoot, "transcript.log"))
304
579
  ]);
@@ -341,7 +616,19 @@ async function removeEmptyStaleWorktreeDirectory(paths) {
341
616
 
342
617
  async function createWorktree(paths, _options = {}, context = {}) {
343
618
  const preconditions = context.preconditions || [];
619
+ const [baseBranchResult, baseCommitResult] = await Promise.all([
620
+ runGit(paths.targetRoot, ["branch", "--show-current"], { timeout: 15000 }),
621
+ runGit(paths.targetRoot, ["rev-parse", "--verify", "HEAD"], { timeout: 15000 })
622
+ ]);
623
+ const baseBranch = normalizeText(baseBranchResult.stdout);
624
+ const baseCommit = normalizeText(baseCommitResult.stdout);
344
625
  if (await hasWorktree(paths)) {
626
+ if (baseBranch && !await readTrimmedFile(path.join(paths.sessionRoot, "base_branch"))) {
627
+ await writeTextFile(path.join(paths.sessionRoot, "base_branch"), `${baseBranch}\n`);
628
+ }
629
+ if (baseCommit && !await readTrimmedFile(path.join(paths.sessionRoot, "base_commit"))) {
630
+ await writeTextFile(path.join(paths.sessionRoot, "base_commit"), `${baseCommit}\n`);
631
+ }
345
632
  await writeReceipt(paths, "worktree_created", `Reused existing worktree ${paths.worktree}.`);
346
633
  await markStatus(paths, SESSION_STATUS.RUNNING);
347
634
  return buildSessionResponse(paths, {
@@ -359,7 +646,8 @@ async function createWorktree(paths, _options = {}, context = {}) {
359
646
  preconditions
360
647
  });
361
648
  }
362
- const result = await runGit(paths.targetRoot, ["worktree", "add", "-b", paths.branch, paths.worktree, "HEAD"], {
649
+ const result = await runLoggedCommand(paths, "git_worktree_add", "git", ["worktree", "add", "-b", paths.branch, paths.worktree, "HEAD"], {
650
+ cwd: paths.targetRoot,
363
651
  timeout: 30000
364
652
  });
365
653
  if (!result.ok) {
@@ -370,6 +658,12 @@ async function createWorktree(paths, _options = {}, context = {}) {
370
658
  preconditions
371
659
  });
372
660
  }
661
+ if (baseBranch) {
662
+ await writeTextFile(path.join(paths.sessionRoot, "base_branch"), `${baseBranch}\n`);
663
+ }
664
+ if (baseCommit) {
665
+ await writeTextFile(path.join(paths.sessionRoot, "base_commit"), `${baseCommit}\n`);
666
+ }
373
667
  await writeReceipt(paths, "worktree_created", `Created worktree ${paths.worktree} on branch ${paths.branch}.`);
374
668
  await markStatus(paths, SESSION_STATUS.RUNNING);
375
669
  return buildSessionResponse(paths, {
@@ -388,22 +682,79 @@ async function recordDependenciesInstalled(paths, {
388
682
  });
389
683
  }
390
684
 
685
+ function parsePackageManager(value = "") {
686
+ const normalized = normalizeText(value);
687
+ const match = /^([a-z][a-z0-9-]*)(?:@(.+))?$/u.exec(normalized);
688
+ if (!match) {
689
+ return {
690
+ name: "",
691
+ version: ""
692
+ };
693
+ }
694
+ return {
695
+ name: match[1],
696
+ version: match[2] || ""
697
+ };
698
+ }
699
+
700
+ async function hasWorktreeFile(worktree, fileName) {
701
+ return fileExists(path.join(worktree, fileName));
702
+ }
703
+
704
+ async function dependencyInstallCommandForWorktree(worktree) {
705
+ const packageJsonSource = await readTextIfExists(path.join(worktree, "package.json"));
706
+ let packageManager = {
707
+ name: "",
708
+ version: ""
709
+ };
710
+ if (packageJsonSource) {
711
+ try {
712
+ const packageJson = JSON.parse(packageJsonSource);
713
+ packageManager = parsePackageManager(packageJson?.packageManager);
714
+ } catch {
715
+ packageManager = {
716
+ name: "",
717
+ version: ""
718
+ };
719
+ }
720
+ }
721
+ const hasPackageLock = await hasWorktreeFile(worktree, "package-lock.json") ||
722
+ await hasWorktreeFile(worktree, "npm-shrinkwrap.json");
723
+ const hasPnpmLock = await hasWorktreeFile(worktree, "pnpm-lock.yaml");
724
+ const hasYarnLock = await hasWorktreeFile(worktree, "yarn.lock");
725
+ const hasBunLock = await hasWorktreeFile(worktree, "bun.lock") ||
726
+ await hasWorktreeFile(worktree, "bun.lockb");
727
+
728
+ if (packageManager.name === "pnpm" || (!packageManager.name && hasPnpmLock)) {
729
+ return ["pnpm", hasPnpmLock ? ["install", "--frozen-lockfile"] : ["install"]];
730
+ }
731
+ if (packageManager.name === "yarn" || (!packageManager.name && hasYarnLock)) {
732
+ const major = Number.parseInt(packageManager.version.split(".")[0] || "1", 10);
733
+ return ["yarn", hasYarnLock && major >= 2 ? ["install", "--immutable"] : hasYarnLock ? ["install", "--frozen-lockfile"] : ["install"]];
734
+ }
735
+ if (packageManager.name === "bun" || (!packageManager.name && hasBunLock)) {
736
+ return ["bun", hasBunLock ? ["install", "--frozen-lockfile"] : ["install"]];
737
+ }
738
+ return ["npm", hasPackageLock ? ["ci"] : ["install"]];
739
+ }
740
+
391
741
  async function installDependencies(paths, _options = {}, context = {}) {
392
742
  const preconditions = context.preconditions || [];
393
- const result = await runCommand("npm", ["install"], {
743
+ const [command, args] = await dependencyInstallCommandForWorktree(paths.worktree);
744
+ const result = await runLoggedCommand(paths, "dependencies_install", command, args, {
394
745
  cwd: paths.worktree,
395
746
  timeout: 1000 * 60 * 10
396
747
  });
397
748
  if (!result.ok) {
398
749
  return failSession(paths, {
399
750
  code: "dependencies_install_failed",
400
- message: result.output || "npm install failed in the session worktree.",
401
- repairCommand: `cd ${paths.worktree} && npm install`,
751
+ message: result.output || `${command} ${args.join(" ")} failed in the session worktree.`,
752
+ repairCommand: `cd ${paths.worktree} && ${command} ${args.join(" ")}`,
402
753
  preconditions
403
754
  });
404
755
  }
405
756
  return recordDependenciesInstalled(paths, {
406
- message: result.output || "Installed Node dependencies in the session worktree.",
757
+ message: result.output || `Installed Node dependencies in the session worktree with ${command} ${args.join(" ")}.`,
407
758
  preconditions
408
759
  });
409
760
  }
@@ -465,7 +816,14 @@ async function draftIssue(paths, options = {}) {
465
816
  repairCommand: `jskit session ${paths.sessionId} step --issue -`
466
817
  });
467
818
  }
468
- const issueTitle = normalizeText(options.issueTitle) || extractIssueTitle(options.issue) || titleFromIssue(issueText);
819
+ const issueTitle = normalizeText(options.issueTitle) || extractIssueTitle(options.issue);
820
+ if (!issueTitle) {
821
+ return failSession(paths, {
822
+ code: "issue_title_required",
823
+ message: "The issue drafting step requires an approved issue title.",
824
+ repairCommand: `jskit session ${paths.sessionId} step --issue-title "<title>" --issue -`
825
+ });
826
+ }
469
827
  await writeTextFile(path.join(paths.sessionRoot, "issue.md"), issueText);
470
828
  await writeTextFile(path.join(paths.sessionRoot, "issue_title"), issueTitle);
471
829
  await writeReceipt(paths, "issue_drafted", "Saved approved issue text.");
@@ -483,9 +841,25 @@ function titleFromIssue(issueText) {
483
841
 
484
842
  async function createIssue(paths, _options = {}, context = {}) {
485
843
  const preconditions = context.preconditions || [];
844
+ const existingIssueUrl = await readTrimmedFile(path.join(paths.sessionRoot, "issue_url"));
486
845
  const issueText = await readTrimmedFile(path.join(paths.sessionRoot, "issue.md"));
487
846
  const issueTitle = await readTrimmedFile(path.join(paths.sessionRoot, "issue_title")) || titleFromIssue(issueText);
488
- const result = await runCommand("gh", [
847
+ if (existingIssueUrl) {
848
+ await writeIssueMetadata(paths, {
849
+ ...issueMetadataFromUrl(existingIssueUrl),
850
+ issueBody: issueText,
851
+ issueBodyPath: path.join(paths.sessionRoot, "issue.md"),
852
+ issueTitle,
853
+ issueUrl: existingIssueUrl
854
+ });
855
+ await recordIssueInAgentDecisions(paths, existingIssueUrl);
856
+ await writeReceipt(paths, "issue_created", `Reused GitHub issue ${existingIssueUrl}.`);
857
+ await markStatus(paths, SESSION_STATUS.RUNNING);
858
+ return buildSessionResponse(paths, {
859
+ preconditions
860
+ });
861
+ }
862
+ const result = await runLoggedCommand(paths, "github_issue_create", "gh", [
489
863
  "issue",
490
864
  "create",
491
865
  "--title",
@@ -506,6 +880,14 @@ async function createIssue(paths, _options = {}, context = {}) {
506
880
  }
507
881
  const issueUrl = result.stdout.split(/\r?\n/u).map((line) => line.trim()).find(Boolean) || result.stdout;
508
882
  await writeTextFile(path.join(paths.sessionRoot, "issue_url"), issueUrl);
883
+ await writeIssueMetadata(paths, {
884
+ ...issueMetadataFromUrl(issueUrl),
885
+ issueBody: issueText,
886
+ issueBodyPath: path.join(paths.sessionRoot, "issue.md"),
887
+ issueTitle,
888
+ issueUrl
889
+ });
890
+ await recordIssueInAgentDecisions(paths, issueUrl);
509
891
  await writeReceipt(paths, "issue_created", `Created GitHub issue ${issueUrl}.`);
510
892
  await markStatus(paths, SESSION_STATUS.RUNNING);
511
893
  return buildSessionResponse(paths, {
@@ -513,26 +895,142 @@ async function createIssue(paths, _options = {}, context = {}) {
513
895
  });
514
896
  }
515
897
 
898
+ async function renderIssueDetailsPrompt(paths, _options = {}, context = {}) {
899
+ const preconditions = context.preconditions || [];
900
+ const issueText = await readTrimmedFile(path.join(paths.sessionRoot, "issue.md"));
901
+ const issueTitle = await readTrimmedFile(path.join(paths.sessionRoot, "issue_title")) || titleFromIssue(issueText);
902
+ const issueUrl = await readTrimmedFile(path.join(paths.sessionRoot, "issue_url"));
903
+ const issueNumber = issueNumberFromUrl(issueUrl);
904
+ const prompt = await renderPrompt(paths, "issue_details.md", {
905
+ issue_file: path.join(paths.sessionRoot, "issue.md"),
906
+ issue_number: issueNumber,
907
+ issue_text: issueText,
908
+ issue_title: issueTitle,
909
+ issue_url: issueUrl,
910
+ issue_details_file: path.join(paths.sessionRoot, "issue_details.md"),
911
+ worktree: paths.worktree
912
+ });
913
+ await writePromptArtifact(paths, "issue_details.md", prompt);
914
+ await markStatus(paths, SESSION_STATUS.WAITING_FOR_USER);
915
+ return buildSessionResponse(paths, {
916
+ codex: ISSUE_DETAILS_CODEX_HANDOFF,
917
+ ok: true,
918
+ preconditions,
919
+ prompt,
920
+ status: SESSION_STATUS.WAITING_FOR_USER
921
+ });
922
+ }
923
+
924
+ async function saveIssueDetails(paths, options = {}, context = {}) {
925
+ const preconditions = context.preconditions || [];
926
+ const source = normalizeText(options.issueDetails || options["issue-details"]);
927
+ const structuredIssueCategory = normalizeText(options.issueCategory || options["issue-category"]);
928
+ const structuredUiImpact = normalizeText(options.uiImpact || options["ui-impact"]);
929
+ const issueDetails = extractIssueDetails(source) || (
930
+ structuredIssueCategory && structuredUiImpact ? source : ""
931
+ );
932
+ if (!source && !structuredIssueCategory && !structuredUiImpact) {
933
+ return renderIssueDetailsPrompt(paths, options, context);
934
+ }
935
+ if (!issueDetails) {
936
+ return failSession(paths, {
937
+ code: "issue_details_required",
938
+ message: "The details step requires confirmed issue details from Codex or the Studio form.",
939
+ repairCommand: `jskit session ${paths.sessionId} step --issue-details -`,
940
+ preconditions
941
+ });
942
+ }
943
+
944
+ const issueCategory = normalizeIssueCategory(structuredIssueCategory || extractIssueCategory(source));
945
+ const uiImpact = normalizeUiImpact(structuredUiImpact || extractUiImpact(source));
946
+ if (!issueCategory) {
947
+ return failSession(paths, {
948
+ code: "issue_category_invalid",
949
+ message: "Issue details must include [issue_category]client, server, client_server, tooling, or unknown[/issue_category].",
950
+ repairCommand: `jskit session ${paths.sessionId} step --issue-details -`,
951
+ preconditions
952
+ });
953
+ }
954
+ if (!uiImpact) {
955
+ return failSession(paths, {
956
+ code: "ui_impact_invalid",
957
+ message: "Issue details must include [ui_impact]none, possible, definite, or unknown[/ui_impact].",
958
+ repairCommand: `jskit session ${paths.sessionId} step --issue-details -`,
959
+ preconditions
960
+ });
961
+ }
962
+
963
+ await writeTextFile(path.join(paths.sessionRoot, "issue_details.md"), issueDetails);
964
+ await writeIssueMetadata(paths, {
965
+ issueCategory,
966
+ uiImpact,
967
+ issueDetailsPath: path.join(paths.sessionRoot, "issue_details.md")
968
+ });
969
+
970
+ const decisions = extractAgentDecisions(source);
971
+ await appendAgentDecisions(paths, decisions);
972
+
973
+ const issueUrl = await readTrimmedFile(path.join(paths.sessionRoot, "issue_url"));
974
+ if (issueUrl) {
975
+ const commentResult = await commentOnIssueOnce(paths, {
976
+ bodyFile: path.join(paths.sessionRoot, "issue_details.md"),
977
+ issueUrl,
978
+ purpose: "issue_details"
979
+ });
980
+ if (!commentResult.ok) {
981
+ return failSession(paths, {
982
+ code: "issue_details_comment_failed",
983
+ message: commentResult.output || "Failed to comment the issue details on the GitHub issue.",
984
+ repairCommand: `gh issue comment ${issueUrl} --body-file ${path.join(paths.sessionRoot, "issue_details.md")}`,
985
+ preconditions
986
+ });
987
+ }
988
+ }
989
+
990
+ await writeReceipt(paths, "issue_details_gathered", "Saved confirmed issue details and recorded the GitHub issue comment.");
991
+ await markStatus(paths, SESSION_STATUS.RUNNING);
992
+ return buildSessionResponse(paths, {
993
+ preconditions
994
+ });
995
+ }
996
+
516
997
  async function makePlan(paths, options = {}, context = {}) {
517
998
  const preconditions = context.preconditions || [];
999
+ const activeCycle = await readActiveCycle(paths);
518
1000
  const issueText = await readTrimmedFile(path.join(paths.sessionRoot, "issue.md"));
519
1001
  const issueTitle = await readTrimmedFile(path.join(paths.sessionRoot, "issue_title")) || titleFromIssue(issueText);
520
1002
  const issueUrl = await readTrimmedFile(path.join(paths.sessionRoot, "issue_url"));
521
1003
  const issueNumber = issueNumberFromUrl(issueUrl);
1004
+ const issueDetails = await readTrimmedFile(path.join(paths.sessionRoot, "issue_details.md"));
522
1005
  const planText = extractPlanText(options.plan);
1006
+ const agentDecisionsPath = path.join(paths.sessionRoot, "agent_decisions.md");
1007
+ const agentDecisionsText = await readTextIfExists(agentDecisionsPath);
1008
+ const currentCycleRoot = cycleRootPath(paths, activeCycle);
1009
+ const planPath = cyclePlanPath(paths, activeCycle);
1010
+ const reworkRequestPath = path.join(currentCycleRoot, "rework_request.md");
1011
+ const reworkRequest = await readTextIfExists(reworkRequestPath);
523
1012
 
524
1013
  if (!planText) {
525
1014
  const prompt = await renderPrompt(paths, "plan_issue.md", {
1015
+ active_cycle: activeCycle,
1016
+ agent_decisions_file: agentDecisionsPath,
1017
+ agent_decisions_text: agentDecisionsText,
1018
+ app_blueprint_file: path.join(paths.worktree, ".jskit", "APP_BLUEPRINT.md"),
526
1019
  issue_file: path.join(paths.sessionRoot, "issue.md"),
527
1020
  issue_number: issueNumber,
528
1021
  issue_text: issueText,
529
1022
  issue_title: issueTitle,
530
1023
  issue_title_file: path.join(paths.sessionRoot, "issue_title"),
531
1024
  issue_url: issueUrl,
532
- plan_file: path.join(paths.sessionRoot, "plan.md"),
1025
+ issue_details_file: path.join(paths.sessionRoot, "issue_details.md"),
1026
+ issue_details_text: issueDetails,
1027
+ plan_file: planPath,
1028
+ plan_source: activeCycle === "001" ? "issue" : "rework",
1029
+ rework_request: reworkRequest,
1030
+ rework_request_file: reworkRequest ? reworkRequestPath : "",
533
1031
  worktree: paths.worktree
534
1032
  });
535
- await writePromptArtifact(paths, "plan_request.md", prompt);
1033
+ await writePromptArtifact(paths, cyclePlanPromptFileName(activeCycle), prompt);
536
1034
  await markStatus(paths, SESSION_STATUS.WAITING_FOR_USER);
537
1035
  return buildSessionResponse(paths, {
538
1036
  ok: true,
@@ -542,31 +1040,48 @@ async function makePlan(paths, options = {}, context = {}) {
542
1040
  });
543
1041
  }
544
1042
 
545
- const planPath = path.join(paths.sessionRoot, "plan.md");
1043
+ await mkdir(currentCycleRoot, { recursive: true });
546
1044
  await writeTextFile(planPath, planText);
547
- const commentResult = await runCommand("gh", ["issue", "comment", issueUrl, "--body-file", planPath], {
548
- cwd: paths.targetRoot,
549
- timeout: 1000 * 60
1045
+ await appendAgentDecisions(paths, extractAgentDecisions(options.plan));
1046
+ await writeReceipt(paths, "plan_made", `Saved cycle ${activeCycle} plan.`);
1047
+ await markStatus(paths, SESSION_STATUS.RUNNING);
1048
+ return buildSessionResponse(paths, {
1049
+ preconditions
550
1050
  });
551
- if (!commentResult.ok) {
552
- return failSession(paths, {
553
- code: "plan_comment_failed",
554
- message: commentResult.output || "Failed to comment the implementation plan on the GitHub issue.",
555
- repairCommand: `gh issue comment ${issueUrl} --body-file ${planPath}`,
1051
+ }
1052
+
1053
+ async function renderPlanExecutionPrompt(paths, _options = {}, context = {}) {
1054
+ const preconditions = context.preconditions || [];
1055
+ const activeCycle = await readActiveCycle(paths);
1056
+ const executionPromptPath = path.join(paths.sessionRoot, "prompts", cyclePlanExecutionPromptFileName(activeCycle));
1057
+ if (await fileExists(executionPromptPath)) {
1058
+ await writeReceipt(paths, "plan_executed", `Cycle ${activeCycle} plan execution completed by Codex.`);
1059
+ await markStatus(paths, SESSION_STATUS.RUNNING);
1060
+ return buildSessionResponse(paths, {
556
1061
  preconditions
557
1062
  });
558
1063
  }
1064
+
1065
+ const issueText = await readTrimmedFile(path.join(paths.sessionRoot, "issue.md"));
1066
+ const issueTitle = await readTrimmedFile(path.join(paths.sessionRoot, "issue_title")) || titleFromIssue(issueText);
1067
+ const issueUrl = await readTrimmedFile(path.join(paths.sessionRoot, "issue_url"));
1068
+ const issueNumber = issueNumberFromUrl(issueUrl);
1069
+ const { planPath, planText } = await readCurrentPlan(paths);
1070
+ const issueDetailsPath = path.join(paths.sessionRoot, "issue_details.md");
1071
+ const issueDetails = await readTrimmedFile(issueDetailsPath);
559
1072
  const executionPrompt = await renderPrompt(paths, "execute_plan.md", {
1073
+ active_cycle: activeCycle,
560
1074
  issue_file: path.join(paths.sessionRoot, "issue.md"),
561
1075
  issue_number: issueNumber,
562
1076
  issue_title: issueTitle,
563
1077
  issue_url: issueUrl,
1078
+ issue_details_file: issueDetailsPath,
1079
+ issue_details_text: issueDetails,
564
1080
  plan_file: planPath,
565
1081
  plan_text: planText,
566
1082
  worktree: paths.worktree
567
1083
  });
568
- await writePromptArtifact(paths, "plan_execution.md", executionPrompt);
569
- await writeReceipt(paths, "plan_made", `Saved plan and commented on ${issueUrl}.`);
1084
+ await writePromptArtifact(paths, cyclePlanExecutionPromptFileName(activeCycle), executionPrompt);
570
1085
  await markStatus(paths, SESSION_STATUS.WAITING_FOR_USER);
571
1086
  return buildSessionResponse(paths, {
572
1087
  codex: PLAN_EXECUTION_CODEX_HANDOFF,
@@ -696,27 +1211,6 @@ async function inspectSessionDiff({
696
1211
  });
697
1212
  }
698
1213
 
699
- async function acceptImplementationChanges(paths) {
700
- const status = await worktreeStatus(paths.worktree);
701
- if (!status.ok) {
702
- return failSession(paths, {
703
- code: "git_status_failed",
704
- message: status.output || "Failed to inspect worktree changes.",
705
- repairCommand: `git -C ${paths.worktree} status --short`
706
- });
707
- }
708
- if (status.changedFiles.length < 1) {
709
- return failSession(paths, {
710
- code: "changes_missing",
711
- message: "No worktree changes found. Ask Codex to implement the approved plan, inspect the worktree, then accept changes once ready.",
712
- repairCommand: `jskit session ${paths.sessionId} step`
713
- });
714
- }
715
- await writeReceipt(paths, "implementation_changes_accepted", `Accepted ${status.changedFiles.length} changed file entries for commit.`);
716
- await markStatus(paths, SESSION_STATUS.RUNNING);
717
- return buildSessionResponse(paths);
718
- }
719
-
720
1214
  async function commitWorktree(paths, {
721
1215
  message,
722
1216
  allowNoChanges = false
@@ -758,35 +1252,105 @@ async function commitWorktree(paths, {
758
1252
  };
759
1253
  }
760
1254
 
761
- async function commitImplementation(paths) {
762
- const result = await commitWorktree(paths, {
763
- message: `Implement JSKIT session ${paths.sessionId}`
1255
+ function uniqueChangedFileList(entries = []) {
1256
+ return [...new Set(entries
1257
+ .flatMap((entry) => String(entry || "").split(/\r?\n/u))
1258
+ .map((line) => line.trim())
1259
+ .filter(Boolean))]
1260
+ .sort((left, right) => left.localeCompare(right));
1261
+ }
1262
+
1263
+ async function changedFilesInWorktree(paths) {
1264
+ const [trackedResult, untrackedResult] = await Promise.all([
1265
+ runGitInWorktree(paths.worktree, ["diff", "--name-only", "HEAD"], {
1266
+ timeout: 15000
1267
+ }),
1268
+ runGitInWorktree(paths.worktree, ["ls-files", "--others", "--exclude-standard"], {
1269
+ timeout: 15000
1270
+ })
1271
+ ]);
1272
+ return uniqueChangedFileList([
1273
+ trackedResult.ok ? trackedResult.stdout : "",
1274
+ untrackedResult.ok ? untrackedResult.stdout : ""
1275
+ ]);
1276
+ }
1277
+
1278
+ async function changedFilesSinceBase(paths) {
1279
+ const baseCommit = await readTrimmedFile(path.join(paths.sessionRoot, "base_commit"));
1280
+ const args = baseCommit
1281
+ ? ["diff", "--name-only", `${baseCommit}..HEAD`]
1282
+ : ["show", "--name-only", "--format=", "HEAD"];
1283
+ const result = await runGitInWorktree(paths.worktree, args, {
1284
+ timeout: 15000
764
1285
  });
765
1286
  if (!result.ok) {
766
- return failSession(paths, {
767
- code: "commit_failed",
768
- message: result.output || "Failed to commit implementation changes.",
769
- repairCommand: `git -C ${paths.worktree} status --short`
770
- });
1287
+ return (await changedFilesInWorktree(paths)).join("\n");
771
1288
  }
772
- await writeReceipt(paths, "implementation_changes_committed", `Committed implementation changes for ${paths.sessionId}.`);
773
- await markStatus(paths, SESSION_STATUS.RUNNING);
774
- return buildSessionResponse(paths);
1289
+ return uniqueChangedFileList([
1290
+ result.stdout,
1291
+ ...(await changedFilesInWorktree(paths))
1292
+ ]).join("\n");
775
1293
  }
776
1294
 
777
- async function changedFilesFromLastCommit(paths) {
778
- const result = await runGitInWorktree(paths.worktree, ["show", "--name-only", "--format=", "HEAD"]);
779
- if (!result.ok) {
780
- return "";
1295
+ function nextReviewPassNumber(pass = "") {
1296
+ const current = Number.parseInt(normalizeReviewPassNumber(pass), 10);
1297
+ return String(current + 1).padStart(3, "0");
1298
+ }
1299
+
1300
+ async function readCurrentReviewPass(paths) {
1301
+ return normalizeReviewPassNumber(await readTrimmedFile(path.join(paths.sessionRoot, "review_passes", "current_pass")));
1302
+ }
1303
+
1304
+ async function writeCurrentReviewPass(paths, pass) {
1305
+ await writeTextFile(path.join(paths.sessionRoot, "review_passes", "current_pass"), `${normalizeReviewPassNumber(pass)}\n`);
1306
+ }
1307
+
1308
+ async function resolveReviewPassForPrompt(paths) {
1309
+ const passes = await readReviewPasses(paths);
1310
+ const latestPass = passes.at(-1);
1311
+ if (!latestPass) {
1312
+ return "001";
1313
+ }
1314
+ if (!["accepted", "no_changes"].includes(latestPass.status)) {
1315
+ return latestPass.pass;
781
1316
  }
782
- return result.stdout.split(/\r?\n/u).map((line) => line.trim()).filter(Boolean).join("\n");
1317
+ return nextReviewPassNumber(latestPass.pass);
1318
+ }
1319
+
1320
+ async function writeReviewPassJson(paths, pass, fileName, payload) {
1321
+ const root = reviewPassRoot(paths, pass);
1322
+ await mkdir(root, { recursive: true });
1323
+ await writeTextFile(path.join(root, fileName), `${JSON.stringify(payload, null, 2)}\n`);
1324
+ }
1325
+
1326
+ async function currentHead(paths) {
1327
+ const result = await runGitInWorktree(paths.worktree, ["rev-parse", "HEAD"], {
1328
+ timeout: 15000
1329
+ });
1330
+ return result.ok ? result.stdout.trim() : "";
783
1331
  }
784
1332
 
785
1333
  async function renderReviewPrompt(paths) {
1334
+ const reviewPass = await resolveReviewPassForPrompt(paths);
1335
+ await writeCurrentReviewPass(paths, reviewPass);
1336
+ const changedFiles = await changedFilesSinceBase(paths);
786
1337
  const prompt = await renderPrompt(paths, "review_changes.md", {
787
- changed_files: await changedFilesFromLastCommit(paths)
1338
+ changed_files: changedFiles,
1339
+ review_pass_limit: String(REVIEW_PASS_LIMIT),
1340
+ review_pass_number: reviewPass
788
1341
  });
1342
+ const passRoot = reviewPassRoot(paths, reviewPass);
789
1343
  await writePromptArtifact(paths, "review.md", prompt);
1344
+ await mkdir(passRoot, { recursive: true });
1345
+ await writeTextFile(path.join(passRoot, "prompt.md"), prompt);
1346
+ await writeReviewPassJson(paths, reviewPass, "prompt.json", {
1347
+ changedFiles: changedFiles.split(/\r?\n/u).filter(Boolean),
1348
+ maxPasses: REVIEW_PASS_LIMIT,
1349
+ pass: reviewPass,
1350
+ promptPath: path.join(passRoot, "prompt.md"),
1351
+ status: "prompted",
1352
+ startedAt: timestampForReceipt()
1353
+ });
790
1354
  await writeReceipt(paths, "review_prompt_rendered", "Started code review.");
791
1355
  await markStatus(paths, SESSION_STATUS.WAITING_FOR_USER);
792
1356
  return buildSessionResponse(paths, {
@@ -796,41 +1360,186 @@ async function renderReviewPrompt(paths) {
796
1360
  });
797
1361
  }
798
1362
 
799
- async function acceptReviewChanges(paths) {
800
- const status = await worktreeStatus(paths.worktree);
801
- if (!status.ok) {
1363
+ async function acceptReviewChanges(paths, options = {}) {
1364
+ const reviewDecisionProvided = Object.hasOwn(options, "reviewFindingsRemaining") ||
1365
+ Object.hasOwn(options, "review-findings-remaining");
1366
+ if (!reviewDecisionProvided) {
802
1367
  return failSession(paths, {
803
- code: "git_status_failed",
1368
+ code: "review_decision_required",
1369
+ message: "Review/deslop requires an explicit decision before it can advance. Use reviewFindingsRemaining true to run another pass, or false when the review loop is done.",
1370
+ repairCommand: `jskit session ${paths.sessionId} step --review-findings-remaining false`
1371
+ });
1372
+ }
1373
+
1374
+ const status = await worktreeStatus(paths.worktree);
1375
+ if (!status.ok) {
1376
+ return failSession(paths, {
1377
+ code: "git_status_failed",
804
1378
  message: status.output || "Failed to inspect review changes.",
805
1379
  repairCommand: `git -C ${paths.worktree} status --short`
806
1380
  });
807
1381
  }
808
1382
  const message = status.changedFiles.length > 0
809
- ? `Accepted ${status.changedFiles.length} review changed file entries for commit.`
1383
+ ? `Accepted ${status.changedFiles.length} review changed file entries.`
810
1384
  : "Accepted review with no file changes.";
1385
+ const reviewPass = await readCurrentReviewPass(paths);
1386
+ const findingsRemaining = options.reviewFindingsRemaining === true ||
1387
+ normalizeText(options["review-findings-remaining"]).toLowerCase() === "true";
1388
+ const remainingFindings = normalizeText(options.reviewFindings || options["review-findings"]);
1389
+ await writeReviewPassJson(paths, reviewPass, "accepted.json", {
1390
+ acceptedAt: timestampForReceipt(),
1391
+ changedFiles: status.changedFiles || [],
1392
+ findingsRemaining,
1393
+ remainingFindings,
1394
+ pass: reviewPass,
1395
+ status: status.changedFiles?.length ? "accepted" : "no_changes"
1396
+ });
811
1397
  await writeReceipt(paths, "review_changes_accepted", message);
812
1398
  await markStatus(paths, SESSION_STATUS.RUNNING);
813
1399
  return buildSessionResponse(paths);
814
1400
  }
815
1401
 
816
- async function commitReviewChanges(paths) {
817
- const result = await commitWorktree(paths, {
818
- allowNoChanges: true,
819
- message: `Apply review changes for ${paths.sessionId}`
1402
+ async function runAutomatedChecks(paths, {
1403
+ stepId,
1404
+ label
1405
+ }) {
1406
+ const [command, args] = await doctorCommandForWorktree(paths.worktree);
1407
+ const promptFileName = `${stepId}.md`;
1408
+ const promptPath = path.join(paths.sessionRoot, "prompts", promptFileName);
1409
+ const checksRoot = path.join(paths.sessionRoot, "checks");
1410
+ await mkdir(checksRoot, { recursive: true });
1411
+ const checkCommand = [command, ...args].join(" ");
1412
+
1413
+ if (await fileExists(promptPath)) {
1414
+ await writeTextFile(
1415
+ path.join(checksRoot, `${stepId}.json`),
1416
+ `${JSON.stringify({
1417
+ command: checkCommand,
1418
+ ok: true,
1419
+ promptPath,
1420
+ status: "completed_by_codex",
1421
+ stepId
1422
+ }, null, 2)}\n`
1423
+ );
1424
+ await writeReceipt(paths, stepId, `${label} completed by Codex: ${checkCommand}.`);
1425
+ await markStatus(paths, SESSION_STATUS.RUNNING);
1426
+ return buildSessionResponse(paths);
1427
+ }
1428
+
1429
+ const prompt = await renderPrompt(paths, "automated_checks.md", {
1430
+ check_command: checkCommand
820
1431
  });
821
- if (!result.ok) {
822
- return failSession(paths, {
823
- code: "review_commit_failed",
824
- message: result.output || "Failed to commit review changes.",
825
- repairCommand: `git -C ${paths.worktree} status --short`
1432
+ await writePromptArtifact(paths, promptFileName, prompt);
1433
+ await writeTextFile(
1434
+ path.join(checksRoot, `${stepId}.json`),
1435
+ `${JSON.stringify({
1436
+ command: checkCommand,
1437
+ ok: false,
1438
+ promptPath,
1439
+ status: "prompted",
1440
+ stepId
1441
+ }, null, 2)}\n`
1442
+ );
1443
+ await markStatus(paths, SESSION_STATUS.WAITING_FOR_USER);
1444
+ return buildSessionResponse(paths, {
1445
+ codex: AUTOMATED_CHECK_REPAIR_CODEX_HANDOFF,
1446
+ prompt,
1447
+ status: SESSION_STATUS.WAITING_FOR_USER
1448
+ });
1449
+ }
1450
+
1451
+ async function writeUiCheckJson(paths, fileName, payload) {
1452
+ const uiChecksRoot = path.join(paths.sessionRoot, "ui_checks");
1453
+ await mkdir(uiChecksRoot, { recursive: true });
1454
+ await writeTextFile(path.join(uiChecksRoot, `${fileName}.json`), `${JSON.stringify(payload, null, 2)}\n`);
1455
+ }
1456
+
1457
+ async function runDeepUiCheck(paths, {
1458
+ stepId,
1459
+ label,
1460
+ phase
1461
+ }, options = {}, context = {}) {
1462
+ const preconditions = context.preconditions || [];
1463
+ const issueMetadata = await readIssueMetadata(paths);
1464
+ const uiImpact = normalizeUiImpact(issueMetadata.uiImpact) || "unknown";
1465
+ const skipRequested = options.skipUiCheck === true || normalizeText(options["skip-ui-check"]).toLowerCase() === "true";
1466
+ const skipReason = normalizeText(options.skipReason || options["skip-reason"]);
1467
+ const shouldSkip = uiImpact === "none" || skipRequested;
1468
+ if (shouldSkip) {
1469
+ if (skipRequested && uiImpact !== "possible") {
1470
+ return failSession(paths, {
1471
+ code: "ui_check_skip_not_allowed",
1472
+ message: `Deep UI check can only be manually skipped when uiImpact is possible. Current uiImpact is ${uiImpact}.`,
1473
+ repairCommand: `jskit session ${paths.sessionId} step`,
1474
+ preconditions
1475
+ });
1476
+ }
1477
+ if (skipRequested && !skipReason) {
1478
+ return failSession(paths, {
1479
+ code: "ui_check_skip_reason_required",
1480
+ message: "Skipping a possible Deep UI check requires --skip-reason.",
1481
+ repairCommand: `jskit session ${paths.sessionId} step --skip-ui-check --skip-reason "<reason>"`,
1482
+ preconditions
1483
+ });
1484
+ }
1485
+ const reason = uiImpact === "none" ? "uiImpact is none." : skipReason;
1486
+ await writeUiCheckJson(paths, stepId, {
1487
+ ok: true,
1488
+ phase,
1489
+ reason,
1490
+ status: "skipped",
1491
+ stepId,
1492
+ uiImpact
1493
+ });
1494
+ await writeReceipt(paths, stepId, `${label} skipped: ${reason}`);
1495
+ await markStatus(paths, SESSION_STATUS.RUNNING);
1496
+ return buildSessionResponse(paths, {
1497
+ preconditions
826
1498
  });
827
1499
  }
828
- const message = result.changedFiles?.length
829
- ? "Committed review changes."
830
- : "No review changes detected.";
831
- await writeReceipt(paths, "review_changes_committed", message);
832
- await markStatus(paths, SESSION_STATUS.RUNNING);
833
- return buildSessionResponse(paths);
1500
+
1501
+ const promptFileName = `${stepId}.md`;
1502
+ const promptPath = path.join(paths.sessionRoot, "prompts", promptFileName);
1503
+ if (await fileExists(promptPath)) {
1504
+ await writeReceipt(paths, stepId, `${label} completed by Codex.`);
1505
+ await markStatus(paths, SESSION_STATUS.RUNNING);
1506
+ return buildSessionResponse(paths, {
1507
+ preconditions
1508
+ });
1509
+ }
1510
+
1511
+ const issueUrl = await readTrimmedFile(path.join(paths.sessionRoot, "issue_url"));
1512
+ const issueText = await readTrimmedFile(path.join(paths.sessionRoot, "issue.md"));
1513
+ const issueTitle = await readTrimmedFile(path.join(paths.sessionRoot, "issue_title")) || titleFromIssue(issueText);
1514
+ const { planPath } = await readCurrentPlan(paths);
1515
+ const prompt = await renderPrompt(paths, "deep_ui_check.md", {
1516
+ changed_files: await changedFilesSinceBase(paths),
1517
+ issue_file: path.join(paths.sessionRoot, "issue.md"),
1518
+ issue_number: issueNumberFromUrl(issueUrl),
1519
+ issue_title: issueTitle,
1520
+ issue_url: issueUrl,
1521
+ phase,
1522
+ issue_details_file: path.join(paths.sessionRoot, "issue_details.md"),
1523
+ plan_file: planPath,
1524
+ ui_impact: uiImpact,
1525
+ worktree: paths.worktree
1526
+ });
1527
+ await writePromptArtifact(paths, promptFileName, prompt);
1528
+ await writeUiCheckJson(paths, stepId, {
1529
+ ok: true,
1530
+ phase,
1531
+ promptPath,
1532
+ status: "prompted",
1533
+ stepId,
1534
+ uiImpact
1535
+ });
1536
+ await markStatus(paths, SESSION_STATUS.WAITING_FOR_USER);
1537
+ return buildSessionResponse(paths, {
1538
+ codex: DEEP_UI_CHECK_CODEX_HANDOFF,
1539
+ preconditions,
1540
+ prompt,
1541
+ status: SESSION_STATUS.WAITING_FOR_USER
1542
+ });
834
1543
  }
835
1544
 
836
1545
  async function userCheck(paths, options = {}) {
@@ -841,13 +1550,48 @@ async function userCheck(paths, options = {}) {
841
1550
  return buildSessionResponse(paths);
842
1551
  }
843
1552
  if (result === "failed" || result === "fail" || result === "no") {
844
- return failSession(paths, {
845
- code: "user_check_failed",
846
- message: "User check was reported as failed. Continue in Codex, then retry this step with --user-check passed.",
847
- repairCommand: `jskit session ${paths.sessionId} step --user-check passed`
1553
+ const activeCycle = await readActiveCycle(paths);
1554
+ await writeCycleReceipt(paths, "user_check_failed", "User reported that manual verification failed.", {
1555
+ cycle: activeCycle
1556
+ });
1557
+ const reworkNotes = normalizeText(options.reworkNotes || options["rework-notes"]);
1558
+ if (!reworkNotes) {
1559
+ await markStatus(paths, SESSION_STATUS.BLOCKED);
1560
+ return buildSessionResponse(paths, {
1561
+ ok: false,
1562
+ errors: [
1563
+ createError({
1564
+ code: "user_check_failed",
1565
+ message: "User check failed. Provide rework notes to start a new plan cycle.",
1566
+ repairCommand: `jskit session ${paths.sessionId} step --user-check failed --rework-notes -`
1567
+ })
1568
+ ],
1569
+ status: SESSION_STATUS.BLOCKED
1570
+ });
1571
+ }
1572
+ const nextCycle = nextCycleNumber(activeCycle);
1573
+ await writeTextFile(path.join(paths.sessionRoot, "cycles", `cycle_${nextCycle}`, "rework_request.md"), `${reworkNotes}\n`);
1574
+ await writeActiveCycle(paths, nextCycle);
1575
+ await writeCycleReceipt(paths, "cycle_started", `Started rework cycle ${nextCycle}.`, {
1576
+ cycle: nextCycle
848
1577
  });
1578
+ await markCurrentStep(paths, "plan_made");
1579
+ await markStatus(paths, SESSION_STATUS.RUNNING);
1580
+ return buildSessionResponse(paths);
849
1581
  }
850
- const prompt = await renderPrompt(paths, "user_check.md");
1582
+ const issueText = await readTrimmedFile(path.join(paths.sessionRoot, "issue.md"));
1583
+ const issueUrl = await readTrimmedFile(path.join(paths.sessionRoot, "issue_url"));
1584
+ const issueTitle = await readTrimmedFile(path.join(paths.sessionRoot, "issue_title")) || titleFromIssue(issueText);
1585
+ const { planPath, planText } = await readCurrentPlan(paths);
1586
+ const prompt = await renderPrompt(paths, "user_check.md", {
1587
+ issue_file: path.join(paths.sessionRoot, "issue.md"),
1588
+ issue_title: issueTitle,
1589
+ issue_url: issueUrl,
1590
+ issue_details_file: path.join(paths.sessionRoot, "issue_details.md"),
1591
+ issue_details_text: await readTrimmedFile(path.join(paths.sessionRoot, "issue_details.md")),
1592
+ plan_file: planPath,
1593
+ plan_text: planText
1594
+ });
851
1595
  await writePromptArtifact(paths, "user_check.md", prompt);
852
1596
  await markStatus(paths, SESSION_STATUS.WAITING_FOR_USER);
853
1597
  return buildSessionResponse(paths, {
@@ -856,6 +1600,141 @@ async function userCheck(paths, options = {}) {
856
1600
  });
857
1601
  }
858
1602
 
1603
+ async function readAcceptedChangesCommit(paths) {
1604
+ const source = await readTextIfExists(path.join(paths.sessionRoot, "changes_committed.json"));
1605
+ return parseJsonObject(source) || null;
1606
+ }
1607
+
1608
+ async function commitAcceptedChanges(paths, _options = {}, context = {}) {
1609
+ const preconditions = context.preconditions || [];
1610
+ let commitInfo = await readAcceptedChangesCommit(paths);
1611
+
1612
+ if (!commitInfo?.commit) {
1613
+ const result = await commitWorktree(paths, {
1614
+ message: `Implement JSKIT session ${paths.sessionId}`
1615
+ });
1616
+ if (!result.ok) {
1617
+ if (result.output === "No changes found.") {
1618
+ return failSession(paths, {
1619
+ code: "accepted_changes_missing",
1620
+ message: "No accepted worktree changes found to commit.",
1621
+ repairCommand: `git -C ${paths.worktree} status --short`,
1622
+ preconditions
1623
+ });
1624
+ }
1625
+ return failSession(paths, {
1626
+ code: "accepted_changes_commit_failed",
1627
+ message: result.output || "Failed to commit accepted changes.",
1628
+ repairCommand: `git -C ${paths.worktree} status --short`,
1629
+ preconditions
1630
+ });
1631
+ }
1632
+ commitInfo = {
1633
+ changedFiles: result.changedFiles || [],
1634
+ commit: await currentHead(paths),
1635
+ committedAt: timestampForReceipt()
1636
+ };
1637
+ await writeTextFile(path.join(paths.sessionRoot, "changes_committed.json"), `${JSON.stringify(commitInfo, null, 2)}\n`);
1638
+ }
1639
+
1640
+ await writeReceipt(paths, "changes_committed", `Committed accepted changes at ${commitInfo.commit || "unknown"}.`);
1641
+ await markStatus(paths, SESSION_STATUS.RUNNING);
1642
+ return buildSessionResponse(paths, {
1643
+ preconditions
1644
+ });
1645
+ }
1646
+
1647
+ async function updateBlueprint(paths, _options = {}, context = {}) {
1648
+ const preconditions = context.preconditions || [];
1649
+ const issueText = await readTrimmedFile(path.join(paths.sessionRoot, "issue.md"));
1650
+ const issueTitle = await readTrimmedFile(path.join(paths.sessionRoot, "issue_title")) || titleFromIssue(issueText);
1651
+ const issueUrl = await readTrimmedFile(path.join(paths.sessionRoot, "issue_url"));
1652
+ const issueNumber = issueNumberFromUrl(issueUrl);
1653
+ const { planPath } = await readCurrentPlan(paths);
1654
+ const issueDetailsPath = path.join(paths.sessionRoot, "issue_details.md");
1655
+ const agentDecisionsPath = path.join(paths.sessionRoot, "agent_decisions.md");
1656
+ const blueprintPath = path.join(paths.worktree, ".jskit", "APP_BLUEPRINT.md");
1657
+ const blueprintPromptPath = path.join(paths.sessionRoot, "prompts", "update_blueprint.md");
1658
+
1659
+ if (await fileExists(blueprintPromptPath)) {
1660
+ const changedFiles = await changedFilesInWorktree(paths);
1661
+ const unexpectedChanges = changedFiles.filter((file) => file !== ".jskit/APP_BLUEPRINT.md");
1662
+ if (unexpectedChanges.length > 0) {
1663
+ return failSession(paths, {
1664
+ code: "blueprint_unexpected_changes",
1665
+ message: `The blueprint step changed files outside .jskit/APP_BLUEPRINT.md: ${unexpectedChanges.join(", ")}`,
1666
+ repairCommand: `git -C ${paths.worktree} status --short`,
1667
+ preconditions
1668
+ });
1669
+ }
1670
+
1671
+ const blueprintText = await readTrimmedFile(blueprintPath);
1672
+ if (!blueprintText) {
1673
+ return failSession(paths, {
1674
+ code: "app_blueprint_missing_after_update",
1675
+ message: "Codex completed the blueprint step without leaving a non-empty .jskit/APP_BLUEPRINT.md file.",
1676
+ repairCommand: `jskit session ${paths.sessionId} step`,
1677
+ preconditions
1678
+ });
1679
+ }
1680
+
1681
+ if (changedFiles.includes(".jskit/APP_BLUEPRINT.md")) {
1682
+ const addResult = await runGitInWorktree(paths.worktree, ["add", ".jskit/APP_BLUEPRINT.md"], {
1683
+ timeout: 15000
1684
+ });
1685
+ if (!addResult.ok) {
1686
+ return failSession(paths, {
1687
+ code: "blueprint_stage_failed",
1688
+ message: addResult.output || "Failed to stage app blueprint update.",
1689
+ repairCommand: `git -C ${paths.worktree} add .jskit/APP_BLUEPRINT.md`,
1690
+ preconditions
1691
+ });
1692
+ }
1693
+ const commitResult = await runGitInWorktree(paths.worktree, ["commit", "-m", `Update app blueprint for ${paths.sessionId}`], {
1694
+ timeout: 1000 * 60
1695
+ });
1696
+ if (!commitResult.ok) {
1697
+ return failSession(paths, {
1698
+ code: "blueprint_commit_failed",
1699
+ message: commitResult.output || "Failed to commit app blueprint update.",
1700
+ repairCommand: `git -C ${paths.worktree} status --short`,
1701
+ preconditions
1702
+ });
1703
+ }
1704
+ await writeReceipt(paths, "blueprint_updated", "Codex updated and JSKIT committed the app blueprint.");
1705
+ } else {
1706
+ await writeReceipt(paths, "blueprint_updated", "Codex reviewed the app blueprint; no blueprint changes were needed.");
1707
+ }
1708
+ await markStatus(paths, SESSION_STATUS.RUNNING);
1709
+ return buildSessionResponse(paths, {
1710
+ preconditions,
1711
+ status: SESSION_STATUS.RUNNING
1712
+ });
1713
+ }
1714
+
1715
+ const prompt = await renderPrompt(paths, "update_blueprint.md", {
1716
+ agent_decisions_file: agentDecisionsPath,
1717
+ app_blueprint_file: blueprintPath,
1718
+ changed_files: await changedFilesSinceBase(paths),
1719
+ issue_file: path.join(paths.sessionRoot, "issue.md"),
1720
+ issue_number: issueNumber,
1721
+ issue_title: issueTitle,
1722
+ issue_url: issueUrl,
1723
+ issue_details_file: issueDetailsPath,
1724
+ plan_file: planPath,
1725
+ worktree: paths.worktree
1726
+ });
1727
+ await writePromptArtifact(paths, "update_blueprint.md", prompt);
1728
+ await markStatus(paths, SESSION_STATUS.WAITING_FOR_USER);
1729
+ return buildSessionResponse(paths, {
1730
+ codex: BLUEPRINT_CODEX_HANDOFF,
1731
+ ok: true,
1732
+ preconditions,
1733
+ prompt,
1734
+ status: SESSION_STATUS.WAITING_FOR_USER
1735
+ });
1736
+ }
1737
+
859
1738
  async function readPackageJson(root) {
860
1739
  try {
861
1740
  return JSON.parse(await readFile(path.join(root, "package.json"), "utf8"));
@@ -873,12 +1752,16 @@ async function doctorCommandForWorktree(worktree) {
873
1752
  if (scripts.verify) {
874
1753
  return ["npm", ["run", "verify"]];
875
1754
  }
876
- return ["npx", ["jskit", "app", "verify"]];
1755
+ return ["npx", ["--no-install", "jskit", "app", "verify"]];
877
1756
  }
878
1757
 
879
1758
  async function runDoctor(paths) {
1759
+ const repairCommitFailure = await maybeCommitDoctorRepair(paths);
1760
+ if (repairCommitFailure) {
1761
+ return repairCommitFailure;
1762
+ }
880
1763
  const [command, args] = await doctorCommandForWorktree(paths.worktree);
881
- const result = await runCommand(command, args, {
1764
+ const result = await runLoggedCommand(paths, "doctor_run", command, args, {
882
1765
  cwd: paths.worktree,
883
1766
  timeout: 1000 * 60 * 15
884
1767
  });
@@ -900,13 +1783,513 @@ async function runDoctor(paths) {
900
1783
  return buildSessionResponse(paths);
901
1784
  }
902
1785
 
1786
+ async function maybeCommitDoctorRepair(paths) {
1787
+ if (!await fileExists(path.join(paths.sessionRoot, "doctor.log")) || await fileExists(path.join(paths.sessionRoot, "steps", "doctor_run"))) {
1788
+ return null;
1789
+ }
1790
+ const status = await worktreeStatus(paths.worktree);
1791
+ if (!status.ok) {
1792
+ return failSession(paths, {
1793
+ code: "git_status_failed",
1794
+ message: status.output || "Failed to inspect verification repair changes.",
1795
+ repairCommand: `git -C ${paths.worktree} status --short`
1796
+ });
1797
+ }
1798
+ if (status.changedFiles.length < 1) {
1799
+ return null;
1800
+ }
1801
+ const result = await commitWorktree(paths, {
1802
+ message: `Verification repairs for ${paths.sessionId}`
1803
+ });
1804
+ if (!result.ok) {
1805
+ return failSession(paths, {
1806
+ code: "doctor_repair_commit_failed",
1807
+ message: result.output || "Failed to commit verification repair changes.",
1808
+ repairCommand: `git -C ${paths.worktree} status --short`
1809
+ });
1810
+ }
1811
+ await writeTextFile(path.join(paths.sessionRoot, "doctor_repair_commit.json"), `${JSON.stringify({
1812
+ changedFiles: result.changedFiles || [],
1813
+ commit: await currentHead(paths),
1814
+ committedAt: timestampForReceipt(),
1815
+ ok: true
1816
+ }, null, 2)}\n`);
1817
+ return null;
1818
+ }
1819
+
1820
+ async function commitLinesSinceBase(paths) {
1821
+ const baseCommit = await readTrimmedFile(path.join(paths.sessionRoot, "base_commit"));
1822
+ const args = baseCommit
1823
+ ? ["log", "--oneline", `${baseCommit}..HEAD`]
1824
+ : ["log", "--oneline", "--max-count=10"];
1825
+ const result = await runGitInWorktree(paths.worktree, args, {
1826
+ timeout: 15000
1827
+ });
1828
+ return result.ok ? result.stdout.trim() : "";
1829
+ }
1830
+
1831
+ async function readCheckSummaries(paths) {
1832
+ const checksRoot = path.join(paths.sessionRoot, "checks");
1833
+ try {
1834
+ const entries = await readdir(checksRoot, { withFileTypes: true });
1835
+ const summaries = [];
1836
+ for (const entry of entries.filter((item) => item.isFile() && item.name.endsWith(".json")).sort((left, right) => left.name.localeCompare(right.name))) {
1837
+ const source = await readTextIfExists(path.join(checksRoot, entry.name));
1838
+ const parsed = parseJsonObject(source);
1839
+ if (parsed) {
1840
+ summaries.push(`- ${parsed.stepId}: ${parsed.ok ? "passed" : "failed"} (${parsed.command})`);
1841
+ }
1842
+ }
1843
+ return summaries.join("\n");
1844
+ } catch {
1845
+ return "";
1846
+ }
1847
+ }
1848
+
1849
+ async function readUiCheckSummaries(paths) {
1850
+ const uiChecksRoot = path.join(paths.sessionRoot, "ui_checks");
1851
+ try {
1852
+ const entries = await readdir(uiChecksRoot, { withFileTypes: true });
1853
+ const summaries = [];
1854
+ for (const entry of entries.filter((item) => item.isFile() && item.name.endsWith(".json")).sort((left, right) => left.name.localeCompare(right.name))) {
1855
+ const source = await readTextIfExists(path.join(uiChecksRoot, entry.name));
1856
+ const parsed = parseJsonObject(source);
1857
+ if (parsed) {
1858
+ summaries.push(`- ${parsed.stepId}: ${parsed.status || (parsed.ok ? "passed" : "failed")}${parsed.reason ? ` (${parsed.reason})` : ""}`);
1859
+ }
1860
+ }
1861
+ return summaries.join("\n");
1862
+ } catch {
1863
+ return "";
1864
+ }
1865
+ }
1866
+
1867
+ async function readReviewPassSummaries(paths) {
1868
+ const passes = await readReviewPasses(paths);
1869
+ return passes
1870
+ .map((entry) => {
1871
+ const changedFiles = Array.isArray(entry.changedFiles) && entry.changedFiles.length
1872
+ ? `; changed files: ${entry.changedFiles.join(", ")}`
1873
+ : "";
1874
+ const commit = entry.commit ? `; commit: ${entry.commit}` : "";
1875
+ return `- ${entry.label}: ${entry.status}${commit}${changedFiles}`;
1876
+ })
1877
+ .join("\n");
1878
+ }
1879
+
1880
+ async function createFinalReport(paths, _options = {}, context = {}) {
1881
+ const preconditions = context.preconditions || [];
1882
+ const issueUrl = await readTrimmedFile(path.join(paths.sessionRoot, "issue_url"));
1883
+ const issueTitle = await readTrimmedFile(path.join(paths.sessionRoot, "issue_title"));
1884
+ const issueDetails = await readTrimmedFile(path.join(paths.sessionRoot, "issue_details.md"));
1885
+ const { planText } = await readCurrentPlan(paths);
1886
+ const agentDecisions = await readTextIfExists(path.join(paths.sessionRoot, "agent_decisions.md"));
1887
+ const filesChanged = await changedFilesSinceBase(paths);
1888
+ const commits = await commitLinesSinceBase(paths);
1889
+ const checks = await readCheckSummaries(paths);
1890
+ const uiChecks = await readUiCheckSummaries(paths);
1891
+ const reviewPasses = await readReviewPassSummaries(paths);
1892
+ const commandLogPath = path.join(paths.sessionRoot, "command_log.jsonl");
1893
+ const blueprintStatus = await readTextIfExists(path.join(paths.sessionRoot, "steps", "blueprint_updated"));
1894
+ const userCheck = await readTextIfExists(path.join(paths.sessionRoot, "steps", `cycle_${await readActiveCycle(paths)}`, "user_check_completed"));
1895
+ const report = [
1896
+ `# Final Report: ${issueTitle || paths.sessionId}`,
1897
+ "",
1898
+ `Issue: ${issueUrl || "(missing)"}`,
1899
+ `Session: ${paths.sessionId}`,
1900
+ "",
1901
+ "## Issue Details",
1902
+ "",
1903
+ issueDetails || "No issue details recorded.",
1904
+ "",
1905
+ "## Plan",
1906
+ "",
1907
+ planText || "No plan recorded.",
1908
+ "",
1909
+ "## Files Changed",
1910
+ "",
1911
+ filesChanged || "No changed files detected against the session base.",
1912
+ "",
1913
+ "## Commits",
1914
+ "",
1915
+ commits || "No commits detected against the session base.",
1916
+ "",
1917
+ "## Checks",
1918
+ "",
1919
+ checks || "No structured checks recorded.",
1920
+ "",
1921
+ "## UI Checks",
1922
+ "",
1923
+ uiChecks || "No structured UI checks recorded.",
1924
+ "",
1925
+ "## Review Passes",
1926
+ "",
1927
+ reviewPasses || "No structured review passes recorded.",
1928
+ "",
1929
+ "## Command Log",
1930
+ "",
1931
+ await fileExists(commandLogPath) ? commandLogPath : "No command log recorded.",
1932
+ "",
1933
+ "## User Check",
1934
+ "",
1935
+ userCheck.trim() || "No user check receipt recorded.",
1936
+ "",
1937
+ "## Blueprint",
1938
+ "",
1939
+ blueprintStatus.trim() || "No blueprint receipt recorded.",
1940
+ "",
1941
+ "## Remaining Unverified Gaps",
1942
+ "",
1943
+ "Review the check and UI check sections above; no additional gaps were recorded by JSKIT.",
1944
+ "",
1945
+ "## Decisions",
1946
+ "",
1947
+ agentDecisions.trim() || "No decision log recorded.",
1948
+ ""
1949
+ ].join("\n");
1950
+ const reportPath = path.join(paths.sessionRoot, "final_report.md");
1951
+ await writeTextFile(reportPath, report);
1952
+ if (issueUrl) {
1953
+ const commentResult = await commentOnIssueOnce(paths, {
1954
+ bodyFile: reportPath,
1955
+ issueUrl,
1956
+ purpose: "final_report"
1957
+ });
1958
+ if (!commentResult.ok) {
1959
+ return failSession(paths, {
1960
+ code: "final_report_comment_failed",
1961
+ message: commentResult.output || "Failed to comment final report on the GitHub issue.",
1962
+ repairCommand: `gh issue comment ${issueUrl} --body-file ${reportPath}`,
1963
+ preconditions
1964
+ });
1965
+ }
1966
+ }
1967
+ await writeReceipt(paths, "final_report_created", "Created final report and recorded the GitHub issue comment.");
1968
+ await markStatus(paths, SESSION_STATUS.RUNNING);
1969
+ return buildSessionResponse(paths, {
1970
+ preconditions
1971
+ });
1972
+ }
1973
+
903
1974
  function issueNumberFromUrl(issueUrl) {
904
1975
  const match = /\/issues\/(\d+)(?:\b|$)/u.exec(String(issueUrl || ""));
905
1976
  return match ? match[1] : "";
906
1977
  }
907
1978
 
1979
+ function parseJsonObject(value) {
1980
+ try {
1981
+ const parsed = JSON.parse(String(value || ""));
1982
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : null;
1983
+ } catch {
1984
+ return null;
1985
+ }
1986
+ }
1987
+
1988
+ async function readPrState(paths, prUrl) {
1989
+ const prRef = normalizeText(prUrl);
1990
+ const args = prRef
1991
+ ? ["pr", "view", prRef, "--json", "state,mergedAt,url,baseRefName"]
1992
+ : ["pr", "view", "--json", "state,mergedAt,url,baseRefName"];
1993
+ const result = await runLoggedCommand(paths, prRef ? "github_pr_view" : "github_pr_view_current_branch", "gh", args, {
1994
+ cwd: paths.targetRoot,
1995
+ timeout: 1000 * 60
1996
+ });
1997
+ if (!result.ok) {
1998
+ return {
1999
+ ok: false,
2000
+ output: result.output
2001
+ };
2002
+ }
2003
+ const payload = parseJsonObject(result.stdout);
2004
+ return {
2005
+ baseRefName: normalizeText(payload?.baseRefName),
2006
+ mergedAt: payload?.mergedAt || "",
2007
+ ok: Boolean(payload),
2008
+ output: result.output,
2009
+ state: String(payload?.state || "").toUpperCase(),
2010
+ url: payload?.url || prRef
2011
+ };
2012
+ }
2013
+
2014
+ async function readCurrentBranchPrState(paths) {
2015
+ const result = await runLoggedCommand(paths, "github_pr_view_current_branch", "gh", ["pr", "view", "--json", "state,mergedAt,url,baseRefName"], {
2016
+ cwd: paths.worktree,
2017
+ timeout: 1000 * 60
2018
+ });
2019
+ if (!result.ok) {
2020
+ return {
2021
+ ok: false,
2022
+ output: result.output
2023
+ };
2024
+ }
2025
+ const payload = parseJsonObject(result.stdout);
2026
+ return {
2027
+ baseRefName: normalizeText(payload?.baseRefName),
2028
+ mergedAt: payload?.mergedAt || "",
2029
+ ok: Boolean(payload?.url),
2030
+ output: result.output,
2031
+ state: String(payload?.state || "").toUpperCase(),
2032
+ url: payload?.url || ""
2033
+ };
2034
+ }
2035
+
2036
+ function prStateIsMerged(prState) {
2037
+ return Boolean(prState?.ok && prState.state === "MERGED");
2038
+ }
2039
+
2040
+ function prStateIsClosed(prState) {
2041
+ return Boolean(prState?.ok && prState.state === "CLOSED");
2042
+ }
2043
+
2044
+ async function currentTargetBranch(targetRoot) {
2045
+ const result = await runGit(targetRoot, ["branch", "--show-current"], {
2046
+ timeout: 15000
2047
+ });
2048
+ return result.ok ? normalizeText(result.stdout) : "";
2049
+ }
2050
+
2051
+ async function assertTargetRootCleanForBaseUpdate(paths) {
2052
+ const status = await runGit(paths.targetRoot, ["status", "--porcelain=v1"], {
2053
+ timeout: 15000
2054
+ });
2055
+ if (!status.ok) {
2056
+ return failSession(paths, {
2057
+ code: "target_root_status_failed",
2058
+ message: status.output || "Failed to inspect target root git status before updating the local base branch.",
2059
+ repairCommand: `git -C ${paths.targetRoot} status --short`
2060
+ });
2061
+ }
2062
+ if (status.stdout.trim()) {
2063
+ return failSession(paths, {
2064
+ code: "target_root_dirty",
2065
+ message: "Target root has uncommitted changes; JSKIT cannot update the local base branch after merging the PR.",
2066
+ repairCommand: `git -C ${paths.targetRoot} status --short`
2067
+ });
2068
+ }
2069
+ return null;
2070
+ }
2071
+
2072
+ async function removeSessionWorktree(paths) {
2073
+ if (await hasWorktree(paths)) {
2074
+ const result = await runLoggedCommand(paths, "git_worktree_remove", "git", ["worktree", "remove", paths.worktree], {
2075
+ cwd: paths.targetRoot,
2076
+ timeout: 1000 * 60
2077
+ });
2078
+ if (!result.ok) {
2079
+ return failSession(paths, {
2080
+ code: "worktree_remove_failed",
2081
+ message: result.output || "Failed to remove worktree.",
2082
+ repairCommand: `git worktree remove ${paths.worktree}`
2083
+ });
2084
+ }
2085
+ }
2086
+ return null;
2087
+ }
2088
+
2089
+ async function writePrOutcome(paths, outcome) {
2090
+ await writeTextFile(path.join(paths.sessionRoot, "pr_outcome.json"), `${JSON.stringify({
2091
+ recordedAt: timestampForReceipt(),
2092
+ ...outcome
2093
+ }, null, 2)}\n`);
2094
+ }
2095
+
2096
+ async function assertTargetRootCanUpdateBase(paths, branch) {
2097
+ const cleanFailure = await assertTargetRootCleanForBaseUpdate(paths);
2098
+ if (cleanFailure) {
2099
+ return cleanFailure;
2100
+ }
2101
+
2102
+ const currentBranch = await currentTargetBranch(paths.targetRoot);
2103
+ if (currentBranch !== branch) {
2104
+ return failSession(paths, {
2105
+ code: "target_branch_mismatch",
2106
+ message: `Target root is on branch ${currentBranch || "(detached)"}, but the merged PR targets ${branch}. JSKIT will not merge origin/${branch} into the wrong branch.`,
2107
+ repairCommand: `git -C ${paths.targetRoot} switch ${branch} && git -C ${paths.targetRoot} pull --ff-only origin ${branch}`
2108
+ });
2109
+ }
2110
+
2111
+ return null;
2112
+ }
2113
+
2114
+ async function assertTargetBaseCanFastForward(paths, branch) {
2115
+ const branchFailure = await assertTargetRootCanUpdateBase(paths, branch);
2116
+ if (branchFailure) {
2117
+ return branchFailure;
2118
+ }
2119
+
2120
+ const fetchResult = await runLoggedCommand(paths, "git_fetch_origin", "git", ["fetch", "origin"], {
2121
+ cwd: paths.targetRoot,
2122
+ timeout: 1000 * 60 * 5
2123
+ });
2124
+ if (!fetchResult.ok) {
2125
+ return failSession(paths, {
2126
+ code: "target_fetch_failed",
2127
+ message: fetchResult.output || `Failed to fetch origin before checking local ${branch}.`,
2128
+ repairCommand: `git -C ${paths.targetRoot} fetch origin`
2129
+ });
2130
+ }
2131
+
2132
+ const remoteBranch = `origin/${branch}`;
2133
+ const remoteExists = await runGit(paths.targetRoot, ["rev-parse", "--verify", remoteBranch], {
2134
+ timeout: 15000
2135
+ });
2136
+ if (!remoteExists.ok) {
2137
+ return failSession(paths, {
2138
+ code: "target_remote_branch_missing",
2139
+ message: `Remote base branch ${remoteBranch} does not exist; JSKIT cannot safely update local ${branch} after merge.`,
2140
+ repairCommand: `git -C ${paths.targetRoot} fetch origin`
2141
+ });
2142
+ }
2143
+
2144
+ const ancestor = await runGit(paths.targetRoot, ["merge-base", "--is-ancestor", branch, remoteBranch], {
2145
+ timeout: 15000
2146
+ });
2147
+ if (!ancestor.ok) {
2148
+ return failSession(paths, {
2149
+ code: "target_branch_not_fast_forwardable",
2150
+ message: `Local ${branch} is not an ancestor of ${remoteBranch}; JSKIT will not merge the PR while the local base branch has diverged.`,
2151
+ repairCommand: `git -C ${paths.targetRoot} pull --ff-only origin ${branch}`
2152
+ });
2153
+ }
2154
+
2155
+ return null;
2156
+ }
2157
+
2158
+ async function updateLocalBaseBranch(paths, baseBranch = "") {
2159
+ const branch = normalizeText(baseBranch) || await currentTargetBranch(paths.targetRoot);
2160
+ if (!branch) {
2161
+ return failSession(paths, {
2162
+ code: "target_branch_missing",
2163
+ message: "Target root is not on a named branch; JSKIT cannot update the local base branch after merging the PR.",
2164
+ repairCommand: `git -C ${paths.targetRoot} branch --show-current`
2165
+ });
2166
+ }
2167
+
2168
+ const branchFailure = await assertTargetRootCanUpdateBase(paths, branch);
2169
+ if (branchFailure) {
2170
+ return branchFailure;
2171
+ }
2172
+
2173
+ const fetchResult = await runGit(paths.targetRoot, ["fetch", "origin"], {
2174
+ timeout: 1000 * 60 * 5
2175
+ });
2176
+ if (!fetchResult.ok) {
2177
+ return failSession(paths, {
2178
+ code: "target_fetch_failed",
2179
+ message: fetchResult.output || `Failed to fetch origin before updating local ${branch}.`,
2180
+ repairCommand: `git -C ${paths.targetRoot} fetch origin`
2181
+ });
2182
+ }
2183
+
2184
+ const pullResult = await runLoggedCommand(paths, "git_pull_base", "git", ["pull", "--ff-only", "origin", branch], {
2185
+ cwd: paths.targetRoot,
2186
+ timeout: 1000 * 60 * 5
2187
+ });
2188
+ if (!pullResult.ok) {
2189
+ return failSession(paths, {
2190
+ code: "target_pull_failed",
2191
+ message: pullResult.output || `Failed to fast-forward local ${branch} after merging the PR.`,
2192
+ repairCommand: `git -C ${paths.targetRoot} pull --ff-only origin ${branch}`
2193
+ });
2194
+ }
2195
+
2196
+ await writeTextFile(path.join(paths.sessionRoot, "local_base_updated"), `${branch}\n${pullResult.output}\n`);
2197
+ return null;
2198
+ }
2199
+
2200
+ async function updateHelperMapBeforePr(paths) {
2201
+ let helperMapPayload;
2202
+ try {
2203
+ const { updateHelperMap } = await import("./helperMap.js");
2204
+ helperMapPayload = await updateHelperMap({
2205
+ targetRoot: paths.worktree
2206
+ });
2207
+ } catch (error) {
2208
+ return {
2209
+ ok: false,
2210
+ code: "helper_map_update_failed",
2211
+ message: String(error?.message || error),
2212
+ repairCommand: `git -C ${paths.worktree} status --short`
2213
+ };
2214
+ }
2215
+
2216
+ const statusResult = await runGitInWorktree(paths.worktree, [
2217
+ "status",
2218
+ "--porcelain=v1",
2219
+ "--",
2220
+ HELPER_MAP_JSON_RELATIVE_PATH,
2221
+ HELPER_MAP_MARKDOWN_RELATIVE_PATH
2222
+ ], {
2223
+ timeout: 15000
2224
+ });
2225
+ if (!statusResult.ok) {
2226
+ return {
2227
+ ok: false,
2228
+ code: "helper_map_status_failed",
2229
+ message: statusResult.output || "Failed to inspect helper-map Git status.",
2230
+ repairCommand: `git -C ${paths.worktree} status --short -- ${HELPER_MAP_JSON_RELATIVE_PATH} ${HELPER_MAP_MARKDOWN_RELATIVE_PATH}`
2231
+ };
2232
+ }
2233
+
2234
+ if (!statusResult.stdout.trim()) {
2235
+ return {
2236
+ ok: true,
2237
+ changed: false,
2238
+ message: "Helper map already up to date."
2239
+ };
2240
+ }
2241
+
2242
+ const addResult = await runGitInWorktree(paths.worktree, [
2243
+ "add",
2244
+ HELPER_MAP_JSON_RELATIVE_PATH,
2245
+ HELPER_MAP_MARKDOWN_RELATIVE_PATH
2246
+ ], {
2247
+ timeout: 15000
2248
+ });
2249
+ if (!addResult.ok) {
2250
+ return {
2251
+ ok: false,
2252
+ code: "helper_map_add_failed",
2253
+ message: addResult.output || "Failed to stage helper-map files.",
2254
+ repairCommand: `git -C ${paths.worktree} add ${HELPER_MAP_JSON_RELATIVE_PATH} ${HELPER_MAP_MARKDOWN_RELATIVE_PATH}`
2255
+ };
2256
+ }
2257
+
2258
+ const commitResult = await runGitInWorktree(paths.worktree, [
2259
+ "commit",
2260
+ "-m",
2261
+ `Update JSKIT helper map for ${paths.sessionId}`
2262
+ ], {
2263
+ timeout: 1000 * 60
2264
+ });
2265
+ if (!commitResult.ok) {
2266
+ return {
2267
+ ok: false,
2268
+ code: "helper_map_commit_failed",
2269
+ message: commitResult.output || "Failed to commit helper-map update.",
2270
+ repairCommand: `git -C ${paths.worktree} commit -m "Update JSKIT helper map for ${paths.sessionId}"`
2271
+ };
2272
+ }
2273
+
2274
+ return {
2275
+ ok: true,
2276
+ changed: true,
2277
+ message: `Updated helper map at ${path.relative(paths.worktree, helperMapPayload.helperMapMarkdownPath)}.`
2278
+ };
2279
+ }
2280
+
908
2281
  async function createPr(paths) {
909
- const pushResult = await runGitInWorktree(paths.worktree, ["push", "-u", "origin", "HEAD"], {
2282
+ const helperMapResult = await updateHelperMapBeforePr(paths);
2283
+ if (!helperMapResult.ok) {
2284
+ return failSession(paths, {
2285
+ code: helperMapResult.code,
2286
+ message: helperMapResult.message,
2287
+ repairCommand: helperMapResult.repairCommand
2288
+ });
2289
+ }
2290
+
2291
+ const pushResult = await runLoggedCommand(paths, "git_push_branch", "git", ["push", "-u", "origin", "HEAD"], {
2292
+ cwd: paths.worktree,
910
2293
  timeout: 1000 * 60 * 5
911
2294
  });
912
2295
  if (!pushResult.ok) {
@@ -916,6 +2299,13 @@ async function createPr(paths) {
916
2299
  repairCommand: `git -C ${paths.worktree} push -u origin HEAD`
917
2300
  });
918
2301
  }
2302
+ const existingPrState = await readCurrentBranchPrState(paths);
2303
+ if (existingPrState.ok && existingPrState.url && !prStateIsClosed(existingPrState)) {
2304
+ await writeTextFile(path.join(paths.sessionRoot, "pr_url"), existingPrState.url);
2305
+ await writeReceipt(paths, "pr_created", `Pushed branch ${paths.branch} and reused existing PR ${existingPrState.url}. ${helperMapResult.message}`);
2306
+ await markStatus(paths, SESSION_STATUS.RUNNING);
2307
+ return buildSessionResponse(paths);
2308
+ }
919
2309
  const issueUrl = await readTrimmedFile(path.join(paths.sessionRoot, "issue_url"));
920
2310
  const issueText = await readTrimmedFile(path.join(paths.sessionRoot, "issue.md"));
921
2311
  const issueTitle = await readTrimmedFile(path.join(paths.sessionRoot, "issue_title")) || titleFromIssue(issueText);
@@ -927,7 +2317,7 @@ async function createPr(paths) {
927
2317
  ].join("\n").trim();
928
2318
  const bodyPath = path.join(paths.sessionRoot, "pr_body.md");
929
2319
  await writeTextFile(bodyPath, body);
930
- const result = await runCommand("gh", [
2320
+ const result = await runLoggedCommand(paths, "github_pr_create", "gh", [
931
2321
  "pr",
932
2322
  "create",
933
2323
  "--title",
@@ -939,6 +2329,13 @@ async function createPr(paths) {
939
2329
  timeout: 1000 * 60
940
2330
  });
941
2331
  if (!result.ok || !result.stdout) {
2332
+ const fallbackPrState = await readCurrentBranchPrState(paths);
2333
+ if (fallbackPrState.ok && fallbackPrState.url && !prStateIsClosed(fallbackPrState)) {
2334
+ await writeTextFile(path.join(paths.sessionRoot, "pr_url"), fallbackPrState.url);
2335
+ await writeReceipt(paths, "pr_created", `Pushed branch ${paths.branch} and reused existing PR ${fallbackPrState.url}. ${helperMapResult.message}`);
2336
+ await markStatus(paths, SESSION_STATUS.RUNNING);
2337
+ return buildSessionResponse(paths);
2338
+ }
942
2339
  const prompt = await renderPrompt(paths, "pr_failure.md", {
943
2340
  doctor_output: result.output
944
2341
  });
@@ -952,54 +2349,150 @@ async function createPr(paths) {
952
2349
  }
953
2350
  const prUrl = result.stdout.split(/\r?\n/u).map((line) => line.trim()).find(Boolean) || result.stdout;
954
2351
  await writeTextFile(path.join(paths.sessionRoot, "pr_url"), prUrl);
955
- await writeReceipt(paths, "pr_created", `Pushed branch ${paths.branch} and created PR ${prUrl}.`);
2352
+ await writeReceipt(paths, "pr_created", `Pushed branch ${paths.branch} and created PR ${prUrl}. ${helperMapResult.message}`);
2353
+ await markStatus(paths, SESSION_STATUS.RUNNING);
2354
+ return buildSessionResponse(paths);
2355
+ }
2356
+
2357
+ async function closePrWithoutMerge(paths, prUrl, options = {}) {
2358
+ const reason = normalizeText(options.closeReason || options["close-reason"]);
2359
+ if (!reason) {
2360
+ return failSession(paths, {
2361
+ code: "close_without_merge_reason_required",
2362
+ message: "Finishing without merging requires --close-reason.",
2363
+ repairCommand: `jskit session ${paths.sessionId} step --close-without-merge --close-reason "<reason>"`
2364
+ });
2365
+ }
2366
+ const prState = await readPrState(paths, prUrl);
2367
+ if (!prState.ok) {
2368
+ return failSession(paths, {
2369
+ code: "pr_state_failed",
2370
+ message: prState.output || "Failed to inspect PR before closing without merge.",
2371
+ repairCommand: `gh pr view ${prUrl} --json state,mergedAt,url,baseRefName`
2372
+ });
2373
+ }
2374
+ if (prStateIsMerged(prState)) {
2375
+ return failSession(paths, {
2376
+ code: "pr_already_merged",
2377
+ message: "Cannot finish without merging because the PR is already merged.",
2378
+ repairCommand: `jskit session ${paths.sessionId} step`
2379
+ });
2380
+ }
2381
+ if (!prStateIsClosed(prState)) {
2382
+ const commentResult = await runLoggedCommand(paths, "github_pr_comment", "gh", ["pr", "comment", prUrl, "--body", `JSKIT session ${paths.sessionId} finished without merging. Reason: ${reason}`], {
2383
+ cwd: paths.targetRoot,
2384
+ timeout: 1000 * 60
2385
+ });
2386
+ if (!commentResult.ok) {
2387
+ return failSession(paths, {
2388
+ code: "pr_comment_failed",
2389
+ message: commentResult.output || "Failed to comment on PR before finishing without merge.",
2390
+ repairCommand: `gh pr comment ${prUrl} --body "<reason>"`
2391
+ });
2392
+ }
2393
+ }
2394
+ const issueUrl = await readTrimmedFile(path.join(paths.sessionRoot, "issue_url"));
2395
+ if (issueUrl) {
2396
+ await runLoggedCommand(paths, "github_issue_comment", "gh", ["issue", "comment", issueUrl, "--body", `JSKIT session ${paths.sessionId} finished without merging PR ${prUrl}. Reason: ${reason}`], {
2397
+ cwd: paths.targetRoot,
2398
+ timeout: 1000 * 60
2399
+ });
2400
+ }
2401
+ const removeFailure = await removeSessionWorktree(paths);
2402
+ if (removeFailure) {
2403
+ return removeFailure;
2404
+ }
2405
+ await writePrOutcome(paths, {
2406
+ issueUrl,
2407
+ outcome: "closed_without_merge",
2408
+ prUrl,
2409
+ prState: prState.state,
2410
+ reason
2411
+ });
2412
+ await writeReceipt(paths, "pr_finalized", `Finished without merging PR ${prUrl}; PR left open and worktree removed ${paths.worktree}. Reason: ${reason}`);
956
2413
  await markStatus(paths, SESSION_STATUS.RUNNING);
957
2414
  return buildSessionResponse(paths);
958
2415
  }
959
2416
 
960
- async function mergePr(paths) {
2417
+ async function finalizePr(paths, options = {}) {
961
2418
  const prUrl = await readTrimmedFile(path.join(paths.sessionRoot, "pr_url"));
2419
+ const closeWithoutMerge = options.closeWithoutMerge === true ||
2420
+ normalizeText(options["close-without-merge"]).toLowerCase() === "true";
2421
+ if (closeWithoutMerge) {
2422
+ return closePrWithoutMerge(paths, prUrl, options);
2423
+ }
2424
+ const mergePr = options.mergePr === true ||
2425
+ normalizeText(options["merge-pr"]).toLowerCase() === "true";
2426
+ if (!mergePr) {
2427
+ return failSession(paths, {
2428
+ code: "pr_finalize_decision_required",
2429
+ message: "Choose whether to merge the PR or finish without merging.",
2430
+ repairCommand: `jskit session ${paths.sessionId} step --merge-pr true`
2431
+ });
2432
+ }
962
2433
  const mergeMarkerPath = path.join(paths.sessionRoot, "pr_merge_completed");
2434
+ const baseBranchPath = path.join(paths.sessionRoot, "pr_base_branch");
963
2435
  const mergeAlreadyCompleted = await readTrimmedFile(mergeMarkerPath);
2436
+ let baseBranch = await readTrimmedFile(baseBranchPath);
964
2437
  if (!mergeAlreadyCompleted) {
965
- const mergeResult = await runCommand("gh", ["pr", "merge", prUrl, "--merge", "--delete-branch"], {
966
- cwd: paths.worktree,
967
- timeout: 1000 * 60 * 5
968
- });
969
- if (!mergeResult.ok) {
2438
+ const existingPrState = await readPrState(paths, prUrl);
2439
+ baseBranch = existingPrState.baseRefName || baseBranch || await currentTargetBranch(paths.targetRoot);
2440
+ if (baseBranch) {
2441
+ await writeTextFile(baseBranchPath, `${baseBranch}\n`);
2442
+ }
2443
+ const baseFailure = await assertTargetBaseCanFastForward(paths, baseBranch);
2444
+ if (baseFailure) {
2445
+ return baseFailure;
2446
+ }
2447
+ let prMerged = prStateIsMerged(existingPrState);
2448
+ let mergeResult = null;
2449
+ if (!prMerged) {
2450
+ mergeResult = await runLoggedCommand(paths, "github_pr_merge", "gh", ["pr", "merge", prUrl, "--merge", "--delete-branch"], {
2451
+ cwd: paths.targetRoot,
2452
+ timeout: 1000 * 60 * 5
2453
+ });
2454
+ if (!mergeResult.ok) {
2455
+ prMerged = prStateIsMerged(await readPrState(paths, prUrl));
2456
+ } else {
2457
+ prMerged = true;
2458
+ }
2459
+ }
2460
+ if (!prMerged) {
970
2461
  const prompt = await renderPrompt(paths, "pr_failure.md", {
971
- doctor_output: mergeResult.output
2462
+ doctor_output: mergeResult?.output || existingPrState.output
972
2463
  });
973
2464
  await writePromptArtifact(paths, "pr_merge_failure.md", prompt);
974
2465
  return failSession(paths, {
975
2466
  code: "pr_merge_failed",
976
- message: mergeResult.output || "Failed to merge PR.",
2467
+ message: mergeResult?.output || existingPrState.output || "Failed to merge PR.",
977
2468
  repairCommand: `gh pr merge ${prUrl} --merge --delete-branch`,
978
2469
  prompt
979
2470
  });
980
2471
  }
981
2472
  const issueUrl = await readTrimmedFile(path.join(paths.sessionRoot, "issue_url"));
982
2473
  if (issueUrl) {
983
- await runCommand("gh", ["issue", "close", issueUrl, "--comment", `Merged PR ${prUrl}.`], {
984
- cwd: paths.worktree,
2474
+ await runLoggedCommand(paths, "github_issue_close", "gh", ["issue", "close", issueUrl, "--comment", `Merged PR ${prUrl}.`], {
2475
+ cwd: paths.targetRoot,
985
2476
  timeout: 1000 * 60
986
2477
  });
987
2478
  }
2479
+ await writePrOutcome(paths, {
2480
+ baseBranch,
2481
+ issueUrl,
2482
+ outcome: "merged",
2483
+ prUrl
2484
+ });
988
2485
  await writeTextFile(mergeMarkerPath, `${prUrl}\n`);
989
2486
  }
990
- if (await hasWorktree(paths)) {
991
- const result = await runGit(paths.targetRoot, ["worktree", "remove", paths.worktree], {
992
- timeout: 1000 * 60
993
- });
994
- if (!result.ok) {
995
- return failSession(paths, {
996
- code: "worktree_remove_failed",
997
- message: result.output || "Failed to remove worktree.",
998
- repairCommand: `git worktree remove ${paths.worktree}`
999
- });
1000
- }
2487
+ const updateFailure = await updateLocalBaseBranch(paths, baseBranch);
2488
+ if (updateFailure) {
2489
+ return updateFailure;
1001
2490
  }
1002
- await writeReceipt(paths, "pr_merged", `Merged PR ${prUrl} and removed worktree ${paths.worktree}.`);
2491
+ const removeFailure = await removeSessionWorktree(paths);
2492
+ if (removeFailure) {
2493
+ return removeFailure;
2494
+ }
2495
+ await writeReceipt(paths, "pr_finalized", `Merged PR ${prUrl}, updated local ${baseBranch || "base branch"}, and removed worktree ${paths.worktree}.`);
1003
2496
  await markStatus(paths, SESSION_STATUS.RUNNING);
1004
2497
  return buildSessionResponse(paths);
1005
2498
  }
@@ -1008,20 +2501,24 @@ async function finishSession(paths) {
1008
2501
  const prUrl = await readTrimmedFile(path.join(paths.sessionRoot, "pr_url"));
1009
2502
  const issueUrl = await readTrimmedFile(path.join(paths.sessionRoot, "issue_url"));
1010
2503
  const codexThreadId = await readTrimmedFile(path.join(paths.sessionRoot, "codex_thread_id"));
2504
+ const prOutcome = parseJsonObject(await readTextIfExists(path.join(paths.sessionRoot, "pr_outcome.json")));
1011
2505
  const prompt = await renderPrompt(paths, "final_comment.md", {
1012
2506
  codex_thread_id: codexThreadId,
1013
2507
  issue_url: issueUrl,
2508
+ pr_outcome: prOutcome?.outcome || "unknown",
2509
+ pr_outcome_reason: prOutcome?.reason || "",
1014
2510
  pr_url: prUrl,
2511
+ session_id: paths.sessionId,
1015
2512
  transcript_log: path.join(paths.completedSessionRoot, "transcript.log")
1016
2513
  });
1017
2514
  await writeTextFile(path.join(paths.sessionRoot, "final_comment.md"), prompt);
1018
2515
  if (issueUrl) {
1019
- await runCommand("gh", ["issue", "comment", issueUrl, "--body-file", path.join(paths.sessionRoot, "final_comment.md")], {
2516
+ await runLoggedCommand(paths, "github_issue_comment", "gh", ["issue", "comment", issueUrl, "--body-file", path.join(paths.sessionRoot, "final_comment.md")], {
1020
2517
  cwd: paths.targetRoot,
1021
2518
  timeout: 1000 * 60
1022
2519
  });
1023
2520
  }
1024
- await writeReceipt(paths, "session_finished", `Finished session ${paths.sessionId}.`);
2521
+ await writeReceipt(paths, "session_finished", `Finished session ${paths.sessionId} with PR outcome ${prOutcome?.outcome || "unknown"}.`);
1025
2522
  await markStatus(paths, SESSION_STATUS.FINISHED);
1026
2523
  await markCurrentStep(paths, "");
1027
2524
  const archivedPaths = await archiveSession(paths, "completed");
@@ -1036,27 +2533,50 @@ const STEP_RUNNERS = Object.freeze({
1036
2533
  issue_prompt_rendered: renderIssuePrompt,
1037
2534
  issue_drafted: draftIssue,
1038
2535
  issue_created: createIssue,
2536
+ issue_details_gathered: saveIssueDetails,
1039
2537
  plan_made: makePlan,
1040
- implementation_changes_accepted: acceptImplementationChanges,
1041
- implementation_changes_committed: commitImplementation,
2538
+ plan_executed: renderPlanExecutionPrompt,
2539
+ automated_checks_run: (paths) => runAutomatedChecks(paths, {
2540
+ label: "Automated checks",
2541
+ stepId: "automated_checks_run"
2542
+ }),
2543
+ deep_ui_check_run: (paths, options, context) => runDeepUiCheck(paths, {
2544
+ label: "Deep UI check",
2545
+ phase: "pre_review",
2546
+ stepId: "deep_ui_check_run"
2547
+ }, options, context),
1042
2548
  review_prompt_rendered: renderReviewPrompt,
1043
2549
  review_changes_accepted: acceptReviewChanges,
1044
- review_changes_committed: commitReviewChanges,
1045
2550
  user_check_completed: userCheck,
2551
+ changes_committed: commitAcceptedChanges,
2552
+ blueprint_updated: updateBlueprint,
1046
2553
  doctor_run: runDoctor,
2554
+ final_report_created: createFinalReport,
1047
2555
  pr_created: createPr,
1048
- pr_merged: mergePr,
2556
+ pr_finalized: finalizePr,
1049
2557
  session_finished: finishSession
1050
2558
  });
1051
2559
 
1052
2560
  const PRECONDITION_RUNNERS = Object.freeze({
2561
+ accepted_changes_committed: assertAcceptedChangesCommitted,
2562
+ active_cycle_exists: assertActiveCycleExists,
2563
+ active_cycle_user_check_passed: assertActiveCycleUserCheckPassed,
2564
+ blueprint_update_satisfied: assertBlueprintUpdateSatisfied,
2565
+ deep_ui_check_satisfied: assertDeepUiCheckSatisfied,
2566
+ dependencies_installed: assertDependenciesInstalled,
2567
+ final_report_exists: assertFinalReportExists,
1053
2568
  git_current_branch: (paths) => assertGitCurrentBranch(paths.targetRoot),
1054
2569
  git_repository: (paths) => assertGitRepository(paths.targetRoot),
1055
2570
  github_auth: (paths) => assertGhAuth(paths.targetRoot),
1056
2571
  github_origin: (paths) => assertGithubOrigin(paths.targetRoot),
2572
+ issue_metadata_exists: assertIssueMetadataExists,
1057
2573
  issue_text_exists: assertIssueTextExists,
1058
2574
  issue_url_exists: assertIssueUrlExists,
2575
+ automated_checks_passed: assertAutomatedChecksPassed,
2576
+ issue_details_exists: assertIssueDetailsExists,
2577
+ plan_text_exists: assertPlanTextExists,
1059
2578
  pr_url_exists: assertPrUrlExists,
2579
+ ready_jskit_app: assertReadyJskitApp,
1060
2580
  session_exists: assertSessionExists,
1061
2581
  worktree_exists: assertWorktreeExists
1062
2582
  });
@@ -1083,6 +2603,18 @@ async function runSessionStep({
1083
2603
  status: artifacts.status
1084
2604
  });
1085
2605
  }
2606
+ if (artifacts.workflowVersion !== SESSION_WORKFLOW_VERSION) {
2607
+ return buildSessionResponse(paths, {
2608
+ ok: false,
2609
+ errors: [
2610
+ createError({
2611
+ code: "unsupported_workflow_version",
2612
+ message: `Session ${paths.sessionId} uses workflow version ${artifacts.workflowVersion || "unknown"}, but this JSKIT runtime expects ${SESSION_WORKFLOW_VERSION}.`
2613
+ })
2614
+ ],
2615
+ status: SESSION_STATUS.BLOCKED
2616
+ });
2617
+ }
1086
2618
  const nextStep = artifacts.nextStep;
1087
2619
  if (!nextStep) {
1088
2620
  return finishSession(paths);
@@ -1109,6 +2641,7 @@ async function runSessionStep({
1109
2641
  preconditions: stepPreconditions.preconditions
1110
2642
  });
1111
2643
  }
2644
+ await appendAgentDecisionsInput(paths, options);
1112
2645
  return runner(paths, options, {
1113
2646
  preconditions: stepPreconditions.preconditions
1114
2647
  });
@@ -1128,7 +2661,7 @@ async function abandonSession({
1128
2661
  }
1129
2662
  const issueUrl = await readTrimmedFile(path.join(paths.sessionRoot, "issue_url"));
1130
2663
  if (issueUrl) {
1131
- const closeIssueResult = await runCommand("gh", ["issue", "close", issueUrl, "--comment", `Abandoned JSKIT Studio session ${paths.sessionId}.`], {
2664
+ const closeIssueResult = await runLoggedCommand(paths, "github_issue_close", "gh", ["issue", "close", issueUrl, "--comment", `Abandoned JSKIT Studio session ${paths.sessionId}.`], {
1132
2665
  cwd: paths.targetRoot,
1133
2666
  timeout: 1000 * 60
1134
2667
  });
@@ -1142,7 +2675,8 @@ async function abandonSession({
1142
2675
  }
1143
2676
  }
1144
2677
  if (await hasWorktree(paths)) {
1145
- await runGit(paths.targetRoot, ["worktree", "remove", "--force", paths.worktree], {
2678
+ await runLoggedCommand(paths, "git_worktree_remove", "git", ["worktree", "remove", "--force", paths.worktree], {
2679
+ cwd: paths.targetRoot,
1146
2680
  timeout: 1000 * 60
1147
2681
  });
1148
2682
  }
@@ -1205,6 +2739,7 @@ export {
1205
2739
  createSessionId,
1206
2740
  extractIssueTitle,
1207
2741
  extractIssueText,
2742
+ extractIssueDetails,
1208
2743
  extractPlanText,
1209
2744
  inspectSession,
1210
2745
  inspectSessionDiff,