@jskit-ai/jskit-cli 0.2.80 → 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 (32) 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 +179 -4
  5. package/src/server/commandHandlers/show.js +169 -34
  6. package/src/server/core/argParser.js +20 -0
  7. package/src/server/core/commandCatalog.js +70 -7
  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 +298 -135
  13. package/src/server/sessionRuntime/paths.js +2 -4
  14. package/src/server/sessionRuntime/preconditions.js +393 -26
  15. package/src/server/sessionRuntime/promptRenderer.js +15 -2
  16. package/src/server/sessionRuntime/prompts/app_blueprint.md +26 -2
  17. package/src/server/sessionRuntime/prompts/automated_checks.md +42 -0
  18. package/src/server/sessionRuntime/prompts/deep_ui_check.md +53 -0
  19. package/src/server/sessionRuntime/prompts/doctor_failure.md +21 -1
  20. package/src/server/sessionRuntime/prompts/execute_plan.md +61 -0
  21. package/src/server/sessionRuntime/prompts/final_comment.md +3 -1
  22. package/src/server/sessionRuntime/prompts/issue_details.md +52 -0
  23. package/src/server/sessionRuntime/prompts/new_issue.md +34 -3
  24. package/src/server/sessionRuntime/prompts/plan_issue.md +81 -0
  25. package/src/server/sessionRuntime/prompts/pr_failure.md +14 -1
  26. package/src/server/sessionRuntime/prompts/review_changes.md +77 -15
  27. package/src/server/sessionRuntime/prompts/update_blueprint.md +36 -0
  28. package/src/server/sessionRuntime/prompts/user_check.md +22 -4
  29. package/src/server/sessionRuntime/responses.js +877 -30
  30. package/src/server/sessionRuntime/worktrees.js +31 -0
  31. package/src/server/sessionRuntime.js +2070 -244
  32. package/src/server/sessionRuntime/prompts/implement_issue.md +0 -25
@@ -1,16 +1,27 @@
1
1
  import {
2
+ appendFile,
2
3
  mkdir,
3
4
  readFile,
4
- readdir
5
+ readdir,
6
+ rmdir
5
7
  } from "node:fs/promises";
6
8
  import path from "node:path";
7
9
  import {
10
+ BLUEPRINT_CODEX_HANDOFF,
11
+ AUTOMATED_CHECK_REPAIR_CODEX_HANDOFF,
12
+ DEEP_UI_CHECK_CODEX_HANDOFF,
13
+ ISSUE_DETAILS_CODEX_HANDOFF,
14
+ PLAN_EXECUTION_CODEX_HANDOFF,
15
+ REVIEW_PASS_LIMIT,
16
+ REVIEW_EXECUTION_CODEX_HANDOFF,
8
17
  SESSION_STATUS,
18
+ SESSION_WORKFLOW_VERSION,
9
19
  STEP_DEFINITIONS,
10
20
  STEP_IDS,
11
21
  STEP_PRECONDITION_NAMES
12
22
  } from "./sessionRuntime/constants.js";
13
23
  import {
24
+ fileExists,
14
25
  normalizeText,
15
26
  readTextIfExists,
16
27
  readTrimmedFile,
@@ -37,19 +48,37 @@ import {
37
48
  failSession,
38
49
  markCurrentStep,
39
50
  markStatus,
51
+ normalizeReviewPassNumber,
52
+ readActiveCycle,
40
53
  readReceiptSteps,
54
+ readReviewPasses,
41
55
  readSessionArtifacts,
56
+ reviewPassRoot,
57
+ writeActiveCycle,
58
+ writeCycleReceipt,
42
59
  writeReceipt
43
60
  } from "./sessionRuntime/responses.js";
44
61
  import {
45
62
  applyPreconditions,
63
+ assertAcceptedChangesCommitted,
64
+ assertActiveCycleExists,
65
+ assertActiveCycleUserCheckPassed,
66
+ assertBlueprintUpdateSatisfied,
67
+ assertDeepUiCheckSatisfied,
68
+ assertDependenciesInstalled,
69
+ assertFinalReportExists,
46
70
  assertGhAuth,
47
71
  assertGitCurrentBranch,
48
72
  assertGitRepository,
49
73
  assertGithubOrigin,
50
- assertIssueArtifacts,
74
+ assertIssueMetadataExists,
51
75
  assertIssueTextExists,
76
+ assertIssueUrlExists,
77
+ assertAutomatedChecksPassed,
78
+ assertIssueDetailsExists,
79
+ assertPlanTextExists,
52
80
  assertPrUrlExists,
81
+ assertReadyJskitApp,
53
82
  assertSessionExists,
54
83
  assertTargetRootWritable,
55
84
  assertWorktreeExists,
@@ -60,6 +89,10 @@ import {
60
89
  renderPrompt,
61
90
  renderTemplate
62
91
  } from "./sessionRuntime/promptRenderer.js";
92
+ import {
93
+ HELPER_MAP_JSON_RELATIVE_PATH,
94
+ HELPER_MAP_MARKDOWN_RELATIVE_PATH
95
+ } from "./helperMapPaths.js";
63
96
 
64
97
  function invalidSessionIdError(sessionId = "") {
65
98
  return createError({
@@ -121,10 +154,273 @@ async function withExistingSession(input, handler) {
121
154
  });
122
155
  }
123
156
 
124
- function extractIssueText(value = "") {
157
+ function extractMarkedText(value = "", marker = "") {
125
158
  const text = normalizeText(value);
126
- const match = /\[issue_text\]([\s\S]*?)\[\/issue_text\]/u.exec(text);
127
- return normalizeText(match ? match[1] : text);
159
+ const normalizedMarker = normalizeText(marker);
160
+ if (!normalizedMarker) {
161
+ return "";
162
+ }
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] : "");
166
+ }
167
+
168
+ function extractIssueTitle(value = "") {
169
+ return extractMarkedText(value, "issue_title");
170
+ }
171
+
172
+ function extractIssueText(value = "") {
173
+ return extractMarkedText(value, "issue_text") || normalizeText(value);
174
+ }
175
+
176
+ function extractPlanText(value = "") {
177
+ return extractMarkedText(value, "plan") || normalizeText(value);
178
+ }
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
+
210
+ async function writePromptArtifact(paths, fileName, prompt) {
211
+ await writeTextFile(path.join(paths.sessionRoot, "prompts", fileName), prompt);
212
+ }
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");
128
424
  }
129
425
 
130
426
  async function createSession({
@@ -161,8 +457,10 @@ async function createSession({
161
457
 
162
458
  await ensureStudioGitExclude(initialPaths.targetRoot);
163
459
  await mkdir(initialPaths.sessionRoot, { recursive: true });
164
- await mkdir(initialPaths.worktreesRoot, { recursive: true });
165
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");
166
464
  await markStatus(initialPaths, SESSION_STATUS.PENDING);
167
465
  await writeReceipt(initialPaths, "session_created", `Created JSKIT Studio issue session ${initialPaths.sessionId}.`);
168
466
 
@@ -172,14 +470,38 @@ async function createSession({
172
470
  });
173
471
  }
174
472
 
175
- async function listSessions({ targetRoot = process.cwd() } = {}) {
473
+ const SESSION_ARCHIVE_ROOTS = Object.freeze([
474
+ "active",
475
+ "completed",
476
+ "abandoned"
477
+ ]);
478
+
479
+ function normalizeArchiveFilter(archive = "active") {
480
+ const requestedArchives = Array.isArray(archive) ? archive : [archive];
481
+ const normalized = requestedArchives
482
+ .map((entry) => String(entry || "").trim().toLowerCase())
483
+ .filter(Boolean);
484
+ if (normalized.includes("all")) {
485
+ return SESSION_ARCHIVE_ROOTS;
486
+ }
487
+ const allowed = new Set(SESSION_ARCHIVE_ROOTS);
488
+ const selected = normalized.filter((entry) => allowed.has(entry));
489
+ return selected.length > 0 ? [...new Set(selected)] : ["active"];
490
+ }
491
+
492
+ async function listSessions({ targetRoot = process.cwd(), archive = "active" } = {}) {
176
493
  const paths = resolveSessionPaths({ targetRoot });
177
494
  const sessions = [];
178
- const roots = [
179
- { archive: "active", root: paths.sessionsRoot },
180
- { archive: "completed", root: paths.completedSessionsRoot },
181
- { archive: "abandoned", root: paths.abandonedSessionsRoot }
182
- ];
495
+ const rootsByArchive = {
496
+ abandoned: paths.abandonedSessionsRoot,
497
+ active: paths.sessionsRoot,
498
+ completed: paths.completedSessionsRoot
499
+ };
500
+ const selectedArchives = normalizeArchiveFilter(archive);
501
+ const roots = selectedArchives.map((archiveName) => ({
502
+ archive: archiveName,
503
+ root: rootsByArchive[archiveName]
504
+ }));
183
505
 
184
506
  for (const rootInfo of roots) {
185
507
  let entries = [];
@@ -207,6 +529,8 @@ async function listSessions({ targetRoot = process.cwd() } = {}) {
207
529
  }
208
530
  sessions.sort((left, right) => right.sessionId.localeCompare(left.sessionId));
209
531
  return {
532
+ archive: selectedArchives.length === 1 ? selectedArchives[0] : "mixed",
533
+ archives: selectedArchives,
210
534
  ok: true,
211
535
  stepDefinitions: buildStepDefinitions(),
212
536
  sessions
@@ -227,7 +551,9 @@ async function inspectSession({
227
551
  function emptySessionDetails(response) {
228
552
  return {
229
553
  ...response,
554
+ issueTitle: "",
230
555
  issueText: "",
556
+ planText: "",
231
557
  receipts: [],
232
558
  transcriptLog: ""
233
559
  };
@@ -243,24 +569,66 @@ async function inspectSessionDetails({
243
569
  }
244
570
  const { paths, preconditions } = context;
245
571
  const response = await buildSessionResponse(paths, { preconditions });
572
+ const { planText } = await readCurrentPlan(paths);
246
573
 
247
- const [issueText, receipts, transcriptLog] = await Promise.all([
574
+ const [issueText, issueTitle, receipts, transcriptLog] = await Promise.all([
248
575
  readTextIfExists(path.join(paths.sessionRoot, "issue.md")),
576
+ readTrimmedFile(path.join(paths.sessionRoot, "issue_title")),
249
577
  readReceiptSteps(paths),
250
578
  readTextIfExists(path.join(paths.sessionRoot, "transcript.log"))
251
579
  ]);
252
580
 
253
581
  return {
254
582
  ...response,
583
+ issueTitle,
255
584
  issueText: issueText.trim(),
585
+ planText: planText.trim(),
256
586
  receipts,
257
587
  transcriptLog
258
588
  };
259
589
  }
260
590
 
591
+ async function removeEmptyStaleWorktreeDirectory(paths) {
592
+ try {
593
+ const entries = await readdir(paths.worktree);
594
+ if (entries.length > 0) {
595
+ return {
596
+ ok: false,
597
+ message: `Worktree path exists but is not a registered Git worktree: ${paths.worktree}`
598
+ };
599
+ }
600
+ await rmdir(paths.worktree);
601
+ return {
602
+ ok: true
603
+ };
604
+ } catch (error) {
605
+ if (error?.code === "ENOENT") {
606
+ return {
607
+ ok: true
608
+ };
609
+ }
610
+ return {
611
+ ok: false,
612
+ message: `Cannot prepare worktree path ${paths.worktree}: ${error?.message || error}`
613
+ };
614
+ }
615
+ }
616
+
261
617
  async function createWorktree(paths, _options = {}, context = {}) {
262
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);
263
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
+ }
264
632
  await writeReceipt(paths, "worktree_created", `Reused existing worktree ${paths.worktree}.`);
265
633
  await markStatus(paths, SESSION_STATUS.RUNNING);
266
634
  return buildSessionResponse(paths, {
@@ -268,8 +636,18 @@ async function createWorktree(paths, _options = {}, context = {}) {
268
636
  });
269
637
  }
270
638
 
271
- await mkdir(paths.worktreesRoot, { recursive: true });
272
- const result = await runGit(paths.targetRoot, ["worktree", "add", "-b", paths.branch, paths.worktree, "HEAD"], {
639
+ await mkdir(path.dirname(paths.worktree), { recursive: true });
640
+ const staleWorktree = await removeEmptyStaleWorktreeDirectory(paths);
641
+ if (!staleWorktree.ok) {
642
+ return failSession(paths, {
643
+ code: "worktree_path_blocked",
644
+ message: staleWorktree.message,
645
+ repairCommand: `ls -la ${paths.worktree}`,
646
+ preconditions
647
+ });
648
+ }
649
+ const result = await runLoggedCommand(paths, "git_worktree_add", "git", ["worktree", "add", "-b", paths.branch, paths.worktree, "HEAD"], {
650
+ cwd: paths.targetRoot,
273
651
  timeout: 30000
274
652
  });
275
653
  if (!result.ok) {
@@ -280,6 +658,12 @@ async function createWorktree(paths, _options = {}, context = {}) {
280
658
  preconditions
281
659
  });
282
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
+ }
283
667
  await writeReceipt(paths, "worktree_created", `Created worktree ${paths.worktree} on branch ${paths.branch}.`);
284
668
  await markStatus(paths, SESSION_STATUS.RUNNING);
285
669
  return buildSessionResponse(paths, {
@@ -287,6 +671,120 @@ async function createWorktree(paths, _options = {}, context = {}) {
287
671
  });
288
672
  }
289
673
 
674
+ async function recordDependenciesInstalled(paths, {
675
+ message = "Installed Node dependencies in the session worktree.",
676
+ preconditions = []
677
+ } = {}) {
678
+ await writeReceipt(paths, "dependencies_installed", message);
679
+ await markStatus(paths, SESSION_STATUS.RUNNING);
680
+ return buildSessionResponse(paths, {
681
+ preconditions
682
+ });
683
+ }
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
+
741
+ async function installDependencies(paths, _options = {}, context = {}) {
742
+ const preconditions = context.preconditions || [];
743
+ const [command, args] = await dependencyInstallCommandForWorktree(paths.worktree);
744
+ const result = await runLoggedCommand(paths, "dependencies_install", command, args, {
745
+ cwd: paths.worktree,
746
+ timeout: 1000 * 60 * 10
747
+ });
748
+ if (!result.ok) {
749
+ return failSession(paths, {
750
+ code: "dependencies_install_failed",
751
+ message: result.output || `${command} ${args.join(" ")} failed in the session worktree.`,
752
+ repairCommand: `cd ${paths.worktree} && ${command} ${args.join(" ")}`,
753
+ preconditions
754
+ });
755
+ }
756
+ return recordDependenciesInstalled(paths, {
757
+ message: result.output || `Installed Node dependencies in the session worktree with ${command} ${args.join(" ")}.`,
758
+ preconditions
759
+ });
760
+ }
761
+
762
+ async function adoptDependenciesInstalled({
763
+ targetRoot = process.cwd(),
764
+ sessionId,
765
+ message = ""
766
+ } = {}) {
767
+ return withExistingSession({ targetRoot, sessionId }, async (paths, context = {}) => {
768
+ const artifacts = await readSessionArtifacts(paths);
769
+ if (artifacts.nextStep !== "dependencies_installed") {
770
+ return buildSessionResponse(paths, {
771
+ ok: false,
772
+ errors: [
773
+ createError({
774
+ code: "session_step_mismatch",
775
+ message: `Cannot record dependencies for ${paths.sessionId}; current step is ${artifacts.nextStep || "complete"}.`
776
+ })
777
+ ],
778
+ preconditions: context.preconditions || []
779
+ });
780
+ }
781
+ return recordDependenciesInstalled(paths, {
782
+ message,
783
+ preconditions: context.preconditions || []
784
+ });
785
+ });
786
+ }
787
+
290
788
  async function renderIssuePrompt(paths, options = {}) {
291
789
  const userInput = normalizeText(options.prompt);
292
790
  if (!userInput) {
@@ -299,7 +797,7 @@ async function renderIssuePrompt(paths, options = {}) {
299
797
  const prompt = await renderPrompt(paths, "new_issue.md", {
300
798
  user_input: userInput
301
799
  });
302
- await writeTextFile(path.join(paths.sessionRoot, "prompt.md"), prompt);
800
+ await writePromptArtifact(paths, "issue_draft.md", prompt);
303
801
  await writeReceipt(paths, "issue_prompt_rendered", "Rendered the issue drafting prompt.");
304
802
  await markStatus(paths, SESSION_STATUS.WAITING_FOR_USER);
305
803
  return buildSessionResponse(paths, {
@@ -318,7 +816,16 @@ async function draftIssue(paths, options = {}) {
318
816
  repairCommand: `jskit session ${paths.sessionId} step --issue -`
319
817
  });
320
818
  }
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
+ }
321
827
  await writeTextFile(path.join(paths.sessionRoot, "issue.md"), issueText);
828
+ await writeTextFile(path.join(paths.sessionRoot, "issue_title"), issueTitle);
322
829
  await writeReceipt(paths, "issue_drafted", "Saved approved issue text.");
323
830
  await markStatus(paths, SESSION_STATUS.RUNNING);
324
831
  return buildSessionResponse(paths);
@@ -334,12 +841,29 @@ function titleFromIssue(issueText) {
334
841
 
335
842
  async function createIssue(paths, _options = {}, context = {}) {
336
843
  const preconditions = context.preconditions || [];
844
+ const existingIssueUrl = await readTrimmedFile(path.join(paths.sessionRoot, "issue_url"));
337
845
  const issueText = await readTrimmedFile(path.join(paths.sessionRoot, "issue.md"));
338
- const result = await runCommand("gh", [
846
+ const issueTitle = await readTrimmedFile(path.join(paths.sessionRoot, "issue_title")) || titleFromIssue(issueText);
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", [
339
863
  "issue",
340
864
  "create",
341
865
  "--title",
342
- titleFromIssue(issueText),
866
+ issueTitle,
343
867
  "--body-file",
344
868
  path.join(paths.sessionRoot, "issue.md")
345
869
  ], {
@@ -356,6 +880,14 @@ async function createIssue(paths, _options = {}, context = {}) {
356
880
  }
357
881
  const issueUrl = result.stdout.split(/\r?\n/u).map((line) => line.trim()).find(Boolean) || result.stdout;
358
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);
359
891
  await writeReceipt(paths, "issue_created", `Created GitHub issue ${issueUrl}.`);
360
892
  await markStatus(paths, SESSION_STATUS.RUNNING);
361
893
  return buildSessionResponse(paths, {
@@ -363,66 +895,328 @@ async function createIssue(paths, _options = {}, context = {}) {
363
895
  });
364
896
  }
365
897
 
366
- async function renderImplementationPrompt(paths) {
898
+ async function renderIssueDetailsPrompt(paths, _options = {}, context = {}) {
899
+ const preconditions = context.preconditions || [];
367
900
  const issueText = await readTrimmedFile(path.join(paths.sessionRoot, "issue.md"));
901
+ const issueTitle = await readTrimmedFile(path.join(paths.sessionRoot, "issue_title")) || titleFromIssue(issueText);
368
902
  const issueUrl = await readTrimmedFile(path.join(paths.sessionRoot, "issue_url"));
369
- const prompt = await renderPrompt(paths, "implement_issue.md", {
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,
370
907
  issue_text: issueText,
371
- issue_url: issueUrl
908
+ issue_title: issueTitle,
909
+ issue_url: issueUrl,
910
+ issue_details_file: path.join(paths.sessionRoot, "issue_details.md"),
911
+ worktree: paths.worktree
372
912
  });
373
- await writeTextFile(path.join(paths.sessionRoot, "prompt.md"), prompt);
374
- await writeReceipt(paths, "implementation_prompt_rendered", "Rendered the implementation prompt.");
913
+ await writePromptArtifact(paths, "issue_details.md", prompt);
375
914
  await markStatus(paths, SESSION_STATUS.WAITING_FOR_USER);
376
915
  return buildSessionResponse(paths, {
916
+ codex: ISSUE_DETAILS_CODEX_HANDOFF,
917
+ ok: true,
918
+ preconditions,
377
919
  prompt,
378
920
  status: SESSION_STATUS.WAITING_FOR_USER
379
921
  });
380
922
  }
381
923
 
382
- async function worktreeStatus(worktree) {
383
- const result = await runGitInWorktree(worktree, ["status", "--porcelain=v1"]);
384
- if (!result.ok) {
385
- return {
386
- ok: false,
387
- changedFiles: [],
388
- output: result.output
389
- };
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
+ });
390
942
  }
391
- const changedFiles = result.stdout.split(/\r?\n/u).map((line) => line.trim()).filter(Boolean);
392
- return {
393
- ok: true,
394
- changedFiles,
395
- output: result.stdout
396
- };
397
- }
398
943
 
399
- async function detectChanges(paths) {
400
- const status = await worktreeStatus(paths.worktree);
401
- if (!status.ok) {
944
+ const issueCategory = normalizeIssueCategory(structuredIssueCategory || extractIssueCategory(source));
945
+ const uiImpact = normalizeUiImpact(structuredUiImpact || extractUiImpact(source));
946
+ if (!issueCategory) {
402
947
  return failSession(paths, {
403
- code: "git_status_failed",
404
- message: status.output || "Failed to inspect worktree changes.",
405
- repairCommand: `git -C ${paths.worktree} status --short`
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
406
952
  });
407
953
  }
408
- if (status.changedFiles.length < 1) {
954
+ if (!uiImpact) {
409
955
  return failSession(paths, {
410
- code: "changes_missing",
411
- message: "No worktree changes found. Paste the implementation prompt into Codex and retry after changes exist.",
412
- repairCommand: `jskit session ${paths.sessionId} step`
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"
413
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
+ }
414
988
  }
415
- await writeReceipt(paths, "implementation_changes_detected", `Detected ${status.changedFiles.length} changed file entries.`);
989
+
990
+ await writeReceipt(paths, "issue_details_gathered", "Saved confirmed issue details and recorded the GitHub issue comment.");
416
991
  await markStatus(paths, SESSION_STATUS.RUNNING);
417
- return buildSessionResponse(paths);
992
+ return buildSessionResponse(paths, {
993
+ preconditions
994
+ });
418
995
  }
419
996
 
420
- async function commitWorktree(paths, {
421
- message,
422
- allowNoChanges = false
423
- } = {}) {
424
- const status = await worktreeStatus(paths.worktree);
425
- if (!status.ok) {
997
+ async function makePlan(paths, options = {}, context = {}) {
998
+ const preconditions = context.preconditions || [];
999
+ const activeCycle = await readActiveCycle(paths);
1000
+ const issueText = await readTrimmedFile(path.join(paths.sessionRoot, "issue.md"));
1001
+ const issueTitle = await readTrimmedFile(path.join(paths.sessionRoot, "issue_title")) || titleFromIssue(issueText);
1002
+ const issueUrl = await readTrimmedFile(path.join(paths.sessionRoot, "issue_url"));
1003
+ const issueNumber = issueNumberFromUrl(issueUrl);
1004
+ const issueDetails = await readTrimmedFile(path.join(paths.sessionRoot, "issue_details.md"));
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);
1012
+
1013
+ if (!planText) {
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"),
1019
+ issue_file: path.join(paths.sessionRoot, "issue.md"),
1020
+ issue_number: issueNumber,
1021
+ issue_text: issueText,
1022
+ issue_title: issueTitle,
1023
+ issue_title_file: path.join(paths.sessionRoot, "issue_title"),
1024
+ issue_url: issueUrl,
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 : "",
1031
+ worktree: paths.worktree
1032
+ });
1033
+ await writePromptArtifact(paths, cyclePlanPromptFileName(activeCycle), prompt);
1034
+ await markStatus(paths, SESSION_STATUS.WAITING_FOR_USER);
1035
+ return buildSessionResponse(paths, {
1036
+ ok: true,
1037
+ preconditions,
1038
+ prompt,
1039
+ status: SESSION_STATUS.WAITING_FOR_USER
1040
+ });
1041
+ }
1042
+
1043
+ await mkdir(currentCycleRoot, { recursive: true });
1044
+ await writeTextFile(planPath, planText);
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
1050
+ });
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, {
1061
+ preconditions
1062
+ });
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);
1072
+ const executionPrompt = await renderPrompt(paths, "execute_plan.md", {
1073
+ active_cycle: activeCycle,
1074
+ issue_file: path.join(paths.sessionRoot, "issue.md"),
1075
+ issue_number: issueNumber,
1076
+ issue_title: issueTitle,
1077
+ issue_url: issueUrl,
1078
+ issue_details_file: issueDetailsPath,
1079
+ issue_details_text: issueDetails,
1080
+ plan_file: planPath,
1081
+ plan_text: planText,
1082
+ worktree: paths.worktree
1083
+ });
1084
+ await writePromptArtifact(paths, cyclePlanExecutionPromptFileName(activeCycle), executionPrompt);
1085
+ await markStatus(paths, SESSION_STATUS.WAITING_FOR_USER);
1086
+ return buildSessionResponse(paths, {
1087
+ codex: PLAN_EXECUTION_CODEX_HANDOFF,
1088
+ preconditions,
1089
+ prompt: executionPrompt,
1090
+ status: SESSION_STATUS.WAITING_FOR_USER
1091
+ });
1092
+ }
1093
+
1094
+ async function worktreeStatus(worktree) {
1095
+ const result = await runGitInWorktree(worktree, ["status", "--porcelain=v1"]);
1096
+ if (!result.ok) {
1097
+ return {
1098
+ ok: false,
1099
+ changedFiles: [],
1100
+ output: result.output
1101
+ };
1102
+ }
1103
+ const changedFiles = result.stdout.split(/\r?\n/u).map((line) => line.trim()).filter(Boolean);
1104
+ return {
1105
+ ok: true,
1106
+ changedFiles,
1107
+ output: result.stdout
1108
+ };
1109
+ }
1110
+
1111
+ async function untrackedFiles(worktree) {
1112
+ const result = await runGitInWorktree(worktree, ["ls-files", "--others", "--exclude-standard", "-z"], {
1113
+ timeout: 15000
1114
+ });
1115
+ if (!result.ok) {
1116
+ return [];
1117
+ }
1118
+ return result.stdout
1119
+ .split("\0")
1120
+ .filter((line) => line.length > 0);
1121
+ }
1122
+
1123
+ async function untrackedFileDiff(worktree, filePath) {
1124
+ const result = await runGitInWorktree(worktree, [
1125
+ "diff",
1126
+ "--no-color",
1127
+ "--no-ext-diff",
1128
+ "--no-index",
1129
+ "--",
1130
+ "/dev/null",
1131
+ filePath
1132
+ ], {
1133
+ timeout: 15000
1134
+ });
1135
+ if (result.ok || result.exitCode === 1) {
1136
+ return result.stdout;
1137
+ }
1138
+ return "";
1139
+ }
1140
+
1141
+ async function untrackedFilesDiff(worktree) {
1142
+ const diffs = [];
1143
+ for (const filePath of await untrackedFiles(worktree)) {
1144
+ const diff = await untrackedFileDiff(worktree, filePath);
1145
+ if (diff) {
1146
+ diffs.push(diff);
1147
+ }
1148
+ }
1149
+ return diffs.join("\n");
1150
+ }
1151
+
1152
+ async function inspectSessionDiff({
1153
+ targetRoot = process.cwd(),
1154
+ sessionId
1155
+ } = {}) {
1156
+ return withExistingSession({ targetRoot, sessionId }, async (paths) => {
1157
+ const session = await buildSessionResponse(paths);
1158
+ if (!await hasWorktree(paths)) {
1159
+ return {
1160
+ ...session,
1161
+ ok: false,
1162
+ errors: [
1163
+ createError({
1164
+ code: "worktree_missing",
1165
+ message: "Session worktree is not available for diff inspection."
1166
+ })
1167
+ ],
1168
+ gitStatus: "",
1169
+ hasChanges: false,
1170
+ stagedDiff: "",
1171
+ unstagedDiff: "",
1172
+ untrackedDiff: ""
1173
+ };
1174
+ }
1175
+
1176
+ const [status, unstagedDiff, stagedDiff] = await Promise.all([
1177
+ runGitInWorktree(paths.worktree, ["status", "--porcelain=v1"], { timeout: 15000 }),
1178
+ runGitInWorktree(paths.worktree, ["diff", "--no-color", "--no-ext-diff"], { timeout: 30000 }),
1179
+ runGitInWorktree(paths.worktree, ["diff", "--cached", "--no-color", "--no-ext-diff"], { timeout: 30000 })
1180
+ ]);
1181
+
1182
+ if (!status.ok || !unstagedDiff.ok || !stagedDiff.ok) {
1183
+ return {
1184
+ ...session,
1185
+ ok: false,
1186
+ errors: [
1187
+ createError({
1188
+ code: "session_diff_failed",
1189
+ message: [status, unstagedDiff, stagedDiff].find((result) => !result.ok)?.output ||
1190
+ "Failed to inspect session worktree diff."
1191
+ })
1192
+ ],
1193
+ gitStatus: status.stdout || "",
1194
+ hasChanges: false,
1195
+ stagedDiff: stagedDiff.stdout || "",
1196
+ unstagedDiff: unstagedDiff.stdout || "",
1197
+ untrackedDiff: ""
1198
+ };
1199
+ }
1200
+
1201
+ const untrackedDiff = await untrackedFilesDiff(paths.worktree);
1202
+ return {
1203
+ ...session,
1204
+ gitStatus: status.stdout,
1205
+ hasChanges: Boolean(status.stdout.trim()),
1206
+ stagedDiff: stagedDiff.stdout,
1207
+ unstagedDiff: unstagedDiff.stdout,
1208
+ untrackedDiff,
1209
+ worktree: paths.worktree
1210
+ };
1211
+ });
1212
+ }
1213
+
1214
+ async function commitWorktree(paths, {
1215
+ message,
1216
+ allowNoChanges = false
1217
+ } = {}) {
1218
+ const status = await worktreeStatus(paths.worktree);
1219
+ if (!status.ok) {
426
1220
  return {
427
1221
  ok: false,
428
1222
  output: status.output
@@ -435,205 +1229,1086 @@ async function commitWorktree(paths, {
435
1229
  output: allowNoChanges ? "No changes to commit." : "No changes found."
436
1230
  };
437
1231
  }
438
- const addResult = await runGitInWorktree(paths.worktree, ["add", "."]);
439
- if (!addResult.ok) {
1232
+ const addResult = await runGitInWorktree(paths.worktree, ["add", "."]);
1233
+ if (!addResult.ok) {
1234
+ return {
1235
+ ok: false,
1236
+ output: addResult.output
1237
+ };
1238
+ }
1239
+ const commitResult = await runGitInWorktree(paths.worktree, ["commit", "-m", message], {
1240
+ timeout: 30000
1241
+ });
1242
+ if (!commitResult.ok) {
1243
+ return {
1244
+ ok: false,
1245
+ output: commitResult.output
1246
+ };
1247
+ }
1248
+ return {
1249
+ changedFiles: status.changedFiles,
1250
+ ok: true,
1251
+ output: commitResult.output
1252
+ };
1253
+ }
1254
+
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
1285
+ });
1286
+ if (!result.ok) {
1287
+ return (await changedFilesInWorktree(paths)).join("\n");
1288
+ }
1289
+ return uniqueChangedFileList([
1290
+ result.stdout,
1291
+ ...(await changedFilesInWorktree(paths))
1292
+ ]).join("\n");
1293
+ }
1294
+
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;
1316
+ }
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() : "";
1331
+ }
1332
+
1333
+ async function renderReviewPrompt(paths) {
1334
+ const reviewPass = await resolveReviewPassForPrompt(paths);
1335
+ await writeCurrentReviewPass(paths, reviewPass);
1336
+ const changedFiles = await changedFilesSinceBase(paths);
1337
+ const prompt = await renderPrompt(paths, "review_changes.md", {
1338
+ changed_files: changedFiles,
1339
+ review_pass_limit: String(REVIEW_PASS_LIMIT),
1340
+ review_pass_number: reviewPass
1341
+ });
1342
+ const passRoot = reviewPassRoot(paths, reviewPass);
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
+ });
1354
+ await writeReceipt(paths, "review_prompt_rendered", "Started code review.");
1355
+ await markStatus(paths, SESSION_STATUS.WAITING_FOR_USER);
1356
+ return buildSessionResponse(paths, {
1357
+ codex: REVIEW_EXECUTION_CODEX_HANDOFF,
1358
+ prompt,
1359
+ status: SESSION_STATUS.WAITING_FOR_USER
1360
+ });
1361
+ }
1362
+
1363
+ async function acceptReviewChanges(paths, options = {}) {
1364
+ const reviewDecisionProvided = Object.hasOwn(options, "reviewFindingsRemaining") ||
1365
+ Object.hasOwn(options, "review-findings-remaining");
1366
+ if (!reviewDecisionProvided) {
1367
+ return failSession(paths, {
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",
1378
+ message: status.output || "Failed to inspect review changes.",
1379
+ repairCommand: `git -C ${paths.worktree} status --short`
1380
+ });
1381
+ }
1382
+ const message = status.changedFiles.length > 0
1383
+ ? `Accepted ${status.changedFiles.length} review changed file entries.`
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
+ });
1397
+ await writeReceipt(paths, "review_changes_accepted", message);
1398
+ await markStatus(paths, SESSION_STATUS.RUNNING);
1399
+ return buildSessionResponse(paths);
1400
+ }
1401
+
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
1431
+ });
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
1498
+ });
1499
+ }
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
+ });
1543
+ }
1544
+
1545
+ async function userCheck(paths, options = {}) {
1546
+ const result = normalizeText(options.userCheck || options["user-check"]).toLowerCase();
1547
+ if (result === "passed" || result === "pass" || result === "ok" || result === "yes") {
1548
+ await writeReceipt(paths, "user_check_completed", "User confirmed check passed.");
1549
+ await markStatus(paths, SESSION_STATUS.RUNNING);
1550
+ return buildSessionResponse(paths);
1551
+ }
1552
+ if (result === "failed" || result === "fail" || result === "no") {
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
1577
+ });
1578
+ await markCurrentStep(paths, "plan_made");
1579
+ await markStatus(paths, SESSION_STATUS.RUNNING);
1580
+ return buildSessionResponse(paths);
1581
+ }
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
+ });
1595
+ await writePromptArtifact(paths, "user_check.md", prompt);
1596
+ await markStatus(paths, SESSION_STATUS.WAITING_FOR_USER);
1597
+ return buildSessionResponse(paths, {
1598
+ prompt,
1599
+ status: SESSION_STATUS.WAITING_FOR_USER
1600
+ });
1601
+ }
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
+
1738
+ async function readPackageJson(root) {
1739
+ try {
1740
+ return JSON.parse(await readFile(path.join(root, "package.json"), "utf8"));
1741
+ } catch {
1742
+ return {};
1743
+ }
1744
+ }
1745
+
1746
+ async function doctorCommandForWorktree(worktree) {
1747
+ const packageJson = await readPackageJson(worktree);
1748
+ const scripts = packageJson && typeof packageJson.scripts === "object" ? packageJson.scripts : {};
1749
+ if (scripts["verify:local"]) {
1750
+ return ["npm", ["run", "verify:local"]];
1751
+ }
1752
+ if (scripts.verify) {
1753
+ return ["npm", ["run", "verify"]];
1754
+ }
1755
+ return ["npx", ["--no-install", "jskit", "app", "verify"]];
1756
+ }
1757
+
1758
+ async function runDoctor(paths) {
1759
+ const repairCommitFailure = await maybeCommitDoctorRepair(paths);
1760
+ if (repairCommitFailure) {
1761
+ return repairCommitFailure;
1762
+ }
1763
+ const [command, args] = await doctorCommandForWorktree(paths.worktree);
1764
+ const result = await runLoggedCommand(paths, "doctor_run", command, args, {
1765
+ cwd: paths.worktree,
1766
+ timeout: 1000 * 60 * 15
1767
+ });
1768
+ await writeTextFile(path.join(paths.sessionRoot, "doctor.log"), result.output);
1769
+ if (!result.ok) {
1770
+ const prompt = await renderPrompt(paths, "doctor_failure.md", {
1771
+ doctor_output: result.output
1772
+ });
1773
+ await writePromptArtifact(paths, "doctor_failure.md", prompt);
1774
+ return failSession(paths, {
1775
+ code: "doctor_failed",
1776
+ message: "Doctor/verification command failed. Paste the failure prompt into Codex, then rerun this step.",
1777
+ repairCommand: `${command} ${args.join(" ")}`,
1778
+ prompt
1779
+ });
1780
+ }
1781
+ await writeReceipt(paths, "doctor_run", `Doctor command passed: ${command} ${args.join(" ")}.`);
1782
+ await markStatus(paths, SESSION_STATUS.RUNNING);
1783
+ return buildSessionResponse(paths);
1784
+ }
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
+
1974
+ function issueNumberFromUrl(issueUrl) {
1975
+ const match = /\/issues\/(\d+)(?:\b|$)/u.exec(String(issueUrl || ""));
1976
+ return match ? match[1] : "";
1977
+ }
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) {
440
1998
  return {
441
1999
  ok: false,
442
- output: addResult.output
2000
+ output: result.output
443
2001
  };
444
2002
  }
445
- const commitResult = await runGitInWorktree(paths.worktree, ["commit", "-m", message], {
446
- timeout: 30000
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
447
2018
  });
448
- if (!commitResult.ok) {
2019
+ if (!result.ok) {
449
2020
  return {
450
2021
  ok: false,
451
- output: commitResult.output
2022
+ output: result.output
452
2023
  };
453
2024
  }
2025
+ const payload = parseJsonObject(result.stdout);
454
2026
  return {
455
- changedFiles: status.changedFiles,
456
- ok: true,
457
- output: commitResult.output
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 || ""
458
2033
  };
459
2034
  }
460
2035
 
461
- async function commitImplementation(paths) {
462
- const result = await commitWorktree(paths, {
463
- message: `Implement JSKIT session ${paths.sessionId}`
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
464
2047
  });
465
- if (!result.ok) {
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) {
466
2056
  return failSession(paths, {
467
- code: "commit_failed",
468
- message: result.output || "Failed to commit implementation changes.",
469
- repairCommand: `git -C ${paths.worktree} status --short`
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`
470
2060
  });
471
2061
  }
472
- await writeReceipt(paths, "implementation_changes_committed", `Committed implementation changes for ${paths.sessionId}.`);
473
- await markStatus(paths, SESSION_STATUS.RUNNING);
474
- return buildSessionResponse(paths);
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;
475
2070
  }
476
2071
 
477
- async function changedFilesFromLastCommit(paths) {
478
- const result = await runGitInWorktree(paths.worktree, ["show", "--name-only", "--format=", "HEAD"]);
479
- if (!result.ok) {
480
- return "";
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
+ }
481
2085
  }
482
- return result.stdout.split(/\r?\n/u).map((line) => line.trim()).filter(Boolean).join("\n");
2086
+ return null;
483
2087
  }
484
2088
 
485
- function reviewPromptStepId(passNumber) {
486
- return passNumber === 1
487
- ? "initial_review_prompt_rendered"
488
- : passNumber === 2
489
- ? "followup_review_prompt_rendered"
490
- : "final_review_prompt_rendered";
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`);
491
2094
  }
492
2095
 
493
- async function renderReviewPrompt(paths, passNumber) {
494
- const prompt = await renderPrompt(paths, "review_changes.md", {
495
- changed_files: await changedFilesFromLastCommit(paths),
496
- review_pass: String(passNumber),
497
- review_pass_note: passNumber >= 3 ? "This is the final review pass before doctor and PR steps." : ""
498
- });
499
- await writeTextFile(path.join(paths.sessionRoot, "prompt.md"), prompt);
500
- await writeReceipt(paths, reviewPromptStepId(passNumber), `Rendered review prompt pass ${passNumber}.`);
501
- await markStatus(paths, SESSION_STATUS.WAITING_FOR_USER);
502
- return buildSessionResponse(paths, {
503
- prompt,
504
- status: SESSION_STATUS.WAITING_FOR_USER
505
- });
506
- }
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
+ }
507
2110
 
508
- function reviewChangesStepId(passNumber) {
509
- return passNumber === 1
510
- ? "initial_review_changes_detected"
511
- : passNumber === 2
512
- ? "followup_review_changes_detected"
513
- : "final_review_changes_detected";
2111
+ return null;
514
2112
  }
515
2113
 
516
- async function detectAndCommitReviewChanges(paths, passNumber) {
517
- const result = await commitWorktree(paths, {
518
- allowNoChanges: true,
519
- message: `Apply review pass ${passNumber} for ${paths.sessionId}`
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
520
2123
  });
521
- if (!result.ok) {
2124
+ if (!fetchResult.ok) {
522
2125
  return failSession(paths, {
523
- code: "review_commit_failed",
524
- message: result.output || `Failed to commit review pass ${passNumber}.`,
525
- repairCommand: `git -C ${paths.worktree} status --short`
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`
526
2129
  });
527
2130
  }
528
- const message = result.changedFiles?.length
529
- ? `Committed review pass ${passNumber} changes.`
530
- : `No review pass ${passNumber} changes detected.`;
531
- await writeReceipt(paths, reviewChangesStepId(passNumber), message);
532
- await markStatus(paths, SESSION_STATUS.RUNNING);
533
- return buildSessionResponse(paths);
534
- }
535
2131
 
536
- function userCheckStepId(passNumber) {
537
- return passNumber === 1
538
- ? "initial_user_check_completed"
539
- : passNumber === 2
540
- ? "followup_user_check_completed"
541
- : "final_user_check_completed";
542
- }
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
+ }
543
2143
 
544
- async function userCheck(paths, passNumber, options = {}) {
545
- const result = normalizeText(options.userCheck || options["user-check"]).toLowerCase();
546
- if (result === "passed" || result === "pass" || result === "ok" || result === "yes") {
547
- await writeReceipt(paths, userCheckStepId(passNumber), `User confirmed check ${passNumber} passed.`);
548
- await markStatus(paths, SESSION_STATUS.RUNNING);
549
- return buildSessionResponse(paths);
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
+ });
550
2153
  }
551
- if (result === "failed" || result === "fail" || result === "no") {
2154
+
2155
+ return null;
2156
+ }
2157
+
2158
+ async function updateLocalBaseBranch(paths, baseBranch = "") {
2159
+ const branch = normalizeText(baseBranch) || await currentTargetBranch(paths.targetRoot);
2160
+ if (!branch) {
552
2161
  return failSession(paths, {
553
- code: "user_check_failed",
554
- message: `User check ${passNumber} was reported as failed. Continue in Codex, then retry this step with --user-check passed.`,
555
- repairCommand: `jskit session ${paths.sessionId} step --user-check passed`
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`
556
2165
  });
557
2166
  }
558
- const prompt = await renderPrompt(paths, "user_check.md", {
559
- review_pass: String(passNumber)
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
560
2175
  });
561
- await writeTextFile(path.join(paths.sessionRoot, "prompt.md"), prompt);
562
- await markStatus(paths, SESSION_STATUS.WAITING_FOR_USER);
563
- return buildSessionResponse(paths, {
564
- prompt,
565
- status: SESSION_STATUS.WAITING_FOR_USER
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
566
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;
567
2198
  }
568
2199
 
569
- async function readPackageJson(root) {
2200
+ async function updateHelperMapBeforePr(paths) {
2201
+ let helperMapPayload;
570
2202
  try {
571
- return JSON.parse(await readFile(path.join(root, "package.json"), "utf8"));
572
- } catch {
573
- return {};
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
+ };
574
2214
  }
575
- }
576
2215
 
577
- async function doctorCommandForWorktree(worktree) {
578
- const packageJson = await readPackageJson(worktree);
579
- const scripts = packageJson && typeof packageJson.scripts === "object" ? packageJson.scripts : {};
580
- if (scripts["verify:local"]) {
581
- return ["npm", ["run", "verify:local"]];
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
+ };
582
2232
  }
583
- if (scripts.verify) {
584
- return ["npm", ["run", "verify"]];
2233
+
2234
+ if (!statusResult.stdout.trim()) {
2235
+ return {
2236
+ ok: true,
2237
+ changed: false,
2238
+ message: "Helper map already up to date."
2239
+ };
585
2240
  }
586
- return ["npx", ["jskit", "app", "verify"]];
587
- }
588
2241
 
589
- async function runDoctor(paths) {
590
- const [command, args] = await doctorCommandForWorktree(paths.worktree);
591
- const result = await runCommand(command, args, {
592
- cwd: paths.worktree,
593
- timeout: 1000 * 60 * 15
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
594
2248
  });
595
- await writeTextFile(path.join(paths.sessionRoot, "doctor.log"), result.output);
596
- if (!result.ok) {
597
- const prompt = await renderPrompt(paths, "doctor_failure.md", {
598
- doctor_output: result.output
599
- });
600
- await writeTextFile(path.join(paths.sessionRoot, "prompt.md"), prompt);
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
+
2281
+ async function createPr(paths) {
2282
+ const helperMapResult = await updateHelperMapBeforePr(paths);
2283
+ if (!helperMapResult.ok) {
601
2284
  return failSession(paths, {
602
- code: "doctor_failed",
603
- message: "Doctor/verification command failed. Paste the failure prompt into Codex, then rerun this step.",
604
- repairCommand: `${command} ${args.join(" ")}`,
605
- prompt
2285
+ code: helperMapResult.code,
2286
+ message: helperMapResult.message,
2287
+ repairCommand: helperMapResult.repairCommand
606
2288
  });
607
2289
  }
608
- await writeReceipt(paths, "doctor_run", `Doctor command passed: ${command} ${args.join(" ")}.`);
609
- await markStatus(paths, SESSION_STATUS.RUNNING);
610
- return buildSessionResponse(paths);
611
- }
612
2290
 
613
- async function pushBranch(paths) {
614
- const result = await runGitInWorktree(paths.worktree, ["push", "-u", "origin", "HEAD"], {
2291
+ const pushResult = await runLoggedCommand(paths, "git_push_branch", "git", ["push", "-u", "origin", "HEAD"], {
2292
+ cwd: paths.worktree,
615
2293
  timeout: 1000 * 60 * 5
616
2294
  });
617
- if (!result.ok) {
2295
+ if (!pushResult.ok) {
618
2296
  return failSession(paths, {
619
2297
  code: "branch_push_failed",
620
- message: result.output || "Failed to push session branch.",
2298
+ message: pushResult.output || "Failed to push session branch.",
621
2299
  repairCommand: `git -C ${paths.worktree} push -u origin HEAD`
622
2300
  });
623
2301
  }
624
- await writeReceipt(paths, "branch_pushed", `Pushed branch ${paths.branch}.`);
625
- await markStatus(paths, SESSION_STATUS.RUNNING);
626
- return buildSessionResponse(paths);
627
- }
628
-
629
- function issueNumberFromUrl(issueUrl) {
630
- const match = /\/issues\/(\d+)(?:\b|$)/u.exec(String(issueUrl || ""));
631
- return match ? match[1] : "";
632
- }
633
-
634
- async function createPr(paths) {
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
+ }
635
2309
  const issueUrl = await readTrimmedFile(path.join(paths.sessionRoot, "issue_url"));
636
2310
  const issueText = await readTrimmedFile(path.join(paths.sessionRoot, "issue.md"));
2311
+ const issueTitle = await readTrimmedFile(path.join(paths.sessionRoot, "issue_title")) || titleFromIssue(issueText);
637
2312
  const issueNumber = issueNumberFromUrl(issueUrl);
638
2313
  const body = [
639
2314
  issueNumber ? `Closes #${issueNumber}` : "",
@@ -642,11 +2317,11 @@ async function createPr(paths) {
642
2317
  ].join("\n").trim();
643
2318
  const bodyPath = path.join(paths.sessionRoot, "pr_body.md");
644
2319
  await writeTextFile(bodyPath, body);
645
- const result = await runCommand("gh", [
2320
+ const result = await runLoggedCommand(paths, "github_pr_create", "gh", [
646
2321
  "pr",
647
2322
  "create",
648
2323
  "--title",
649
- titleFromIssue(issueText),
2324
+ issueTitle,
650
2325
  "--body-file",
651
2326
  bodyPath
652
2327
  ], {
@@ -654,10 +2329,17 @@ async function createPr(paths) {
654
2329
  timeout: 1000 * 60
655
2330
  });
656
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
+ }
657
2339
  const prompt = await renderPrompt(paths, "pr_failure.md", {
658
2340
  doctor_output: result.output
659
2341
  });
660
- await writeTextFile(path.join(paths.sessionRoot, "prompt.md"), prompt);
2342
+ await writePromptArtifact(paths, "pr_create_failure.md", prompt);
661
2343
  return failSession(paths, {
662
2344
  code: "pr_create_failed",
663
2345
  message: result.output || "Failed to create PR.",
@@ -667,55 +2349,150 @@ async function createPr(paths) {
667
2349
  }
668
2350
  const prUrl = result.stdout.split(/\r?\n/u).map((line) => line.trim()).find(Boolean) || result.stdout;
669
2351
  await writeTextFile(path.join(paths.sessionRoot, "pr_url"), prUrl);
670
- await writeReceipt(paths, "pr_created", `Created PR ${prUrl}.`);
2352
+ await writeReceipt(paths, "pr_created", `Pushed branch ${paths.branch} and created PR ${prUrl}. ${helperMapResult.message}`);
671
2353
  await markStatus(paths, SESSION_STATUS.RUNNING);
672
2354
  return buildSessionResponse(paths);
673
2355
  }
674
2356
 
675
- async function mergePr(paths) {
676
- const prUrl = await readTrimmedFile(path.join(paths.sessionRoot, "pr_url"));
677
- const mergeResult = await runCommand("gh", ["pr", "merge", prUrl, "--merge", "--delete-branch"], {
678
- cwd: paths.worktree,
679
- timeout: 1000 * 60 * 5
680
- });
681
- if (!mergeResult.ok) {
682
- const prompt = await renderPrompt(paths, "pr_failure.md", {
683
- doctor_output: mergeResult.output
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>"`
684
2364
  });
685
- await writeTextFile(path.join(paths.sessionRoot, "prompt.md"), prompt);
2365
+ }
2366
+ const prState = await readPrState(paths, prUrl);
2367
+ if (!prState.ok) {
686
2368
  return failSession(paths, {
687
- code: "pr_merge_failed",
688
- message: mergeResult.output || "Failed to merge PR.",
689
- repairCommand: `gh pr merge ${prUrl} --merge --delete-branch`,
690
- prompt
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
691
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
+ }
692
2393
  }
693
2394
  const issueUrl = await readTrimmedFile(path.join(paths.sessionRoot, "issue_url"));
694
2395
  if (issueUrl) {
695
- await runCommand("gh", ["issue", "close", issueUrl, "--comment", `Merged PR ${prUrl}.`], {
696
- cwd: paths.worktree,
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,
697
2398
  timeout: 1000 * 60
698
2399
  });
699
2400
  }
700
- await writeReceipt(paths, "pr_merged", `Merged PR ${prUrl}.`);
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}`);
701
2413
  await markStatus(paths, SESSION_STATUS.RUNNING);
702
2414
  return buildSessionResponse(paths);
703
2415
  }
704
2416
 
705
- async function removeWorktree(paths) {
706
- if (await hasWorktree(paths)) {
707
- const result = await runGit(paths.targetRoot, ["worktree", "remove", paths.worktree], {
708
- timeout: 1000 * 60
2417
+ async function finalizePr(paths, options = {}) {
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`
709
2431
  });
710
- if (!result.ok) {
2432
+ }
2433
+ const mergeMarkerPath = path.join(paths.sessionRoot, "pr_merge_completed");
2434
+ const baseBranchPath = path.join(paths.sessionRoot, "pr_base_branch");
2435
+ const mergeAlreadyCompleted = await readTrimmedFile(mergeMarkerPath);
2436
+ let baseBranch = await readTrimmedFile(baseBranchPath);
2437
+ if (!mergeAlreadyCompleted) {
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) {
2461
+ const prompt = await renderPrompt(paths, "pr_failure.md", {
2462
+ doctor_output: mergeResult?.output || existingPrState.output
2463
+ });
2464
+ await writePromptArtifact(paths, "pr_merge_failure.md", prompt);
711
2465
  return failSession(paths, {
712
- code: "worktree_remove_failed",
713
- message: result.output || "Failed to remove worktree.",
714
- repairCommand: `git worktree remove ${paths.worktree}`
2466
+ code: "pr_merge_failed",
2467
+ message: mergeResult?.output || existingPrState.output || "Failed to merge PR.",
2468
+ repairCommand: `gh pr merge ${prUrl} --merge --delete-branch`,
2469
+ prompt
2470
+ });
2471
+ }
2472
+ const issueUrl = await readTrimmedFile(path.join(paths.sessionRoot, "issue_url"));
2473
+ if (issueUrl) {
2474
+ await runLoggedCommand(paths, "github_issue_close", "gh", ["issue", "close", issueUrl, "--comment", `Merged PR ${prUrl}.`], {
2475
+ cwd: paths.targetRoot,
2476
+ timeout: 1000 * 60
715
2477
  });
716
2478
  }
2479
+ await writePrOutcome(paths, {
2480
+ baseBranch,
2481
+ issueUrl,
2482
+ outcome: "merged",
2483
+ prUrl
2484
+ });
2485
+ await writeTextFile(mergeMarkerPath, `${prUrl}\n`);
2486
+ }
2487
+ const updateFailure = await updateLocalBaseBranch(paths, baseBranch);
2488
+ if (updateFailure) {
2489
+ return updateFailure;
717
2490
  }
718
- await writeReceipt(paths, "worktree_removed", `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}.`);
719
2496
  await markStatus(paths, SESSION_STATUS.RUNNING);
720
2497
  return buildSessionResponse(paths);
721
2498
  }
@@ -724,20 +2501,24 @@ async function finishSession(paths) {
724
2501
  const prUrl = await readTrimmedFile(path.join(paths.sessionRoot, "pr_url"));
725
2502
  const issueUrl = await readTrimmedFile(path.join(paths.sessionRoot, "issue_url"));
726
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")));
727
2505
  const prompt = await renderPrompt(paths, "final_comment.md", {
728
2506
  codex_thread_id: codexThreadId,
729
2507
  issue_url: issueUrl,
2508
+ pr_outcome: prOutcome?.outcome || "unknown",
2509
+ pr_outcome_reason: prOutcome?.reason || "",
730
2510
  pr_url: prUrl,
2511
+ session_id: paths.sessionId,
731
2512
  transcript_log: path.join(paths.completedSessionRoot, "transcript.log")
732
2513
  });
733
2514
  await writeTextFile(path.join(paths.sessionRoot, "final_comment.md"), prompt);
734
2515
  if (issueUrl) {
735
- 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")], {
736
2517
  cwd: paths.targetRoot,
737
2518
  timeout: 1000 * 60
738
2519
  });
739
2520
  }
740
- 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"}.`);
741
2522
  await markStatus(paths, SESSION_STATUS.FINISHED);
742
2523
  await markCurrentStep(paths, "");
743
2524
  const archivedPaths = await archiveSession(paths, "completed");
@@ -748,37 +2529,54 @@ async function finishSession(paths) {
748
2529
 
749
2530
  const STEP_RUNNERS = Object.freeze({
750
2531
  worktree_created: createWorktree,
2532
+ dependencies_installed: installDependencies,
751
2533
  issue_prompt_rendered: renderIssuePrompt,
752
2534
  issue_drafted: draftIssue,
753
2535
  issue_created: createIssue,
754
- implementation_prompt_rendered: renderImplementationPrompt,
755
- implementation_changes_detected: detectChanges,
756
- implementation_changes_committed: commitImplementation,
757
- initial_review_prompt_rendered: (paths) => renderReviewPrompt(paths, 1),
758
- initial_review_changes_detected: (paths) => detectAndCommitReviewChanges(paths, 1),
759
- initial_user_check_completed: (paths, options) => userCheck(paths, 1, options),
760
- followup_review_prompt_rendered: (paths) => renderReviewPrompt(paths, 2),
761
- followup_review_changes_detected: (paths) => detectAndCommitReviewChanges(paths, 2),
762
- followup_user_check_completed: (paths, options) => userCheck(paths, 2, options),
763
- final_review_prompt_rendered: (paths) => renderReviewPrompt(paths, 3),
764
- final_review_changes_detected: (paths) => detectAndCommitReviewChanges(paths, 3),
765
- final_user_check_completed: (paths, options) => userCheck(paths, 3, options),
2536
+ issue_details_gathered: saveIssueDetails,
2537
+ plan_made: makePlan,
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),
2548
+ review_prompt_rendered: renderReviewPrompt,
2549
+ review_changes_accepted: acceptReviewChanges,
2550
+ user_check_completed: userCheck,
2551
+ changes_committed: commitAcceptedChanges,
2552
+ blueprint_updated: updateBlueprint,
766
2553
  doctor_run: runDoctor,
767
- branch_pushed: pushBranch,
2554
+ final_report_created: createFinalReport,
768
2555
  pr_created: createPr,
769
- pr_merged: mergePr,
770
- worktree_removed: removeWorktree,
2556
+ pr_finalized: finalizePr,
771
2557
  session_finished: finishSession
772
2558
  });
773
2559
 
774
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,
775
2568
  git_current_branch: (paths) => assertGitCurrentBranch(paths.targetRoot),
776
2569
  git_repository: (paths) => assertGitRepository(paths.targetRoot),
777
2570
  github_auth: (paths) => assertGhAuth(paths.targetRoot),
778
2571
  github_origin: (paths) => assertGithubOrigin(paths.targetRoot),
779
- issue_artifacts: assertIssueArtifacts,
2572
+ issue_metadata_exists: assertIssueMetadataExists,
780
2573
  issue_text_exists: assertIssueTextExists,
2574
+ issue_url_exists: assertIssueUrlExists,
2575
+ automated_checks_passed: assertAutomatedChecksPassed,
2576
+ issue_details_exists: assertIssueDetailsExists,
2577
+ plan_text_exists: assertPlanTextExists,
781
2578
  pr_url_exists: assertPrUrlExists,
2579
+ ready_jskit_app: assertReadyJskitApp,
782
2580
  session_exists: assertSessionExists,
783
2581
  worktree_exists: assertWorktreeExists
784
2582
  });
@@ -805,6 +2603,18 @@ async function runSessionStep({
805
2603
  status: artifacts.status
806
2604
  });
807
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
+ }
808
2618
  const nextStep = artifacts.nextStep;
809
2619
  if (!nextStep) {
810
2620
  return finishSession(paths);
@@ -831,6 +2641,7 @@ async function runSessionStep({
831
2641
  preconditions: stepPreconditions.preconditions
832
2642
  });
833
2643
  }
2644
+ await appendAgentDecisionsInput(paths, options);
834
2645
  return runner(paths, options, {
835
2646
  preconditions: stepPreconditions.preconditions
836
2647
  });
@@ -850,13 +2661,22 @@ async function abandonSession({
850
2661
  }
851
2662
  const issueUrl = await readTrimmedFile(path.join(paths.sessionRoot, "issue_url"));
852
2663
  if (issueUrl) {
853
- 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}.`], {
854
2665
  cwd: paths.targetRoot,
855
2666
  timeout: 1000 * 60
856
2667
  });
2668
+ if (!closeIssueResult.ok) {
2669
+ return failSession(paths, {
2670
+ code: "issue_close_failed",
2671
+ message: closeIssueResult.output || "Failed to close GitHub issue for abandoned session.",
2672
+ repairCommand: `gh issue close ${issueUrl}`,
2673
+ status: SESSION_STATUS.FAILED
2674
+ });
2675
+ }
857
2676
  }
858
2677
  if (await hasWorktree(paths)) {
859
- 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,
860
2680
  timeout: 1000 * 60
861
2681
  });
862
2682
  }
@@ -911,17 +2731,23 @@ export {
911
2731
  STEP_IDS,
912
2732
  STEP_PRECONDITION_NAMES,
913
2733
  abandonSession,
2734
+ adoptDependenciesInstalled,
914
2735
  adoptCodexThreadId,
915
2736
  buildSessionResponse,
916
2737
  buildSessionErrorResponse,
917
2738
  createSession,
918
2739
  createSessionId,
2740
+ extractIssueTitle,
919
2741
  extractIssueText,
2742
+ extractIssueDetails,
2743
+ extractPlanText,
920
2744
  inspectSession,
2745
+ inspectSessionDiff,
921
2746
  inspectSessionDetails,
922
2747
  isValidSessionId,
923
2748
  listSessions,
924
2749
  renderTemplate,
2750
+ recordDependenciesInstalled,
925
2751
  resolveSessionPaths,
926
2752
  runSessionStep
927
2753
  };