@jskit-ai/jskit-cli 0.2.81 → 0.2.83

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