@jskit-ai/jskit-cli 0.2.92 → 0.2.97

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. package/package.json +4 -4
  2. package/src/server/appBlueprint.js +37 -14
  3. package/src/server/core/argParser.js +0 -12
  4. package/src/server/core/commandCatalog.js +2 -92
  5. package/src/server/core/createCommandHandlers.js +0 -3
  6. package/src/server/helperMap.js +214 -56
  7. package/src/server/index.js +0 -1
  8. package/src/server/{sessionRuntime/prompts → prompts}/app_blueprint.md +1 -1
  9. package/src/server/commandHandlers/session.js +0 -471
  10. package/src/server/sessionRuntime/appReadiness.js +0 -55
  11. package/src/server/sessionRuntime/constants.js +0 -377
  12. package/src/server/sessionRuntime/io.js +0 -97
  13. package/src/server/sessionRuntime/paths.js +0 -163
  14. package/src/server/sessionRuntime/preconditions.js +0 -663
  15. package/src/server/sessionRuntime/promptRenderer.js +0 -41
  16. package/src/server/sessionRuntime/prompts/automated_checks_run.md +0 -28
  17. package/src/server/sessionRuntime/prompts/blueprint_updated.md +0 -29
  18. package/src/server/sessionRuntime/prompts/deep_ui_check_run.md +0 -40
  19. package/src/server/sessionRuntime/prompts/final_comment.md +0 -10
  20. package/src/server/sessionRuntime/prompts/final_report_created.md +0 -44
  21. package/src/server/sessionRuntime/prompts/issue_created.md +0 -26
  22. package/src/server/sessionRuntime/prompts/issue_prompt_rendered.md +0 -1
  23. package/src/server/sessionRuntime/prompts/make_plan.md +0 -57
  24. package/src/server/sessionRuntime/prompts/plan_executed.md +0 -39
  25. package/src/server/sessionRuntime/prompts/pr_failure.md +0 -28
  26. package/src/server/sessionRuntime/prompts/pr_merge_prepared.md +0 -22
  27. package/src/server/sessionRuntime/prompts/review_changes_accepted_resolve.md +0 -12
  28. package/src/server/sessionRuntime/prompts/review_prompt_rendered.md +0 -61
  29. package/src/server/sessionRuntime/prompts/user_check_completed.md +0 -17
  30. package/src/server/sessionRuntime/responses.js +0 -1481
  31. package/src/server/sessionRuntime/worktrees.js +0 -31
  32. package/src/server/sessionRuntime.js +0 -3659
@@ -1,3659 +0,0 @@
1
- import {
2
- createHash
3
- } from "node:crypto";
4
- import {
5
- appendFile,
6
- mkdir,
7
- readFile,
8
- readdir,
9
- rm,
10
- rmdir
11
- } from "node:fs/promises";
12
- import path from "node:path";
13
- import {
14
- BLUEPRINT_CODEX_HANDOFF,
15
- AUTOMATED_CHECK_REPAIR_CODEX_HANDOFF,
16
- DEPENDENCIES_INSTALL_RESULT_FILE,
17
- DEEP_UI_CHECK_CODEX_HANDOFF,
18
- ISSUE_DEFINITION_CODEX_HANDOFF,
19
- ISSUE_FILE_CODEX_HANDOFF,
20
- PLAN_CODEX_HANDOFF,
21
- PLAN_EXECUTION_CODEX_HANDOFF,
22
- PR_FILE_CODEX_HANDOFF,
23
- PR_MERGE_PREP_CODEX_HANDOFF,
24
- REVIEW_PASS_LIMIT,
25
- REVIEW_EXECUTION_CODEX_HANDOFF,
26
- RESOLVE_DESLOP_CODEX_HANDOFF,
27
- SESSION_STATUS,
28
- SESSION_WORKFLOW_VERSION,
29
- STEP_DEFINITION_BY_ID,
30
- STEP_DEFINITIONS,
31
- STEP_IDS,
32
- STEP_PRECONDITION_NAMES
33
- } from "./sessionRuntime/constants.js";
34
- import {
35
- fileExists,
36
- normalizeText,
37
- readTextIfExists,
38
- readTrimmedFile,
39
- runCommand,
40
- runGit,
41
- runGitInWorktree,
42
- timestampForStepRecord,
43
- writeTextFile
44
- } from "./sessionRuntime/io.js";
45
- import {
46
- archiveSession,
47
- createAvailableSessionId,
48
- createSessionId,
49
- isValidSessionId,
50
- resolveExistingSessionRoot,
51
- resolveSessionPaths,
52
- pathsForExistingSession
53
- } from "./sessionRuntime/paths.js";
54
- import {
55
- buildSessionErrorResponse,
56
- buildSessionResponse,
57
- buildStepDefinitions,
58
- createError,
59
- failSession,
60
- markCurrentStep,
61
- markStatus,
62
- normalizeReviewPassNumber,
63
- readActiveCycle,
64
- readStepRecords,
65
- readReviewPasses,
66
- readSessionArtifacts,
67
- reviewPassRoot,
68
- writeStepRecord
69
- } from "./sessionRuntime/responses.js";
70
- import {
71
- applyPreconditions,
72
- assertAcceptedChangesCommitted,
73
- assertActiveCycleExists,
74
- assertActiveCycleUserCheckPassed,
75
- assertBlueprintUpdateSatisfied,
76
- assertDeepUiCheckSatisfied,
77
- assertDependenciesInstalled,
78
- assertGhAuth,
79
- assertGitCurrentBranch,
80
- assertGitRepository,
81
- assertGithubOrigin,
82
- assertIssueTextExists,
83
- assertIssueUrlExists,
84
- assertAutomatedChecksPassed,
85
- assertMainCheckoutSyncSatisfied,
86
- assertPrUrlExists,
87
- assertPullRequestFileExists,
88
- assertReadyJskitApp,
89
- assertSessionExists,
90
- assertTargetRootWritable,
91
- assertUserCheckPassed,
92
- assertWorktreeExists,
93
- ensureStudioGitExclude,
94
- hasWorktree
95
- } from "./sessionRuntime/preconditions.js";
96
- import {
97
- renderPrompt,
98
- renderTemplate
99
- } from "./sessionRuntime/promptRenderer.js";
100
- import {
101
- HELPER_MAP_JSON_RELATIVE_PATH,
102
- HELPER_MAP_MARKDOWN_RELATIVE_PATH
103
- } from "./helperMapPaths.js";
104
-
105
- const SESSION_PROVISION_PACKAGE_SCRIPT = "jskit:provision-session";
106
- const SESSION_FINALIZATION_GUARD_PACKAGE_SCRIPT = "jskit:finalization-guard";
107
-
108
- function invalidSessionIdError(sessionId = "") {
109
- return createError({
110
- code: "invalid_session_id",
111
- message: `Invalid session id "${sessionId}". Expected YYYY-MM-DD_HH-MM-SS.`
112
- });
113
- }
114
-
115
- function invalidSessionIdResponse({
116
- targetRoot,
117
- sessionId
118
- }) {
119
- return buildSessionErrorResponse({
120
- targetRoot,
121
- sessionId,
122
- errors: [invalidSessionIdError(sessionId)]
123
- });
124
- }
125
-
126
- async function existingSessionContext({
127
- targetRoot = process.cwd(),
128
- sessionId
129
- } = {}) {
130
- if (!isValidSessionId(sessionId)) {
131
- return {
132
- ok: false,
133
- response: invalidSessionIdResponse({ targetRoot, sessionId })
134
- };
135
- }
136
-
137
- const paths = await pathsForExistingSession(resolveSessionPaths({ targetRoot, sessionId }));
138
- const preconditions = await applyPreconditions(paths, [
139
- () => assertSessionExists(paths)
140
- ]);
141
- if (!preconditions.ok) {
142
- return {
143
- ok: false,
144
- response: await failSession(paths, {
145
- ...preconditions.error,
146
- preconditions: preconditions.preconditions
147
- })
148
- };
149
- }
150
-
151
- return {
152
- ok: true,
153
- paths,
154
- preconditions: preconditions.preconditions
155
- };
156
- }
157
-
158
- async function withExistingSession(input, handler) {
159
- const context = await existingSessionContext(input);
160
- if (!context.ok) {
161
- return context.response;
162
- }
163
- return handler(context.paths, {
164
- preconditions: context.preconditions
165
- });
166
- }
167
-
168
- function extractMarkedText(value = "", marker = "") {
169
- const text = normalizeText(value);
170
- const normalizedMarker = normalizeText(marker);
171
- if (!normalizedMarker) {
172
- return "";
173
- }
174
- const pattern = new RegExp(`\\[${normalizedMarker}\\]([\\s\\S]*?)\\[/${normalizedMarker}\\]`, "gu");
175
- const matches = [...text.matchAll(pattern)];
176
- return normalizeText(matches.length > 0 ? matches[matches.length - 1][1] : "");
177
- }
178
-
179
- function extractIssueTitle(value = "") {
180
- return extractMarkedText(value, "issue_title");
181
- }
182
-
183
- function extractIssueText(value = "") {
184
- return extractMarkedText(value, "issue_text") || normalizeText(value);
185
- }
186
-
187
- async function writePromptArtifact(paths, fileName, prompt) {
188
- await writeTextFile(path.join(paths.sessionRoot, "prompts", fileName), prompt);
189
- }
190
-
191
- function commandText(command, args = []) {
192
- return [command, ...args].map((part) => {
193
- const value = String(part || "");
194
- return /^[A-Za-z0-9_./:=@,+-]+$/u.test(value)
195
- ? value
196
- : `'${value.replaceAll("'", "'\\''")}'`;
197
- }).join(" ");
198
- }
199
-
200
- function cycleRootPath(paths, cycle) {
201
- return path.join(paths.sessionRoot, "cycles", `cycle_${cycle}`);
202
- }
203
-
204
- function commandOutputSummary(output = "") {
205
- const normalized = normalizeText(output);
206
- if (normalized.length <= 1800) {
207
- return normalized;
208
- }
209
- return normalized.slice(-1800);
210
- }
211
-
212
- async function appendCommandLog(paths, {
213
- args = [],
214
- command,
215
- cwd = "",
216
- kind = "command",
217
- result
218
- } = {}) {
219
- if (!paths?.sessionRoot || !command || !result) {
220
- return;
221
- }
222
- const entry = {
223
- at: timestampForStepRecord(),
224
- command: commandText(command, args),
225
- cwd,
226
- exitCode: Number.isInteger(result.exitCode) ? result.exitCode : null,
227
- kind,
228
- ok: result.ok === true,
229
- outputSummary: commandOutputSummary(result.output)
230
- };
231
- await appendFile(path.join(paths.sessionRoot, "command_log.jsonl"), `${JSON.stringify(entry)}\n`, "utf8");
232
- }
233
-
234
- async function runLoggedCommand(paths, kind, command, args = [], options = {}) {
235
- const result = await runCommand(command, args, options);
236
- await appendCommandLog(paths, {
237
- args,
238
- command,
239
- cwd: options.cwd || "",
240
- kind,
241
- result
242
- });
243
- return result;
244
- }
245
-
246
- async function readWorktreePackageJson(worktree) {
247
- const source = await readTextIfExists(path.join(worktree, "package.json"));
248
- if (!source) {
249
- return {};
250
- }
251
- try {
252
- const parsed = JSON.parse(source);
253
- return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
254
- } catch {
255
- return {};
256
- }
257
- }
258
-
259
- function packageScriptRunArgs(packageManager, scriptName) {
260
- if (packageManager === "pnpm") {
261
- return ["pnpm", ["run", scriptName]];
262
- }
263
- if (packageManager === "yarn") {
264
- return ["yarn", ["run", scriptName]];
265
- }
266
- if (packageManager === "bun") {
267
- return ["bun", ["run", scriptName]];
268
- }
269
- return ["npm", ["run", "--silent", scriptName]];
270
- }
271
-
272
- async function packageScriptCommandForWorktree(worktree, scriptName, {
273
- preferredPackageManager = ""
274
- } = {}) {
275
- const packageJson = await readWorktreePackageJson(worktree);
276
- const script = packageJson?.scripts?.[scriptName];
277
- if (typeof script !== "string" || !normalizeText(script)) {
278
- return null;
279
- }
280
- const packageManager = preferredPackageManager || (await dependencyInstallCommandForWorktree(worktree))[0];
281
- const [command, args] = packageScriptRunArgs(packageManager, scriptName);
282
- return {
283
- args,
284
- command
285
- };
286
- }
287
-
288
- function sessionPackageScriptEnv(paths, scriptName) {
289
- return {
290
- JSKIT_SESSION_ID: paths.sessionId,
291
- JSKIT_SESSION_PACKAGE_SCRIPT: scriptName,
292
- JSKIT_SESSION_ROOT: paths.sessionRoot,
293
- JSKIT_TARGET_ROOT: paths.targetRoot,
294
- JSKIT_WORKTREE_ROOT: paths.worktree
295
- };
296
- }
297
-
298
- function packageScriptRepairCommand(paths, command, args) {
299
- return `cd ${paths.worktree} && ${command} ${args.join(" ")}`;
300
- }
301
-
302
- function packageScriptRecordName(scriptName) {
303
- return normalizeText(scriptName).replace(/[^a-zA-Z0-9._-]+/gu, "_");
304
- }
305
-
306
- async function writeSessionHookRecord(paths, scriptName, message) {
307
- await writeTextFile(
308
- path.join(paths.sessionRoot, "hooks", packageScriptRecordName(scriptName)),
309
- `${timestampForStepRecord()}\n${normalizeText(message) || `${scriptName} completed.`}`
310
- );
311
- }
312
-
313
- async function runOptionalSessionPackageScript(paths, {
314
- failureCode,
315
- failureMessage,
316
- kind,
317
- preferredPackageManager = "",
318
- preconditions = [],
319
- scriptName,
320
- timeout = 1000 * 60 * 10
321
- } = {}) {
322
- const scriptCommand = await packageScriptCommandForWorktree(paths.worktree, scriptName, {
323
- preferredPackageManager
324
- });
325
- if (!scriptCommand) {
326
- return {
327
- ok: true,
328
- ran: false
329
- };
330
- }
331
- const result = await runLoggedCommand(paths, kind, scriptCommand.command, scriptCommand.args, {
332
- cwd: paths.worktree,
333
- env: sessionPackageScriptEnv(paths, scriptName),
334
- timeout
335
- });
336
- if (!result.ok) {
337
- return {
338
- ok: false,
339
- response: await failSession(paths, {
340
- code: failureCode,
341
- message: result.output || failureMessage,
342
- preconditions,
343
- repairCommand: packageScriptRepairCommand(paths, scriptCommand.command, scriptCommand.args)
344
- })
345
- };
346
- }
347
- await writeSessionHookRecord(paths, scriptName, result.output || `${scriptName} completed.`);
348
- return {
349
- ok: true,
350
- ran: true,
351
- result
352
- };
353
- }
354
-
355
- async function runSessionFinalizationGuard(paths, preconditions = []) {
356
- return runOptionalSessionPackageScript(paths, {
357
- failureCode: "session_finalization_guard_failed",
358
- failureMessage: `${SESSION_FINALIZATION_GUARD_PACKAGE_SCRIPT} failed in the session worktree.`,
359
- kind: "session_finalization_guard",
360
- preconditions,
361
- scriptName: SESSION_FINALIZATION_GUARD_PACKAGE_SCRIPT
362
- });
363
- }
364
-
365
- async function runSessionProvisioningHook(paths, {
366
- preferredPackageManager = "",
367
- preconditions = []
368
- } = {}) {
369
- return runOptionalSessionPackageScript(paths, {
370
- failureCode: "session_provision_failed",
371
- failureMessage: `${SESSION_PROVISION_PACKAGE_SCRIPT} failed in the session worktree.`,
372
- kind: "session_provision",
373
- preferredPackageManager,
374
- preconditions,
375
- scriptName: SESSION_PROVISION_PACKAGE_SCRIPT
376
- });
377
- }
378
-
379
- async function readGithubComments(paths) {
380
- const source = await readTextIfExists(path.join(paths.sessionRoot, "github_comments.json"));
381
- if (!source) {
382
- return {};
383
- }
384
- const parsed = parseJsonObject(source);
385
- return parsed || {};
386
- }
387
-
388
- async function writeGithubComments(paths, comments = {}) {
389
- await writeTextFile(path.join(paths.sessionRoot, "github_comments.json"), `${JSON.stringify(comments, null, 2)}\n`);
390
- }
391
-
392
- async function commentOnIssueOnce(paths, {
393
- bodyFile,
394
- issueUrl,
395
- purpose
396
- }) {
397
- const normalizedPurpose = normalizeText(purpose);
398
- if (!issueUrl || !normalizedPurpose) {
399
- return {
400
- ok: true,
401
- skipped: true
402
- };
403
- }
404
- const comments = await readGithubComments(paths);
405
- if (comments[normalizedPurpose]) {
406
- return {
407
- ok: true,
408
- skipped: true
409
- };
410
- }
411
- const result = await runLoggedCommand(paths, "github_issue_comment", "gh", ["issue", "comment", issueUrl, "--body-file", bodyFile], {
412
- cwd: paths.targetRoot,
413
- timeout: 1000 * 60
414
- });
415
- if (!result.ok) {
416
- return {
417
- ok: false,
418
- output: result.output
419
- };
420
- }
421
- comments[normalizedPurpose] = {
422
- bodyFile,
423
- commentedAt: timestampForStepRecord(),
424
- issueUrl,
425
- purpose: normalizedPurpose
426
- };
427
- await writeGithubComments(paths, comments);
428
- return {
429
- ok: true,
430
- skipped: false
431
- };
432
- }
433
-
434
- function issueMetadataFromUrl(issueUrl = "") {
435
- const match = /^https:\/\/github\.com\/([^/]+)\/([^/]+)\/issues\/(\d+)(?:\b|$)/u.exec(String(issueUrl || "").trim());
436
- if (!match) {
437
- return {
438
- issueNumber: "",
439
- issueUrl: normalizeText(issueUrl),
440
- owner: "",
441
- repository: ""
442
- };
443
- }
444
- return {
445
- issueNumber: match[3],
446
- issueUrl: normalizeText(issueUrl),
447
- owner: match[1],
448
- repository: match[2]
449
- };
450
- }
451
-
452
- async function writeIssueMetadataFiles(paths, {
453
- issueTitle = "",
454
- issueUrl = ""
455
- } = {}) {
456
- const issueMetadata = issueMetadataFromUrl(issueUrl);
457
- const metadataValues = {
458
- issue_body_path: path.join(paths.sessionRoot, "issue.md"),
459
- issue_number: issueMetadata.issueNumber,
460
- issue_owner: issueMetadata.owner,
461
- issue_repository: issueMetadata.repository,
462
- issue_title: normalizeText(issueTitle),
463
- issue_url: issueMetadata.issueUrl
464
- };
465
- await Promise.all(
466
- Object.entries(metadataValues)
467
- .filter(([, value]) => normalizeText(value))
468
- .map(([name, value]) => writeTextFile(path.join(paths.sessionRoot, "metadata", name), value))
469
- );
470
- }
471
-
472
- async function createSession({
473
- targetRoot = process.cwd(),
474
- sessionId = "",
475
- now = new Date()
476
- } = {}) {
477
- if (sessionId && !isValidSessionId(sessionId)) {
478
- return invalidSessionIdResponse({ targetRoot, sessionId });
479
- }
480
- const initialPaths = resolveSessionPaths({
481
- targetRoot,
482
- sessionId: sessionId || await createAvailableSessionId(targetRoot, now)
483
- });
484
- const existingSession = await resolveExistingSessionRoot(initialPaths);
485
- if (existingSession.root) {
486
- return failSession(initialPaths, {
487
- code: "session_exists",
488
- message: `Session already exists: ${initialPaths.sessionId}`,
489
- status: SESSION_STATUS.BLOCKED
490
- });
491
- }
492
-
493
- const preconditions = await applyPreconditions(initialPaths, [
494
- () => assertTargetRootWritable(initialPaths.targetRoot),
495
- () => assertGitRepository(initialPaths.targetRoot)
496
- ]);
497
- if (!preconditions.ok) {
498
- return failSession(initialPaths, {
499
- ...preconditions.error,
500
- preconditions: preconditions.preconditions
501
- });
502
- }
503
-
504
- await ensureStudioGitExclude(initialPaths.targetRoot);
505
- await mkdir(initialPaths.sessionRoot, { recursive: true });
506
- await writeTextFile(path.join(initialPaths.sessionRoot, "transcript.log"), "");
507
- await writeTextFile(path.join(initialPaths.sessionRoot, "workflow_version"), `${SESSION_WORKFLOW_VERSION}\n`);
508
- await markStatus(initialPaths, SESSION_STATUS.PENDING);
509
- await markCurrentStep(initialPaths, "worktree_created");
510
-
511
- return buildSessionResponse(initialPaths, {
512
- ok: true,
513
- preconditions: preconditions.preconditions
514
- });
515
- }
516
-
517
- const SESSION_ARCHIVE_ROOTS = Object.freeze([
518
- "active",
519
- "completed",
520
- "abandoned"
521
- ]);
522
-
523
- function normalizeArchiveFilter(archive = "active") {
524
- const requestedArchives = Array.isArray(archive) ? archive : [archive];
525
- const normalized = requestedArchives
526
- .map((entry) => String(entry || "").trim().toLowerCase())
527
- .filter(Boolean);
528
- if (normalized.includes("all")) {
529
- return SESSION_ARCHIVE_ROOTS;
530
- }
531
- const allowed = new Set(SESSION_ARCHIVE_ROOTS);
532
- const selected = normalized.filter((entry) => allowed.has(entry));
533
- return selected.length > 0 ? [...new Set(selected)] : ["active"];
534
- }
535
-
536
- async function listSessions({ targetRoot = process.cwd(), archive = "active" } = {}) {
537
- const paths = resolveSessionPaths({ targetRoot });
538
- const sessions = [];
539
- const rootsByArchive = {
540
- abandoned: paths.abandonedSessionsRoot,
541
- active: paths.sessionsRoot,
542
- completed: paths.completedSessionsRoot
543
- };
544
- const selectedArchives = normalizeArchiveFilter(archive);
545
- const roots = selectedArchives.map((archiveName) => ({
546
- archive: archiveName,
547
- root: rootsByArchive[archiveName]
548
- }));
549
-
550
- for (const rootInfo of roots) {
551
- let entries = [];
552
- try {
553
- entries = await readdir(rootInfo.root, { withFileTypes: true });
554
- } catch {
555
- entries = [];
556
- }
557
-
558
- for (const entry of entries) {
559
- if (!entry.isDirectory() || !isValidSessionId(entry.name)) {
560
- continue;
561
- }
562
- const sessionPaths = resolveSessionPaths({
563
- targetRoot,
564
- sessionId: entry.name
565
- });
566
- const response = await buildSessionResponse({
567
- ...sessionPaths,
568
- archive: rootInfo.archive,
569
- sessionRoot: path.join(rootInfo.root, entry.name)
570
- });
571
- sessions.push(response);
572
- }
573
- }
574
- sessions.sort((left, right) => right.sessionId.localeCompare(left.sessionId));
575
- return {
576
- archive: selectedArchives.length === 1 ? selectedArchives[0] : "mixed",
577
- archives: selectedArchives,
578
- ok: true,
579
- stepDefinitions: buildStepDefinitions(),
580
- sessions
581
- };
582
- }
583
-
584
- async function inspectSession({
585
- targetRoot = process.cwd(),
586
- sessionId
587
- } = {}) {
588
- return withExistingSession({ targetRoot, sessionId }, (paths, context) => {
589
- return buildSessionResponse(paths, {
590
- preconditions: context.preconditions
591
- });
592
- });
593
- }
594
-
595
- function emptySessionDetails(response) {
596
- return {
597
- ...response,
598
- issueTitle: "",
599
- issueText: "",
600
- stepRecords: [],
601
- transcriptLog: ""
602
- };
603
- }
604
-
605
- async function inspectSessionDetails({
606
- targetRoot = process.cwd(),
607
- sessionId
608
- } = {}) {
609
- const context = await existingSessionContext({ targetRoot, sessionId });
610
- if (!context.ok) {
611
- return emptySessionDetails(context.response);
612
- }
613
- const { paths, preconditions } = context;
614
- const response = await buildSessionResponse(paths, { preconditions });
615
- const [issueText, issueTitle, stepRecords, transcriptLog] = await Promise.all([
616
- readTextIfExists(path.join(paths.sessionRoot, "issue.md")),
617
- readTrimmedFile(path.join(paths.sessionRoot, "issue_title")),
618
- readStepRecords(paths),
619
- readTextIfExists(path.join(paths.sessionRoot, "transcript.log"))
620
- ]);
621
-
622
- return {
623
- ...response,
624
- issueTitle,
625
- issueText: issueText.trim(),
626
- stepRecords,
627
- transcriptLog
628
- };
629
- }
630
-
631
- async function removeEmptyStaleWorktreeDirectory(paths) {
632
- try {
633
- const entries = await readdir(paths.worktree);
634
- if (entries.length > 0) {
635
- return {
636
- ok: false,
637
- message: `Worktree path exists but is not a registered Git worktree: ${paths.worktree}`
638
- };
639
- }
640
- await rmdir(paths.worktree);
641
- return {
642
- ok: true
643
- };
644
- } catch (error) {
645
- if (error?.code === "ENOENT") {
646
- return {
647
- ok: true
648
- };
649
- }
650
- return {
651
- ok: false,
652
- message: `Cannot prepare worktree path ${paths.worktree}: ${error?.message || error}`
653
- };
654
- }
655
- }
656
-
657
- async function createWorktree(paths, _options = {}, context = {}) {
658
- const preconditions = context.preconditions || [];
659
- const completeStep = context.completeStep !== false;
660
- const [baseBranchResult, baseCommitResult] = await Promise.all([
661
- runGit(paths.targetRoot, ["branch", "--show-current"], { timeout: 15000 }),
662
- runGit(paths.targetRoot, ["rev-parse", "--verify", "HEAD"], { timeout: 15000 })
663
- ]);
664
- const baseBranch = normalizeText(baseBranchResult.stdout);
665
- const baseCommit = normalizeText(baseCommitResult.stdout);
666
- if (await hasWorktree(paths)) {
667
- if (baseBranch && !await readTrimmedFile(path.join(paths.sessionRoot, "base_branch"))) {
668
- await writeTextFile(path.join(paths.sessionRoot, "base_branch"), `${baseBranch}\n`);
669
- }
670
- if (baseCommit && !await readTrimmedFile(path.join(paths.sessionRoot, "base_commit"))) {
671
- await writeTextFile(path.join(paths.sessionRoot, "base_commit"), `${baseCommit}\n`);
672
- }
673
- if (completeStep) {
674
- await writeStepRecord(paths, "worktree_created", `Reused existing worktree ${paths.worktree}.`);
675
- }
676
- await markStatus(paths, SESSION_STATUS.RUNNING);
677
- return buildSessionResponse(paths, {
678
- preconditions
679
- });
680
- }
681
-
682
- await mkdir(path.dirname(paths.worktree), { recursive: true });
683
- const staleWorktree = await removeEmptyStaleWorktreeDirectory(paths);
684
- if (!staleWorktree.ok) {
685
- return failSession(paths, {
686
- code: "worktree_path_blocked",
687
- message: staleWorktree.message,
688
- repairCommand: `ls -la ${paths.worktree}`,
689
- preconditions
690
- });
691
- }
692
- const result = await runLoggedCommand(paths, "git_worktree_add", "git", ["worktree", "add", "-b", paths.branch, paths.worktree, "HEAD"], {
693
- cwd: paths.targetRoot,
694
- timeout: 30000
695
- });
696
- if (!result.ok) {
697
- return failSession(paths, {
698
- code: "worktree_create_failed",
699
- message: result.output || `Failed to create worktree ${paths.worktree}.`,
700
- repairCommand: `git worktree add -b ${paths.branch} ${paths.worktree} HEAD`,
701
- preconditions
702
- });
703
- }
704
- if (baseBranch) {
705
- await writeTextFile(path.join(paths.sessionRoot, "base_branch"), `${baseBranch}\n`);
706
- }
707
- if (baseCommit) {
708
- await writeTextFile(path.join(paths.sessionRoot, "base_commit"), `${baseCommit}\n`);
709
- }
710
- if (completeStep) {
711
- await writeStepRecord(paths, "worktree_created", `Created worktree ${paths.worktree} on branch ${paths.branch}.`);
712
- }
713
- await markStatus(paths, SESSION_STATUS.RUNNING);
714
- return buildSessionResponse(paths, {
715
- preconditions
716
- });
717
- }
718
-
719
- async function recordDependenciesInstalled(paths, {
720
- message = "Installed Node dependencies in the session worktree.",
721
- preconditions = []
722
- } = {}) {
723
- await writeStepRecord(paths, "dependencies_installed", message);
724
- await markStatus(paths, SESSION_STATUS.RUNNING);
725
- return buildSessionResponse(paths, {
726
- preconditions
727
- });
728
- }
729
-
730
- async function recordDependencyInstallResult(paths, {
731
- message = "Installed Node dependencies in the session worktree.",
732
- preconditions = []
733
- } = {}) {
734
- await writeTextFile(
735
- path.join(paths.sessionRoot, DEPENDENCIES_INSTALL_RESULT_FILE),
736
- `${timestampForStepRecord()}\n${normalizeText(message) || "Installed Node dependencies in the session worktree."}`
737
- );
738
- await markStatus(paths, SESSION_STATUS.RUNNING);
739
- return buildSessionResponse(paths, {
740
- preconditions
741
- });
742
- }
743
-
744
- function parsePackageManager(value = "") {
745
- const normalized = normalizeText(value);
746
- const match = /^([a-z][a-z0-9-]*)(?:@(.+))?$/u.exec(normalized);
747
- if (!match) {
748
- return {
749
- name: "",
750
- version: ""
751
- };
752
- }
753
- return {
754
- name: match[1],
755
- version: match[2] || ""
756
- };
757
- }
758
-
759
- async function hasWorktreeFile(worktree, fileName) {
760
- return fileExists(path.join(worktree, fileName));
761
- }
762
-
763
- async function dependencyInstallCommandForWorktree(worktree) {
764
- const packageJsonSource = await readTextIfExists(path.join(worktree, "package.json"));
765
- let packageManager = {
766
- name: "",
767
- version: ""
768
- };
769
- if (packageJsonSource) {
770
- try {
771
- const packageJson = JSON.parse(packageJsonSource);
772
- packageManager = parsePackageManager(packageJson?.packageManager);
773
- } catch {
774
- packageManager = {
775
- name: "",
776
- version: ""
777
- };
778
- }
779
- }
780
- const hasPackageLock = await hasWorktreeFile(worktree, "package-lock.json") ||
781
- await hasWorktreeFile(worktree, "npm-shrinkwrap.json");
782
- const hasPnpmLock = await hasWorktreeFile(worktree, "pnpm-lock.yaml");
783
- const hasYarnLock = await hasWorktreeFile(worktree, "yarn.lock");
784
- const hasBunLock = await hasWorktreeFile(worktree, "bun.lock") ||
785
- await hasWorktreeFile(worktree, "bun.lockb");
786
-
787
- if (packageManager.name === "pnpm" || (!packageManager.name && hasPnpmLock)) {
788
- return ["pnpm", hasPnpmLock ? ["install", "--frozen-lockfile"] : ["install"]];
789
- }
790
- if (packageManager.name === "yarn" || (!packageManager.name && hasYarnLock)) {
791
- const major = Number.parseInt(packageManager.version.split(".")[0] || "1", 10);
792
- return ["yarn", hasYarnLock && major >= 2 ? ["install", "--immutable"] : hasYarnLock ? ["install", "--frozen-lockfile"] : ["install"]];
793
- }
794
- if (packageManager.name === "bun" || (!packageManager.name && hasBunLock)) {
795
- return ["bun", hasBunLock ? ["install", "--frozen-lockfile"] : ["install"]];
796
- }
797
- return ["npm", hasPackageLock ? ["ci"] : ["install"]];
798
- }
799
-
800
- async function installDependencies(paths, _options = {}, context = {}) {
801
- const preconditions = context.preconditions || [];
802
- const completeStep = context.completeStep !== false;
803
- const [command, args] = await dependencyInstallCommandForWorktree(paths.worktree);
804
- const result = await runLoggedCommand(paths, "dependencies_install", command, args, {
805
- cwd: paths.worktree,
806
- timeout: 1000 * 60 * 10
807
- });
808
- if (!result.ok) {
809
- return failSession(paths, {
810
- code: "dependencies_install_failed",
811
- message: result.output || `${command} ${args.join(" ")} failed in the session worktree.`,
812
- repairCommand: `cd ${paths.worktree} && ${command} ${args.join(" ")}`,
813
- preconditions
814
- });
815
- }
816
- const provisionResult = await runSessionProvisioningHook(paths, {
817
- preferredPackageManager: command,
818
- preconditions
819
- });
820
- if (!provisionResult.ok) {
821
- return provisionResult.response;
822
- }
823
- const installMessage = result.output || `Installed Node dependencies in the session worktree with ${command} ${args.join(" ")}.`;
824
- const recorder = completeStep ? recordDependenciesInstalled : recordDependencyInstallResult;
825
- return recorder(paths, {
826
- message: provisionResult.ran ? `${installMessage}\n${SESSION_PROVISION_PACKAGE_SCRIPT} completed.` : installMessage,
827
- preconditions
828
- });
829
- }
830
-
831
- async function adoptDependenciesInstalled({
832
- targetRoot = process.cwd(),
833
- sessionId,
834
- message = ""
835
- } = {}) {
836
- return withExistingSession({ targetRoot, sessionId }, async (paths, context = {}) => {
837
- const preconditions = context.preconditions || [];
838
- const artifacts = await readSessionArtifacts(paths);
839
- if (artifacts.nextStep !== "dependencies_installed") {
840
- return buildSessionResponse(paths, {
841
- ok: false,
842
- errors: [
843
- createError({
844
- code: "session_step_mismatch",
845
- message: `Cannot record dependencies for ${paths.sessionId}; current step is ${artifacts.nextStep || "complete"}.`
846
- })
847
- ],
848
- preconditions
849
- });
850
- }
851
- const provisionResult = await runSessionProvisioningHook(paths, {
852
- preconditions
853
- });
854
- if (!provisionResult.ok) {
855
- return provisionResult.response;
856
- }
857
- const hookMessage = provisionResult.ran
858
- ? `${normalizeText(message) || "Installed Node dependencies in the session worktree."}\n${SESSION_PROVISION_PACKAGE_SCRIPT} completed.`
859
- : message;
860
- return recordDependencyInstallResult(paths, {
861
- message: hookMessage,
862
- preconditions
863
- });
864
- });
865
- }
866
-
867
- const STUDIO_CONTEXT_START_MARKER = "[[JSKIT_STUDIO_CONTEXT_START]]";
868
- const STUDIO_CONTEXT_END_MARKER = "[[JSKIT_STUDIO_CONTEXT_END]]";
869
-
870
- function issueDefinitionPrompt(userInput, context) {
871
- return [
872
- userInput,
873
- "",
874
- STUDIO_CONTEXT_START_MARKER,
875
- "JSKIT Studio context marker: follow the instructions inside this context block normally, but ignore the surrounding JSKIT_STUDIO_CONTEXT markers.",
876
- "",
877
- context,
878
- STUDIO_CONTEXT_END_MARKER
879
- ].join("\n").trim();
880
- }
881
-
882
- async function renderIssuePrompt(paths, options = {}, context = {}) {
883
- const userInput = normalizeText(options.prompt);
884
- const issueDefinitionSentinelPath = path.join(paths.sessionRoot, "metadata", "issue_prompt_rendered_requested");
885
- if (!userInput && context.completeStep !== false && await fileExists(issueDefinitionSentinelPath)) {
886
- await writeStepRecord(paths, "issue_prompt_rendered", "Issue scoped in Codex terminal.");
887
- await markStatus(paths, SESSION_STATUS.RUNNING);
888
- return buildSessionResponse(paths);
889
- }
890
- if (!userInput) {
891
- return failSession(paths, {
892
- code: "prompt_required",
893
- message: "The issue prompt step requires --prompt.",
894
- repairCommand: `jskit session ${paths.sessionId} step --prompt "<what should change>"`
895
- });
896
- }
897
- const promptContext = await renderPrompt(paths, "issue_prompt_rendered.md");
898
- const prompt = issueDefinitionPrompt(userInput, promptContext);
899
- await writeTextFile(issueDefinitionSentinelPath, "true\n");
900
- await markStatus(paths, SESSION_STATUS.WAITING_FOR_USER);
901
- return buildSessionResponse(paths, {
902
- codex: ISSUE_DEFINITION_CODEX_HANDOFF,
903
- ok: true,
904
- prompt,
905
- status: SESSION_STATUS.WAITING_FOR_USER
906
- });
907
- }
908
-
909
- function titleFromIssue(issueText) {
910
- const firstMeaningfulLine = String(issueText || "")
911
- .split(/\r?\n/u)
912
- .map((line) => line.replace(/^#+\s*/u, "").trim())
913
- .find(Boolean);
914
- return (firstMeaningfulLine || "JSKIT Studio issue").slice(0, 120);
915
- }
916
-
917
- async function createIssue(paths, _options = {}, context = {}) {
918
- const preconditions = context.preconditions || [];
919
- const issueText = await readTrimmedFile(path.join(paths.sessionRoot, "issue.md"));
920
- if (!issueText) {
921
- return renderIssueFilePrompt(paths, context);
922
- }
923
- if (context.completeStep === false) {
924
- return renderIssueFilePrompt(paths, context);
925
- }
926
- await writeStepRecord(paths, "issue_created", "Issue files are ready for review and submission.");
927
- await markStatus(paths, SESSION_STATUS.RUNNING);
928
- return buildSessionResponse(paths, {
929
- preconditions
930
- });
931
- }
932
-
933
- async function submitIssue(paths, _options = {}, context = {}) {
934
- const preconditions = context.preconditions || [];
935
- const completeStep = context.completeStep !== false;
936
- const existingIssueUrl = await readTrimmedFile(path.join(paths.sessionRoot, "issue_url"));
937
- const issueText = await readTrimmedFile(path.join(paths.sessionRoot, "issue.md"));
938
- const issueTitle = await readTrimmedFile(path.join(paths.sessionRoot, "issue_title")) || titleFromIssue(issueText);
939
- if (!issueText) {
940
- return sessionStepError(paths, {
941
- code: "issue_file_missing",
942
- message: "Cannot create the GitHub issue until issue.md exists.",
943
- repairCommand: `jskit session ${paths.sessionId} create_issue_file`
944
- });
945
- }
946
- if (existingIssueUrl) {
947
- await writeIssueMetadataFiles(paths, {
948
- issueTitle,
949
- issueUrl: existingIssueUrl
950
- });
951
- if (completeStep) {
952
- await writeStepRecord(paths, "issue_submitted", `Reused GitHub issue ${existingIssueUrl}.`);
953
- }
954
- await markStatus(paths, SESSION_STATUS.RUNNING);
955
- return buildSessionResponse(paths, {
956
- preconditions
957
- });
958
- }
959
- const githubPreconditions = await runNamedPreconditions(paths, ["github_auth", "github_origin"]);
960
- if (!githubPreconditions.ok) {
961
- return failSession(paths, {
962
- ...githubPreconditions.error,
963
- preconditions: [
964
- ...preconditions,
965
- ...githubPreconditions.preconditions
966
- ]
967
- });
968
- }
969
- const result = await runLoggedCommand(paths, "github_issue_create", "gh", [
970
- "issue",
971
- "create",
972
- "--title",
973
- issueTitle,
974
- "--body-file",
975
- path.join(paths.sessionRoot, "issue.md")
976
- ], {
977
- cwd: paths.targetRoot,
978
- timeout: 30000
979
- });
980
- if (!result.ok || !result.stdout) {
981
- return failSession(paths, {
982
- code: "issue_create_failed",
983
- message: result.output || "GitHub issue creation failed.",
984
- repairCommand: "gh issue create",
985
- preconditions
986
- });
987
- }
988
- const issueUrl = result.stdout.split(/\r?\n/u).map((line) => line.trim()).find(Boolean) || result.stdout;
989
- await writeTextFile(path.join(paths.sessionRoot, "issue_url"), issueUrl);
990
- await writeIssueMetadataFiles(paths, {
991
- issueTitle,
992
- issueUrl
993
- });
994
- if (completeStep) {
995
- await writeStepRecord(paths, "issue_submitted", `Created GitHub issue ${issueUrl}.`);
996
- }
997
- await markStatus(paths, SESSION_STATUS.RUNNING);
998
- return buildSessionResponse(paths, {
999
- preconditions
1000
- });
1001
- }
1002
-
1003
- async function renderIssueFilePrompt(paths, context = {}) {
1004
- const preconditions = context.preconditions || [];
1005
- const issueFileSentinelPath = path.join(paths.sessionRoot, "metadata", "issue_created_requested");
1006
- const prompt = await renderPrompt(paths, "issue_created.md", {
1007
- issue_file: path.join(paths.sessionRoot, "issue.md"),
1008
- issue_title_file: path.join(paths.sessionRoot, "issue_title")
1009
- });
1010
- await writeTextFile(issueFileSentinelPath, "true\n");
1011
- await markStatus(paths, SESSION_STATUS.WAITING_FOR_USER);
1012
- return buildSessionResponse(paths, {
1013
- codex: ISSUE_FILE_CODEX_HANDOFF,
1014
- ok: true,
1015
- preconditions,
1016
- prompt,
1017
- status: SESSION_STATUS.WAITING_FOR_USER
1018
- });
1019
- }
1020
-
1021
- async function makePlan(paths, _options = {}, context = {}) {
1022
- const preconditions = context.preconditions || [];
1023
- const makePlanSentinelPath = path.join(paths.sessionRoot, "metadata", "make_plan_requested");
1024
- const issueText = await readTrimmedFile(path.join(paths.sessionRoot, "issue.md"));
1025
- const issueTitle = await readTrimmedFile(path.join(paths.sessionRoot, "issue_title")) || titleFromIssue(issueText);
1026
- const issueUrl = await readTrimmedFile(path.join(paths.sessionRoot, "issue_url"));
1027
- const issueNumber = issueNumberFromUrl(issueUrl);
1028
- if (context.completeStep !== false && await fileExists(makePlanSentinelPath)) {
1029
- await writeStepRecord(paths, "plan_made", "Plan reviewed in Codex terminal.");
1030
- await markStatus(paths, SESSION_STATUS.RUNNING);
1031
- return buildSessionResponse(paths, {
1032
- preconditions
1033
- });
1034
- }
1035
-
1036
- const prompt = await renderPrompt(paths, "make_plan.md", {
1037
- app_blueprint_file: path.join(paths.worktree, ".jskit", "APP_BLUEPRINT.md"),
1038
- issue_file: path.join(paths.sessionRoot, "issue.md"),
1039
- issue_number: issueNumber,
1040
- issue_text: issueText,
1041
- issue_title: issueTitle,
1042
- issue_title_file: path.join(paths.sessionRoot, "issue_title"),
1043
- issue_url: issueUrl,
1044
- worktree: paths.worktree
1045
- });
1046
- await writeTextFile(makePlanSentinelPath, "true\n");
1047
- await markStatus(paths, SESSION_STATUS.WAITING_FOR_USER);
1048
- return buildSessionResponse(paths, {
1049
- codex: PLAN_CODEX_HANDOFF,
1050
- ok: true,
1051
- preconditions,
1052
- prompt,
1053
- status: SESSION_STATUS.WAITING_FOR_USER
1054
- });
1055
- }
1056
-
1057
- async function renderPlanExecutionPrompt(paths, _options = {}, context = {}) {
1058
- const preconditions = context.preconditions || [];
1059
- const executePlanSentinelPath = path.join(paths.sessionRoot, "metadata", "execute_plan_requested");
1060
- if (context.completeStep !== false && await fileExists(executePlanSentinelPath)) {
1061
- await writeStepRecord(paths, "plan_executed", "Plan execution completed by Codex.");
1062
- await markStatus(paths, SESSION_STATUS.RUNNING);
1063
- return buildSessionResponse(paths, {
1064
- preconditions
1065
- });
1066
- }
1067
-
1068
- const issueText = await readTrimmedFile(path.join(paths.sessionRoot, "issue.md"));
1069
- const issueTitle = await readTrimmedFile(path.join(paths.sessionRoot, "issue_title")) || titleFromIssue(issueText);
1070
- const issueUrl = await readTrimmedFile(path.join(paths.sessionRoot, "issue_url"));
1071
- const issueNumber = issueNumberFromUrl(issueUrl);
1072
- const executionPrompt = await renderPrompt(paths, "plan_executed.md", {
1073
- issue_file: path.join(paths.sessionRoot, "issue.md"),
1074
- issue_number: issueNumber,
1075
- issue_title: issueTitle,
1076
- issue_url: issueUrl,
1077
- worktree: paths.worktree
1078
- });
1079
- await writeTextFile(executePlanSentinelPath, "true\n");
1080
- await markStatus(paths, SESSION_STATUS.WAITING_FOR_USER);
1081
- return buildSessionResponse(paths, {
1082
- codex: PLAN_EXECUTION_CODEX_HANDOFF,
1083
- preconditions,
1084
- prompt: executionPrompt,
1085
- status: SESSION_STATUS.WAITING_FOR_USER
1086
- });
1087
- }
1088
-
1089
- async function worktreeStatus(worktree) {
1090
- const result = await runGitInWorktree(worktree, ["status", "--porcelain=v1"]);
1091
- if (!result.ok) {
1092
- return {
1093
- ok: false,
1094
- changedFiles: [],
1095
- output: result.output
1096
- };
1097
- }
1098
- const changedFiles = result.stdout.split(/\r?\n/u).map((line) => line.trim()).filter(Boolean);
1099
- return {
1100
- ok: true,
1101
- changedFiles,
1102
- output: result.stdout
1103
- };
1104
- }
1105
-
1106
- async function untrackedFiles(worktree) {
1107
- const result = await runGitInWorktree(worktree, ["ls-files", "--others", "--exclude-standard", "-z"], {
1108
- timeout: 15000
1109
- });
1110
- if (!result.ok) {
1111
- return [];
1112
- }
1113
- return result.stdout
1114
- .split("\0")
1115
- .filter((line) => line.length > 0);
1116
- }
1117
-
1118
- async function untrackedFileDiff(worktree, filePath) {
1119
- const result = await runGitInWorktree(worktree, [
1120
- "diff",
1121
- "--no-color",
1122
- "--no-ext-diff",
1123
- "--no-index",
1124
- "--",
1125
- "/dev/null",
1126
- filePath
1127
- ], {
1128
- timeout: 15000
1129
- });
1130
- if (result.ok || result.exitCode === 1) {
1131
- return result.stdout;
1132
- }
1133
- return "";
1134
- }
1135
-
1136
- async function untrackedFilesDiff(worktree) {
1137
- const diffs = [];
1138
- for (const filePath of await untrackedFiles(worktree)) {
1139
- const diff = await untrackedFileDiff(worktree, filePath);
1140
- if (diff) {
1141
- diffs.push(diff);
1142
- }
1143
- }
1144
- return diffs.join("\n");
1145
- }
1146
-
1147
- async function inspectSessionDiff({
1148
- targetRoot = process.cwd(),
1149
- sessionId
1150
- } = {}) {
1151
- return withExistingSession({ targetRoot, sessionId }, async (paths) => {
1152
- const session = await buildSessionResponse(paths);
1153
- if (!await hasWorktree(paths)) {
1154
- return {
1155
- ...session,
1156
- ok: false,
1157
- errors: [
1158
- createError({
1159
- code: "worktree_missing",
1160
- message: "Session worktree is not available for diff inspection."
1161
- })
1162
- ],
1163
- gitStatus: "",
1164
- hasChanges: false,
1165
- stagedDiff: "",
1166
- unstagedDiff: "",
1167
- untrackedDiff: ""
1168
- };
1169
- }
1170
-
1171
- const [status, unstagedDiff, stagedDiff] = await Promise.all([
1172
- runGitInWorktree(paths.worktree, ["status", "--porcelain=v1"], { timeout: 15000 }),
1173
- runGitInWorktree(paths.worktree, ["diff", "--no-color", "--no-ext-diff"], { timeout: 30000 }),
1174
- runGitInWorktree(paths.worktree, ["diff", "--cached", "--no-color", "--no-ext-diff"], { timeout: 30000 })
1175
- ]);
1176
-
1177
- if (!status.ok || !unstagedDiff.ok || !stagedDiff.ok) {
1178
- return {
1179
- ...session,
1180
- ok: false,
1181
- errors: [
1182
- createError({
1183
- code: "session_diff_failed",
1184
- message: [status, unstagedDiff, stagedDiff].find((result) => !result.ok)?.output ||
1185
- "Failed to inspect session worktree diff."
1186
- })
1187
- ],
1188
- gitStatus: status.stdout || "",
1189
- hasChanges: false,
1190
- stagedDiff: stagedDiff.stdout || "",
1191
- unstagedDiff: unstagedDiff.stdout || "",
1192
- untrackedDiff: ""
1193
- };
1194
- }
1195
-
1196
- const untrackedDiff = await untrackedFilesDiff(paths.worktree);
1197
- return {
1198
- ...session,
1199
- gitStatus: status.stdout,
1200
- hasChanges: Boolean(status.stdout.trim()),
1201
- stagedDiff: stagedDiff.stdout,
1202
- unstagedDiff: unstagedDiff.stdout,
1203
- untrackedDiff,
1204
- worktree: paths.worktree
1205
- };
1206
- });
1207
- }
1208
-
1209
- const FIRST_REWINDABLE_STEP_ID = "dependencies_installed";
1210
- const REWIND_CLOSED_STATUSES = Object.freeze([
1211
- SESSION_STATUS.ABANDONED,
1212
- SESSION_STATUS.FINISHED
1213
- ]);
1214
-
1215
- async function removeSessionPath(paths, ...parts) {
1216
- await rm(path.join(paths.sessionRoot, ...parts), {
1217
- force: true,
1218
- recursive: true
1219
- });
1220
- }
1221
-
1222
- async function removeSessionRootFile(paths, fileName) {
1223
- await removeSessionPath(paths, fileName);
1224
- }
1225
-
1226
- async function removePromptArtifact(paths, fileName) {
1227
- await removeSessionPath(paths, "prompts", fileName);
1228
- }
1229
-
1230
- async function removeGlobalCodexResult(paths, stepId) {
1231
- await removeSessionPath(paths, "codex_results", stepId);
1232
- }
1233
-
1234
- async function removeCycleCodexResults(paths, stepId) {
1235
- const cyclesRoot = path.join(paths.sessionRoot, "cycles");
1236
- let cycleDirectories = [];
1237
- try {
1238
- cycleDirectories = (await readdir(cyclesRoot, { withFileTypes: true }))
1239
- .filter((entry) => entry.isDirectory() && /^cycle_\d+$/u.test(entry.name))
1240
- .map((entry) => entry.name);
1241
- } catch {
1242
- cycleDirectories = [];
1243
- }
1244
- await Promise.all(cycleDirectories.map((cycleDirectory) => {
1245
- return removeSessionPath(paths, "cycles", cycleDirectory, "codex_results", stepId);
1246
- }));
1247
- }
1248
-
1249
- async function removeCodexResult(paths, stepId) {
1250
- await Promise.all([
1251
- removeGlobalCodexResult(paths, stepId),
1252
- removeCycleCodexResults(paths, stepId)
1253
- ]);
1254
- }
1255
-
1256
- async function removeGithubCommentPurpose(paths, purpose) {
1257
- const comments = await readGithubComments(paths);
1258
- if (!Object.hasOwn(comments, purpose)) {
1259
- return;
1260
- }
1261
- delete comments[purpose];
1262
- if (Object.keys(comments).length === 0) {
1263
- await removeSessionRootFile(paths, "github_comments.json");
1264
- return;
1265
- }
1266
- await writeGithubComments(paths, comments);
1267
- }
1268
-
1269
- async function removeCycleDirectories(paths) {
1270
- for (const rootName of ["steps", "cycles"]) {
1271
- const root = path.join(paths.sessionRoot, rootName);
1272
- let entries = [];
1273
- try {
1274
- entries = await readdir(root, { withFileTypes: true });
1275
- } catch {
1276
- entries = [];
1277
- }
1278
- await Promise.all(entries
1279
- .filter((entry) => entry.isDirectory() && /^cycle_\d+$/u.test(entry.name))
1280
- .map((entry) => rm(path.join(root, entry.name), {
1281
- force: true,
1282
- recursive: true
1283
- })));
1284
- }
1285
- }
1286
-
1287
- async function removePlanArtifacts(paths) {
1288
- await removeSessionPath(paths, "metadata", "make_plan_requested");
1289
- }
1290
-
1291
- async function removePlanExecutionArtifacts(paths) {
1292
- await Promise.all([
1293
- removeSessionPath(paths, "metadata", "execute_plan_requested"),
1294
- removeCodexResult(paths, "plan_executed")
1295
- ]);
1296
- }
1297
-
1298
- const STEP_CANCELERS = Object.freeze({
1299
- dependencies_installed: async () => {},
1300
- issue_prompt_rendered: async (paths) => {
1301
- await removeSessionPath(paths, "metadata", "issue_prompt_rendered_requested");
1302
- },
1303
- issue_created: async (paths) => {
1304
- await Promise.all([
1305
- removeSessionPath(paths, "metadata", "issue_created_requested"),
1306
- removeSessionRootFile(paths, "issue.md"),
1307
- removeSessionRootFile(paths, "issue_title")
1308
- ]);
1309
- },
1310
- issue_submitted: async (paths) => {
1311
- await Promise.all([
1312
- removeSessionRootFile(paths, "issue_url"),
1313
- removeSessionPath(paths, "metadata", "issue_body_path"),
1314
- removeSessionPath(paths, "metadata", "issue_details_path"),
1315
- removeSessionPath(paths, "metadata", "issue_number"),
1316
- removeSessionPath(paths, "metadata", "issue_owner"),
1317
- removeSessionPath(paths, "metadata", "issue_repository"),
1318
- removeSessionPath(paths, "metadata", "issue_title"),
1319
- removeSessionPath(paths, "metadata", "issue_url")
1320
- ]);
1321
- },
1322
- plan_made: removePlanArtifacts,
1323
- plan_executed: removePlanExecutionArtifacts,
1324
- deep_ui_check_run: async (paths) => {
1325
- await Promise.all([
1326
- removeSessionPath(paths, "ui_checks"),
1327
- removeCodexResult(paths, "deep_ui_check_run")
1328
- ]);
1329
- },
1330
- review_prompt_rendered: async (paths) => {
1331
- await Promise.all([
1332
- removePromptArtifact(paths, "review_prompt_rendered"),
1333
- removeSessionPath(paths, "review_passes"),
1334
- removeCodexResult(paths, "review_prompt_rendered")
1335
- ]);
1336
- },
1337
- review_changes_accepted: async (paths) => {
1338
- await removeSessionPath(paths, "review_passes");
1339
- },
1340
- automated_checks_run: async (paths) => {
1341
- await Promise.all([
1342
- removeSessionPath(paths, "checks"),
1343
- removeCodexResult(paths, "automated_checks_run")
1344
- ]);
1345
- },
1346
- user_check_completed: async (paths) => {
1347
- await Promise.all([
1348
- removePromptArtifact(paths, "user_check_completed"),
1349
- removeSessionPath(paths, "steps", "user_check_failed")
1350
- ]);
1351
- },
1352
- changes_committed: async (paths) => {
1353
- await removeSessionRootFile(paths, "changes_committed.json");
1354
- },
1355
- blueprint_updated: async (paths) => {
1356
- await Promise.all([
1357
- removeSessionPath(paths, "metadata", "blueprint_updated_requested"),
1358
- removeSessionRootFile(paths, BLUEPRINT_BASELINE_FILE),
1359
- removeCodexResult(paths, "blueprint_updated")
1360
- ]);
1361
- },
1362
- final_report_created: async (paths) => {
1363
- await Promise.all([
1364
- removeSessionPath(paths, "metadata", "pull_request_file_requested"),
1365
- removeSessionRootFile(paths, "pull_request.md"),
1366
- removeSessionRootFile(paths, "final_report"),
1367
- removeSessionRootFile(paths, "final_report.md"),
1368
- removeGithubCommentPurpose(paths, "final_report")
1369
- ]);
1370
- },
1371
- pr_created: async (paths) => {
1372
- await Promise.all([
1373
- removePromptArtifact(paths, "pr_create_failure"),
1374
- removeSessionRootFile(paths, "pr_body.md"),
1375
- removeSessionRootFile(paths, "pull_request_body.md"),
1376
- removeSessionRootFile(paths, "pr_url")
1377
- ]);
1378
- },
1379
- pr_merge_prepared: async (paths) => {
1380
- await Promise.all([
1381
- removePromptArtifact(paths, "pr_merge_prepared"),
1382
- removePromptArtifact(paths, "pr_merge_failure"),
1383
- removeSessionRootFile(paths, "pr_base_branch"),
1384
- removeSessionRootFile(paths, "pr_merge_completed"),
1385
- removeSessionRootFile(paths, "pr_outcome.json")
1386
- ]);
1387
- },
1388
- main_checkout_synced: async (paths) => {
1389
- await Promise.all([
1390
- removeSessionRootFile(paths, "local_base_updated"),
1391
- removeSessionRootFile(paths, "main_checkout_sync.json")
1392
- ]);
1393
- },
1394
- session_finished: async (paths) => {
1395
- await Promise.all([
1396
- removeSessionRootFile(paths, "final_comment")
1397
- ]);
1398
- }
1399
- });
1400
-
1401
- function targetIsAllowedRewindStep(stepId) {
1402
- if (!STEP_IDS.includes(stepId)) {
1403
- return false;
1404
- }
1405
- if (stepId === "worktree_created") {
1406
- return false;
1407
- }
1408
- return STEP_IDS.indexOf(stepId) >= STEP_IDS.indexOf(FIRST_REWINDABLE_STEP_ID);
1409
- }
1410
-
1411
- function deletedStepIdsForRewindTarget(stepId) {
1412
- const targetIndex = STEP_IDS.indexOf(stepId);
1413
- return targetIndex < 0 ? [] : STEP_IDS.slice(targetIndex);
1414
- }
1415
-
1416
- async function removeStepRecordsForDeletedSteps(paths, deletedStepIds) {
1417
- const stepsRoot = path.join(paths.sessionRoot, "steps");
1418
- let cycleDirectories = [];
1419
- try {
1420
- cycleDirectories = (await readdir(stepsRoot, { withFileTypes: true }))
1421
- .filter((entry) => entry.isDirectory() && /^cycle_\d+$/u.test(entry.name))
1422
- .map((entry) => entry.name);
1423
- } catch {
1424
- cycleDirectories = [];
1425
- }
1426
- await Promise.all(deletedStepIds.flatMap((stepId) => [
1427
- removeSessionPath(paths, "steps", stepId),
1428
- ...cycleDirectories.map((cycleDirectory) => removeSessionPath(paths, "steps", cycleDirectory, stepId))
1429
- ]));
1430
- }
1431
-
1432
- async function cancelDeletedStepArtifacts(paths, deletedStepIds) {
1433
- for (const stepId of deletedStepIds) {
1434
- const canceler = STEP_CANCELERS[stepId];
1435
- if (typeof canceler !== "function") {
1436
- continue;
1437
- }
1438
- await canceler(paths);
1439
- }
1440
- }
1441
-
1442
- async function rewindSession({
1443
- targetRoot = process.cwd(),
1444
- sessionId,
1445
- stepId
1446
- } = {}) {
1447
- return withExistingSession({ targetRoot, sessionId }, async (paths) => {
1448
- const artifacts = await readSessionArtifacts(paths);
1449
- const normalizedStepId = normalizeText(stepId);
1450
- const currentStatus = artifacts.status || SESSION_STATUS.PENDING;
1451
-
1452
- if (paths.archive && paths.archive !== "active") {
1453
- return buildSessionResponse(paths, {
1454
- ok: false,
1455
- errors: [
1456
- createError({
1457
- code: "session_archived_read_only",
1458
- message: `Session ${paths.sessionId} is archived and cannot be rewound.`
1459
- })
1460
- ],
1461
- status: currentStatus
1462
- });
1463
- }
1464
-
1465
- if (REWIND_CLOSED_STATUSES.includes(currentStatus)) {
1466
- return buildSessionResponse(paths, {
1467
- ok: false,
1468
- errors: [
1469
- createError({
1470
- code: "session_closed_read_only",
1471
- message: `Session ${paths.sessionId} is ${currentStatus} and cannot be rewound.`
1472
- })
1473
- ],
1474
- status: currentStatus
1475
- });
1476
- }
1477
-
1478
- if (artifacts.workflowVersion !== SESSION_WORKFLOW_VERSION) {
1479
- return buildSessionResponse(paths, {
1480
- ok: false,
1481
- errors: [
1482
- createError({
1483
- code: "unsupported_workflow_version",
1484
- message: `Session ${paths.sessionId} uses workflow version ${artifacts.workflowVersion || "unknown"}, but this JSKIT runtime expects ${SESSION_WORKFLOW_VERSION}.`
1485
- })
1486
- ],
1487
- status: SESSION_STATUS.BLOCKED
1488
- });
1489
- }
1490
-
1491
- if (!targetIsAllowedRewindStep(normalizedStepId)) {
1492
- return buildSessionResponse(paths, {
1493
- ok: false,
1494
- errors: [
1495
- createError({
1496
- code: "rewind_step_not_allowed",
1497
- message: `Cannot rewind session ${paths.sessionId} to ${normalizedStepId || "(missing)"}.`
1498
- })
1499
- ],
1500
- status: currentStatus
1501
- });
1502
- }
1503
-
1504
- if (!artifacts.completedSteps.includes(normalizedStepId)) {
1505
- return buildSessionResponse(paths, {
1506
- ok: false,
1507
- errors: [
1508
- createError({
1509
- code: "rewind_step_not_completed",
1510
- message: `Cannot rewind session ${paths.sessionId} to ${normalizedStepId} because that step is not completed.`
1511
- })
1512
- ],
1513
- status: currentStatus
1514
- });
1515
- }
1516
-
1517
- const deletedStepIds = deletedStepIdsForRewindTarget(normalizedStepId);
1518
- await removeStepRecordsForDeletedSteps(paths, deletedStepIds);
1519
- await cancelDeletedStepArtifacts(paths, deletedStepIds);
1520
- await markCurrentStep(paths, normalizedStepId);
1521
- await markStatus(paths, SESSION_STATUS.PENDING);
1522
- return buildSessionResponse(paths, {
1523
- status: SESSION_STATUS.PENDING
1524
- });
1525
- });
1526
- }
1527
-
1528
- async function commitWorktree(paths, {
1529
- message,
1530
- allowNoChanges = false
1531
- } = {}) {
1532
- const status = await worktreeStatus(paths.worktree);
1533
- if (!status.ok) {
1534
- return {
1535
- ok: false,
1536
- output: status.output
1537
- };
1538
- }
1539
- if (status.changedFiles.length < 1) {
1540
- return {
1541
- changedFiles: [],
1542
- ok: allowNoChanges,
1543
- output: allowNoChanges ? "No changes to commit." : "No changes found."
1544
- };
1545
- }
1546
- const addResult = await runGitInWorktree(paths.worktree, ["add", "."]);
1547
- if (!addResult.ok) {
1548
- return {
1549
- ok: false,
1550
- output: addResult.output
1551
- };
1552
- }
1553
- const commitResult = await runGitInWorktree(paths.worktree, ["commit", "-m", message], {
1554
- timeout: 30000
1555
- });
1556
- if (!commitResult.ok) {
1557
- return {
1558
- ok: false,
1559
- output: commitResult.output
1560
- };
1561
- }
1562
- return {
1563
- changedFiles: status.changedFiles,
1564
- ok: true,
1565
- output: commitResult.output
1566
- };
1567
- }
1568
-
1569
- function uniqueChangedFileList(entries = []) {
1570
- return [...new Set(entries
1571
- .flatMap((entry) => String(entry || "").split(/\r?\n/u))
1572
- .map((line) => line.trim())
1573
- .filter(Boolean))]
1574
- .sort((left, right) => left.localeCompare(right));
1575
- }
1576
-
1577
- async function changedFilesInWorktree(paths) {
1578
- const [trackedResult, untrackedResult] = await Promise.all([
1579
- runGitInWorktree(paths.worktree, ["diff", "--name-only", "HEAD"], {
1580
- timeout: 15000
1581
- }),
1582
- runGitInWorktree(paths.worktree, ["ls-files", "--others", "--exclude-standard"], {
1583
- timeout: 15000
1584
- })
1585
- ]);
1586
- return uniqueChangedFileList([
1587
- trackedResult.ok ? trackedResult.stdout : "",
1588
- untrackedResult.ok ? untrackedResult.stdout : ""
1589
- ]);
1590
- }
1591
-
1592
- const BLUEPRINT_RELATIVE_PATH = ".jskit/APP_BLUEPRINT.md";
1593
- const BLUEPRINT_BASELINE_FILE = "blueprint_update_baseline.json";
1594
-
1595
- function blueprintBaselinePath(paths) {
1596
- return path.join(paths.sessionRoot, BLUEPRINT_BASELINE_FILE);
1597
- }
1598
-
1599
- function isBlueprintRelativePath(filePath = "") {
1600
- return normalizeText(filePath) === BLUEPRINT_RELATIVE_PATH;
1601
- }
1602
-
1603
- function nonBlueprintChangedFiles(files = []) {
1604
- return files.filter((file) => !isBlueprintRelativePath(file));
1605
- }
1606
-
1607
- async function hashWorktreeFile(paths, filePath) {
1608
- try {
1609
- const buffer = await readFile(path.join(paths.worktree, filePath));
1610
- return createHash("sha256").update(buffer).digest("hex");
1611
- } catch {
1612
- return "missing";
1613
- }
1614
- }
1615
-
1616
- async function buildDirtyFileSnapshot(paths, files = []) {
1617
- const entries = await Promise.all(nonBlueprintChangedFiles(files).map(async (file) => [
1618
- file,
1619
- await hashWorktreeFile(paths, file)
1620
- ]));
1621
- return Object.fromEntries(entries);
1622
- }
1623
-
1624
- async function writeBlueprintBaseline(paths) {
1625
- const changedFiles = await changedFilesInWorktree(paths);
1626
- const snapshot = await buildDirtyFileSnapshot(paths, changedFiles);
1627
- const payload = {
1628
- changedFiles: Object.keys(snapshot).sort((left, right) => left.localeCompare(right)),
1629
- files: snapshot,
1630
- recordedAt: timestampForStepRecord()
1631
- };
1632
- await writeTextFile(blueprintBaselinePath(paths), `${JSON.stringify(payload, null, 2)}\n`);
1633
- return payload;
1634
- }
1635
-
1636
- async function readBlueprintBaseline(paths) {
1637
- return parseJsonObject(await readTextIfExists(blueprintBaselinePath(paths))) || null;
1638
- }
1639
-
1640
- async function unexpectedBlueprintStepChanges(paths, changedFiles = []) {
1641
- const baseline = await readBlueprintBaseline(paths);
1642
- if (!baseline?.files || typeof baseline.files !== "object" || Array.isArray(baseline.files)) {
1643
- return nonBlueprintChangedFiles(changedFiles);
1644
- }
1645
- const baselineFiles = baseline.files;
1646
- const currentFiles = new Set(nonBlueprintChangedFiles(changedFiles));
1647
- const candidates = new Set([
1648
- ...Object.keys(baselineFiles),
1649
- ...currentFiles
1650
- ]);
1651
- const unexpected = [];
1652
- for (const file of [...candidates].sort((left, right) => left.localeCompare(right))) {
1653
- if (!Object.prototype.hasOwnProperty.call(baselineFiles, file)) {
1654
- unexpected.push(file);
1655
- continue;
1656
- }
1657
- const currentHash = await hashWorktreeFile(paths, file);
1658
- if (currentHash !== baselineFiles[file]) {
1659
- unexpected.push(file);
1660
- }
1661
- }
1662
- return unexpected;
1663
- }
1664
-
1665
- async function changedFilesSinceBase(paths) {
1666
- const baseCommit = await readTrimmedFile(path.join(paths.sessionRoot, "base_commit"));
1667
- const args = baseCommit
1668
- ? ["diff", "--name-only", `${baseCommit}..HEAD`]
1669
- : ["show", "--name-only", "--format=", "HEAD"];
1670
- const result = await runGitInWorktree(paths.worktree, args, {
1671
- timeout: 15000
1672
- });
1673
- if (!result.ok) {
1674
- return (await changedFilesInWorktree(paths)).join("\n");
1675
- }
1676
- return uniqueChangedFileList([
1677
- result.stdout,
1678
- ...(await changedFilesInWorktree(paths))
1679
- ]).join("\n");
1680
- }
1681
-
1682
- function nextReviewPassNumber(pass = "") {
1683
- const current = Number.parseInt(normalizeReviewPassNumber(pass), 10);
1684
- return String(current + 1).padStart(3, "0");
1685
- }
1686
-
1687
- async function readCurrentReviewPass(paths) {
1688
- return normalizeReviewPassNumber(await readTrimmedFile(path.join(paths.sessionRoot, "review_passes", "current_pass")));
1689
- }
1690
-
1691
- async function writeCurrentReviewPass(paths, pass) {
1692
- await writeTextFile(path.join(paths.sessionRoot, "review_passes", "current_pass"), `${normalizeReviewPassNumber(pass)}\n`);
1693
- }
1694
-
1695
- async function resolveReviewPassForPrompt(paths) {
1696
- const passes = await readReviewPasses(paths);
1697
- const latestPass = passes.at(-1);
1698
- if (!latestPass) {
1699
- return "001";
1700
- }
1701
- if (!["accepted", "no_changes"].includes(latestPass.status)) {
1702
- return latestPass.pass;
1703
- }
1704
- return nextReviewPassNumber(latestPass.pass);
1705
- }
1706
-
1707
- async function writeReviewPassJson(paths, pass, fileName, payload) {
1708
- const root = reviewPassRoot(paths, pass);
1709
- await mkdir(root, { recursive: true });
1710
- await writeTextFile(path.join(root, fileName), `${JSON.stringify(payload, null, 2)}\n`);
1711
- }
1712
-
1713
- async function currentHead(paths) {
1714
- const result = await runGitInWorktree(paths.worktree, ["rev-parse", "HEAD"], {
1715
- timeout: 15000
1716
- });
1717
- return result.ok ? result.stdout.trim() : "";
1718
- }
1719
-
1720
- async function renderReviewPrompt(paths) {
1721
- const reviewPass = await resolveReviewPassForPrompt(paths);
1722
- await writeCurrentReviewPass(paths, reviewPass);
1723
- const changedFiles = await changedFilesSinceBase(paths);
1724
- const prompt = await renderPrompt(paths, "review_prompt_rendered.md", {
1725
- changed_files: changedFiles,
1726
- review_pass_limit: String(REVIEW_PASS_LIMIT),
1727
- review_pass_number: reviewPass
1728
- });
1729
- const passRoot = reviewPassRoot(paths, reviewPass);
1730
- await writePromptArtifact(paths, "review_prompt_rendered", prompt);
1731
- await mkdir(passRoot, { recursive: true });
1732
- await writeTextFile(path.join(passRoot, "review_prompt_rendered"), prompt);
1733
- await writeReviewPassJson(paths, reviewPass, "prompt.json", {
1734
- changedFiles: changedFiles.split(/\r?\n/u).filter(Boolean),
1735
- maxPasses: REVIEW_PASS_LIMIT,
1736
- pass: reviewPass,
1737
- promptPath: path.join(passRoot, "review_prompt_rendered"),
1738
- status: "prompted",
1739
- startedAt: timestampForStepRecord()
1740
- });
1741
- await markStatus(paths, SESSION_STATUS.WAITING_FOR_USER);
1742
- return buildSessionResponse(paths, {
1743
- codex: REVIEW_EXECUTION_CODEX_HANDOFF,
1744
- prompt,
1745
- status: SESSION_STATUS.WAITING_FOR_USER
1746
- });
1747
- }
1748
-
1749
- async function renderResolveDeslopPrompt(paths, context = {}) {
1750
- const preconditions = context.preconditions || [];
1751
- const reviewPass = await readCurrentReviewPass(paths);
1752
- const prompt = await renderPrompt(paths, "review_changes_accepted_resolve.md", {});
1753
- await writePromptArtifact(paths, "review_changes_accepted_resolve", prompt);
1754
- if (reviewPass) {
1755
- await writeReviewPassJson(paths, reviewPass, "resolve_prompt.json", {
1756
- pass: reviewPass,
1757
- promptPath: path.join(paths.sessionRoot, "prompts", "review_changes_accepted_resolve"),
1758
- status: "resolve_prompted",
1759
- startedAt: timestampForStepRecord()
1760
- });
1761
- }
1762
- await markStatus(paths, SESSION_STATUS.WAITING_FOR_USER);
1763
- return buildSessionResponse(paths, {
1764
- codex: RESOLVE_DESLOP_CODEX_HANDOFF,
1765
- ok: true,
1766
- preconditions,
1767
- prompt,
1768
- status: SESSION_STATUS.WAITING_FOR_USER
1769
- });
1770
- }
1771
-
1772
- async function acceptReviewChanges(paths, options = {}, context = {}) {
1773
- const resolveDeslop = options.resolveDeslop === true ||
1774
- normalizeText(options["resolve-deslop"]).toLowerCase() === "true";
1775
- if (resolveDeslop) {
1776
- return renderResolveDeslopPrompt(paths, context);
1777
- }
1778
-
1779
- const reviewDecisionProvided = Object.hasOwn(options, "reviewFindingsRemaining") ||
1780
- Object.hasOwn(options, "review-findings-remaining");
1781
- if (!reviewDecisionProvided) {
1782
- return failSession(paths, {
1783
- code: "review_decision_required",
1784
- message: "Accept review/deslop requires an explicit decision: resolve review/deslop, run review/deslop again, or continue.",
1785
- repairCommand: `jskit session ${paths.sessionId} step --review-findings-remaining false`
1786
- });
1787
- }
1788
-
1789
- const status = await worktreeStatus(paths.worktree);
1790
- if (!status.ok) {
1791
- return failSession(paths, {
1792
- code: "git_status_failed",
1793
- message: status.output || "Failed to inspect review changes.",
1794
- repairCommand: `git -C ${paths.worktree} status --short`
1795
- });
1796
- }
1797
- const message = status.changedFiles.length > 0
1798
- ? `Accepted ${status.changedFiles.length} review changed file entries.`
1799
- : "Accepted review with no file changes.";
1800
- const reviewPass = await readCurrentReviewPass(paths);
1801
- const findingsRemaining = options.reviewFindingsRemaining === true ||
1802
- normalizeText(options["review-findings-remaining"]).toLowerCase() === "true";
1803
- await writeReviewPassJson(paths, reviewPass, "accepted.json", {
1804
- acceptedAt: timestampForStepRecord(),
1805
- changedFiles: status.changedFiles || [],
1806
- findingsRemaining,
1807
- remainingFindings: "",
1808
- pass: reviewPass,
1809
- status: status.changedFiles?.length ? "accepted" : "no_changes"
1810
- });
1811
- await writeStepRecord(paths, "review_changes_accepted", message);
1812
- await markStatus(paths, SESSION_STATUS.RUNNING);
1813
- return buildSessionResponse(paths);
1814
- }
1815
-
1816
- async function runAutomatedChecks(paths, {
1817
- stepId
1818
- }, _options = {}, context = {}) {
1819
- const preconditions = context.preconditions || [];
1820
- const [command, args] = await doctorCommandForWorktree(paths.worktree);
1821
- const checksRoot = path.join(paths.sessionRoot, "checks");
1822
- await mkdir(checksRoot, { recursive: true });
1823
- const checkCommand = [command, ...args].join(" ");
1824
-
1825
- const prompt = await renderPrompt(paths, "automated_checks_run.md", {
1826
- check_command: checkCommand
1827
- });
1828
- await writeTextFile(
1829
- path.join(checksRoot, `${stepId}.json`),
1830
- `${JSON.stringify({
1831
- command: checkCommand,
1832
- ok: false,
1833
- status: "prompted",
1834
- stepId
1835
- }, null, 2)}\n`
1836
- );
1837
- await markStatus(paths, SESSION_STATUS.WAITING_FOR_USER);
1838
- return buildSessionResponse(paths, {
1839
- codex: AUTOMATED_CHECK_REPAIR_CODEX_HANDOFF,
1840
- preconditions,
1841
- prompt,
1842
- status: SESSION_STATUS.WAITING_FOR_USER
1843
- });
1844
- }
1845
-
1846
- async function writeUiCheckJson(paths, fileName, payload) {
1847
- const uiChecksRoot = path.join(paths.sessionRoot, "ui_checks");
1848
- await mkdir(uiChecksRoot, { recursive: true });
1849
- await writeTextFile(path.join(uiChecksRoot, `${fileName}.json`), `${JSON.stringify(payload, null, 2)}\n`);
1850
- }
1851
-
1852
- async function runDeepUiCheck(paths, {
1853
- stepId,
1854
- phase
1855
- }, _options = {}, context = {}) {
1856
- const preconditions = context.preconditions || [];
1857
- const issueUrl = await readTrimmedFile(path.join(paths.sessionRoot, "issue_url"));
1858
- const issueText = await readTrimmedFile(path.join(paths.sessionRoot, "issue.md"));
1859
- const issueTitle = await readTrimmedFile(path.join(paths.sessionRoot, "issue_title")) || titleFromIssue(issueText);
1860
- const prompt = await renderPrompt(paths, "deep_ui_check_run.md", {
1861
- changed_files: await changedFilesSinceBase(paths),
1862
- issue_file: path.join(paths.sessionRoot, "issue.md"),
1863
- issue_number: issueNumberFromUrl(issueUrl),
1864
- issue_title: issueTitle,
1865
- issue_url: issueUrl,
1866
- phase,
1867
- worktree: paths.worktree
1868
- });
1869
- await writeUiCheckJson(paths, stepId, {
1870
- ok: true,
1871
- phase,
1872
- status: "prompted",
1873
- stepId
1874
- });
1875
- await markStatus(paths, SESSION_STATUS.WAITING_FOR_USER);
1876
- return buildSessionResponse(paths, {
1877
- codex: DEEP_UI_CHECK_CODEX_HANDOFF,
1878
- preconditions,
1879
- prompt,
1880
- status: SESSION_STATUS.WAITING_FOR_USER
1881
- });
1882
- }
1883
-
1884
- async function userCheck(paths, options = {}) {
1885
- const result = normalizeText(options.userCheck || options["user-check"]).toLowerCase();
1886
- if (result === "passed" || result === "pass" || result === "ok" || result === "yes") {
1887
- await writeStepRecord(paths, "user_check_completed", "User confirmed check passed.");
1888
- await markStatus(paths, SESSION_STATUS.RUNNING);
1889
- return buildSessionResponse(paths);
1890
- }
1891
- if (result === "failed" || result === "fail" || result === "no") {
1892
- await markStatus(paths, SESSION_STATUS.RUNNING);
1893
- return buildSessionResponse(paths, {
1894
- warnings: [
1895
- {
1896
- code: "user_check_failed",
1897
- message: "Complete user check failed. Rewind to the step that should be redone."
1898
- }
1899
- ]
1900
- });
1901
- }
1902
- const issueText = await readTrimmedFile(path.join(paths.sessionRoot, "issue.md"));
1903
- const issueUrl = await readTrimmedFile(path.join(paths.sessionRoot, "issue_url"));
1904
- const issueTitle = await readTrimmedFile(path.join(paths.sessionRoot, "issue_title")) || titleFromIssue(issueText);
1905
- const prompt = await renderPrompt(paths, "user_check_completed.md", {
1906
- issue_file: path.join(paths.sessionRoot, "issue.md"),
1907
- issue_title: issueTitle,
1908
- issue_url: issueUrl
1909
- });
1910
- await writePromptArtifact(paths, "user_check_completed", prompt);
1911
- await markStatus(paths, SESSION_STATUS.WAITING_FOR_USER);
1912
- return buildSessionResponse(paths, {
1913
- prompt,
1914
- status: SESSION_STATUS.WAITING_FOR_USER
1915
- });
1916
- }
1917
-
1918
- async function readAcceptedChangesCommit(paths) {
1919
- const source = await readTextIfExists(path.join(paths.sessionRoot, "changes_committed.json"));
1920
- return parseJsonObject(source) || null;
1921
- }
1922
-
1923
- async function commitAcceptedChanges(paths, _options = {}, context = {}) {
1924
- const preconditions = context.preconditions || [];
1925
- const completeStep = context.completeStep !== false;
1926
- let commitInfo = await readAcceptedChangesCommit(paths);
1927
-
1928
- if (!commitInfo?.commit) {
1929
- const result = await commitWorktree(paths, {
1930
- allowNoChanges: true,
1931
- message: `Implement JSKIT session ${paths.sessionId}`
1932
- });
1933
- if (!result.ok) {
1934
- return failSession(paths, {
1935
- code: "accepted_changes_commit_failed",
1936
- message: result.output || "Failed to commit accepted changes.",
1937
- repairCommand: `git -C ${paths.worktree} status --short`,
1938
- preconditions
1939
- });
1940
- }
1941
- commitInfo = {
1942
- changedFiles: result.changedFiles || [],
1943
- commit: await currentHead(paths),
1944
- committedAt: timestampForStepRecord(),
1945
- noChanges: (result.changedFiles || []).length < 1
1946
- };
1947
- await writeTextFile(path.join(paths.sessionRoot, "changes_committed.json"), `${JSON.stringify(commitInfo, null, 2)}\n`);
1948
- }
1949
-
1950
- const warnings = [];
1951
- if (commitInfo.noChanges === true) {
1952
- warnings.push({
1953
- code: "accepted_changes_noop",
1954
- message: "No accepted worktree changes were found; continuing without a new commit."
1955
- });
1956
- }
1957
- if (!completeStep) {
1958
- await markStatus(paths, SESSION_STATUS.RUNNING);
1959
- return buildSessionResponse(paths, {
1960
- preconditions,
1961
- warnings
1962
- });
1963
- }
1964
- await writeStepRecord(
1965
- paths,
1966
- "changes_committed",
1967
- commitInfo.noChanges === true
1968
- ? "No accepted worktree changes were found; continued without a new commit."
1969
- : `Committed accepted changes at ${commitInfo.commit || "unknown"}.`
1970
- );
1971
- await markStatus(paths, SESSION_STATUS.RUNNING);
1972
- return buildSessionResponse(paths, {
1973
- preconditions,
1974
- warnings
1975
- });
1976
- }
1977
-
1978
- async function updateBlueprint(paths, _options = {}, context = {}) {
1979
- const preconditions = context.preconditions || [];
1980
- const issueText = await readTrimmedFile(path.join(paths.sessionRoot, "issue.md"));
1981
- const issueTitle = await readTrimmedFile(path.join(paths.sessionRoot, "issue_title")) || titleFromIssue(issueText);
1982
- const issueUrl = await readTrimmedFile(path.join(paths.sessionRoot, "issue_url"));
1983
- const issueNumber = issueNumberFromUrl(issueUrl);
1984
- const blueprintPath = path.join(paths.worktree, BLUEPRINT_RELATIVE_PATH);
1985
- const blueprintSentinelPath = path.join(paths.sessionRoot, "metadata", "blueprint_updated_requested");
1986
-
1987
- if (context.completeStep !== false && await fileExists(blueprintSentinelPath)) {
1988
- const changedFiles = await changedFilesInWorktree(paths);
1989
- const unexpectedChanges = await unexpectedBlueprintStepChanges(paths, changedFiles);
1990
- if (unexpectedChanges.length > 0) {
1991
- return failSession(paths, {
1992
- code: "blueprint_unexpected_changes",
1993
- message: `The blueprint step changed files outside ${BLUEPRINT_RELATIVE_PATH}: ${unexpectedChanges.join(", ")}`,
1994
- repairCommand: `git -C ${paths.worktree} status --short`,
1995
- preconditions
1996
- });
1997
- }
1998
-
1999
- const blueprintText = await readTrimmedFile(blueprintPath);
2000
- if (!blueprintText) {
2001
- return failSession(paths, {
2002
- code: "app_blueprint_missing_after_update",
2003
- message: "Codex completed the blueprint step without leaving a non-empty .jskit/APP_BLUEPRINT.md file.",
2004
- repairCommand: `jskit session ${paths.sessionId} step`,
2005
- preconditions
2006
- });
2007
- }
2008
-
2009
- if (changedFiles.includes(BLUEPRINT_RELATIVE_PATH)) {
2010
- await writeStepRecord(paths, "blueprint_updated", "Codex updated the app blueprint; JSKIT will include it in the accepted changes commit.");
2011
- } else {
2012
- await writeStepRecord(paths, "blueprint_updated", "Codex reviewed the app blueprint; no blueprint changes were needed.");
2013
- }
2014
- await markStatus(paths, SESSION_STATUS.RUNNING);
2015
- return buildSessionResponse(paths, {
2016
- preconditions,
2017
- status: SESSION_STATUS.RUNNING
2018
- });
2019
- }
2020
-
2021
- await writeBlueprintBaseline(paths);
2022
- const prompt = await renderPrompt(paths, "blueprint_updated.md", {
2023
- app_blueprint_file: blueprintPath,
2024
- changed_files: await changedFilesSinceBase(paths),
2025
- issue_file: path.join(paths.sessionRoot, "issue.md"),
2026
- issue_number: issueNumber,
2027
- issue_title: issueTitle,
2028
- issue_url: issueUrl,
2029
- worktree: paths.worktree
2030
- });
2031
- await writeTextFile(blueprintSentinelPath, "true\n");
2032
- await markStatus(paths, SESSION_STATUS.WAITING_FOR_USER);
2033
- return buildSessionResponse(paths, {
2034
- codex: BLUEPRINT_CODEX_HANDOFF,
2035
- ok: true,
2036
- preconditions,
2037
- prompt,
2038
- status: SESSION_STATUS.WAITING_FOR_USER
2039
- });
2040
- }
2041
-
2042
- async function readPackageJson(root) {
2043
- try {
2044
- return JSON.parse(await readFile(path.join(root, "package.json"), "utf8"));
2045
- } catch {
2046
- return {};
2047
- }
2048
- }
2049
-
2050
- async function doctorCommandForWorktree(worktree) {
2051
- const packageJson = await readPackageJson(worktree);
2052
- const scripts = packageJson && typeof packageJson.scripts === "object" ? packageJson.scripts : {};
2053
- if (scripts["verify:local"]) {
2054
- return ["npm", ["run", "verify:local"]];
2055
- }
2056
- if (scripts.verify) {
2057
- return ["npm", ["run", "verify"]];
2058
- }
2059
- return ["npx", ["--no-install", "jskit", "app", "verify"]];
2060
- }
2061
-
2062
- async function commitLinesSinceBase(paths) {
2063
- const baseCommit = await readTrimmedFile(path.join(paths.sessionRoot, "base_commit"));
2064
- const args = baseCommit
2065
- ? ["log", "--oneline", `${baseCommit}..HEAD`]
2066
- : ["log", "--oneline", "--max-count=10"];
2067
- const result = await runGitInWorktree(paths.worktree, args, {
2068
- timeout: 15000
2069
- });
2070
- return result.ok ? result.stdout.trim() : "";
2071
- }
2072
-
2073
- async function readCheckSummaries(paths) {
2074
- const checksRoot = path.join(paths.sessionRoot, "checks");
2075
- try {
2076
- const entries = await readdir(checksRoot, { withFileTypes: true });
2077
- const summaries = [];
2078
- for (const entry of entries.filter((item) => item.isFile() && item.name.endsWith(".json")).sort((left, right) => left.name.localeCompare(right.name))) {
2079
- const source = await readTextIfExists(path.join(checksRoot, entry.name));
2080
- const parsed = parseJsonObject(source);
2081
- if (parsed) {
2082
- summaries.push(`- ${parsed.stepId}: ${parsed.ok ? "passed" : "failed"} (${parsed.command})`);
2083
- }
2084
- }
2085
- return summaries.join("\n");
2086
- } catch {
2087
- return "";
2088
- }
2089
- }
2090
-
2091
- async function readUiCheckSummaries(paths) {
2092
- const uiChecksRoot = path.join(paths.sessionRoot, "ui_checks");
2093
- try {
2094
- const entries = await readdir(uiChecksRoot, { withFileTypes: true });
2095
- const summaries = [];
2096
- for (const entry of entries.filter((item) => item.isFile() && item.name.endsWith(".json")).sort((left, right) => left.name.localeCompare(right.name))) {
2097
- const source = await readTextIfExists(path.join(uiChecksRoot, entry.name));
2098
- const parsed = parseJsonObject(source);
2099
- if (parsed) {
2100
- summaries.push(`- ${parsed.stepId}: ${parsed.status || (parsed.ok ? "passed" : "failed")}${parsed.reason ? ` (${parsed.reason})` : ""}`);
2101
- }
2102
- }
2103
- return summaries.join("\n");
2104
- } catch {
2105
- return "";
2106
- }
2107
- }
2108
-
2109
- async function readReviewPassSummaries(paths) {
2110
- const passes = await readReviewPasses(paths);
2111
- return passes
2112
- .map((entry) => {
2113
- const changedFiles = Array.isArray(entry.changedFiles) && entry.changedFiles.length
2114
- ? `; changed files: ${entry.changedFiles.join(", ")}`
2115
- : "";
2116
- const commit = entry.commit ? `; commit: ${entry.commit}` : "";
2117
- return `- ${entry.label}: ${entry.status}${commit}${changedFiles}`;
2118
- })
2119
- .join("\n");
2120
- }
2121
-
2122
- async function renderPullRequestFilePrompt(paths, context = {}) {
2123
- const preconditions = context.preconditions || [];
2124
- const issueUrl = await readTrimmedFile(path.join(paths.sessionRoot, "issue_url"));
2125
- const issueTitle = await readTrimmedFile(path.join(paths.sessionRoot, "issue_title"));
2126
- const filesChanged = await changedFilesSinceBase(paths);
2127
- const commits = await commitLinesSinceBase(paths);
2128
- const checks = await readCheckSummaries(paths);
2129
- const uiChecks = await readUiCheckSummaries(paths);
2130
- const reviewPasses = await readReviewPassSummaries(paths);
2131
- const commandLogPath = path.join(paths.sessionRoot, "command_log.jsonl");
2132
- const blueprintStatus = await readTextIfExists(path.join(paths.sessionRoot, "steps", "blueprint_updated"));
2133
- const userCheck = await readTextIfExists(path.join(paths.sessionRoot, "steps", "user_check_completed")) ||
2134
- await readTextIfExists(path.join(paths.sessionRoot, "steps", `cycle_${await readActiveCycle(paths)}`, "user_check_completed"));
2135
- const prompt = await renderPrompt(paths, "final_report_created.md", {
2136
- base_branch: await readTrimmedFile(path.join(paths.sessionRoot, "base_branch")),
2137
- blueprint_status: blueprintStatus.trim() || "No blueprint update recorded.",
2138
- checks: checks || "No structured checks recorded.",
2139
- command_log: await fileExists(commandLogPath) ? commandLogPath : "No command log recorded.",
2140
- commits: commits || "No commits detected against the session base.",
2141
- files_changed: filesChanged || "No changed files detected against the session base.",
2142
- issue_file: path.join(paths.sessionRoot, "issue.md"),
2143
- issue_title: issueTitle || paths.sessionId,
2144
- issue_url: issueUrl || "",
2145
- pull_request_file: path.join(paths.sessionRoot, "pull_request.md"),
2146
- review_passes: reviewPasses || "No structured review passes recorded.",
2147
- session_id: paths.sessionId,
2148
- ui_checks: uiChecks || "No structured UI checks recorded.",
2149
- user_check: userCheck.trim() || "No user check recorded.",
2150
- worktree: paths.worktree
2151
- });
2152
- await writeTextFile(path.join(paths.sessionRoot, "metadata", "pull_request_file_requested"), "true\n");
2153
- await markStatus(paths, SESSION_STATUS.WAITING_FOR_USER);
2154
- return buildSessionResponse(paths, {
2155
- codex: PR_FILE_CODEX_HANDOFF,
2156
- ok: true,
2157
- preconditions,
2158
- prompt,
2159
- status: SESSION_STATUS.WAITING_FOR_USER
2160
- });
2161
- }
2162
-
2163
- async function createPullRequestFile(paths, _options = {}, context = {}) {
2164
- const preconditions = context.preconditions || [];
2165
- const pullRequestText = await readTrimmedFile(path.join(paths.sessionRoot, "pull_request.md"));
2166
- if (!pullRequestText || context.completeStep === false) {
2167
- return renderPullRequestFilePrompt(paths, context);
2168
- }
2169
- await writeStepRecord(paths, "final_report_created", "Pull request file is ready for review and submission.");
2170
- await markStatus(paths, SESSION_STATUS.RUNNING);
2171
- return buildSessionResponse(paths, {
2172
- preconditions
2173
- });
2174
- }
2175
-
2176
- function issueNumberFromUrl(issueUrl) {
2177
- const match = /\/issues\/(\d+)(?:\b|$)/u.exec(String(issueUrl || ""));
2178
- return match ? match[1] : "";
2179
- }
2180
-
2181
- function parseJsonObject(value) {
2182
- try {
2183
- const parsed = JSON.parse(String(value || ""));
2184
- return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : null;
2185
- } catch {
2186
- return null;
2187
- }
2188
- }
2189
-
2190
- function booleanOption(options = {}, ...names) {
2191
- return names.some((name) => {
2192
- const value = options[name];
2193
- return value === true || normalizeText(value).toLowerCase() === "true";
2194
- });
2195
- }
2196
-
2197
- function skipStepRequested(options = {}) {
2198
- return booleanOption(options, "skipStep", "skip-step", "skip");
2199
- }
2200
-
2201
- function skipStepReason(options = {}, stepId = "") {
2202
- return normalizeText(options.skipReason || options["skip-reason"]) ||
2203
- `User skipped ${STEP_DEFINITION_BY_ID[stepId]?.label || stepId}.`;
2204
- }
2205
-
2206
- async function writeJsonFile(filePath, payload) {
2207
- await writeTextFile(filePath, `${JSON.stringify(payload, null, 2)}\n`);
2208
- }
2209
-
2210
- async function writeTextIfMissing(filePath, value) {
2211
- if (await fileExists(filePath)) {
2212
- return;
2213
- }
2214
- await writeTextFile(filePath, value);
2215
- }
2216
-
2217
- async function writeSkippedIssueDraft(paths, reason) {
2218
- await writeTextIfMissing(path.join(paths.sessionRoot, "issue_title"), `Skipped issue draft for ${paths.sessionId}\n`);
2219
- await writeTextIfMissing(
2220
- path.join(paths.sessionRoot, "issue.md"),
2221
- `# Skipped issue draft\n\n${reason}\n`
2222
- );
2223
- }
2224
-
2225
- async function writeSkippedReviewPass(paths, reason) {
2226
- const reviewPass = normalizeReviewPassNumber(await readTrimmedFile(path.join(paths.sessionRoot, "review_passes", "current_pass")) || "001");
2227
- await writeCurrentReviewPass(paths, reviewPass);
2228
- await writeReviewPassJson(paths, reviewPass, "accepted.json", {
2229
- acceptedAt: timestampForStepRecord(),
2230
- changedFiles: [],
2231
- findingsRemaining: false,
2232
- pass: reviewPass,
2233
- reason,
2234
- status: "skipped"
2235
- });
2236
- }
2237
-
2238
- async function writeSkippedStepArtifacts(paths, stepId, reason) {
2239
- if (stepId === "issue_created") {
2240
- await writeSkippedIssueDraft(paths, reason);
2241
- }
2242
- if (stepId === "issue_submitted") {
2243
- await writeTextIfMissing(path.join(paths.sessionRoot, "issue_url"), `skipped://${paths.sessionId}/issue\n`);
2244
- }
2245
- if (stepId === "deep_ui_check_run") {
2246
- await writeUiCheckJson(paths, stepId, {
2247
- ok: true,
2248
- reason,
2249
- status: "skipped",
2250
- stepId
2251
- });
2252
- }
2253
- if (stepId === "automated_checks_run") {
2254
- await mkdir(path.join(paths.sessionRoot, "checks"), { recursive: true });
2255
- await writeJsonFile(path.join(paths.sessionRoot, "checks", `${stepId}.json`), {
2256
- ok: true,
2257
- reason,
2258
- status: "skipped",
2259
- stepId
2260
- });
2261
- }
2262
- if (stepId === "review_prompt_rendered") {
2263
- await writeSkippedReviewPass(paths, reason);
2264
- }
2265
- if (stepId === "review_changes_accepted") {
2266
- await writeSkippedReviewPass(paths, reason);
2267
- }
2268
- if (stepId === "changes_committed") {
2269
- await writeJsonFile(path.join(paths.sessionRoot, "changes_committed.json"), {
2270
- changedFiles: [],
2271
- commit: await currentHead(paths),
2272
- committedAt: timestampForStepRecord(),
2273
- noChanges: true,
2274
- reason
2275
- });
2276
- }
2277
- if (stepId === "final_report_created") {
2278
- await writeTextIfMissing(
2279
- path.join(paths.sessionRoot, "pull_request.md"),
2280
- `# Pull Request: ${paths.sessionId}\n\nPull request file step skipped.\n\n${reason}\n`
2281
- );
2282
- }
2283
- if (stepId === "pr_created") {
2284
- await writeTextIfMissing(path.join(paths.sessionRoot, "pr_url"), `skipped://${paths.sessionId}/pr\n`);
2285
- }
2286
- if (stepId === "pr_merge_prepared") {
2287
- const prUrl = await readTrimmedFile(path.join(paths.sessionRoot, "pr_url"));
2288
- await writePrOutcome(paths, {
2289
- outcome: "skipped",
2290
- prUrl,
2291
- reason
2292
- });
2293
- }
2294
- if (stepId === "main_checkout_synced") {
2295
- await writeMainCheckoutSync(paths, {
2296
- reason,
2297
- status: "skipped"
2298
- });
2299
- }
2300
- }
2301
-
2302
- async function skipCurrentStep(paths, stepId, options = {}) {
2303
- if (["worktree_created", "dependencies_installed", "issue_prompt_rendered", "session_finished"].includes(stepId)) {
2304
- return failSession(paths, {
2305
- code: "session_step_skip_not_allowed",
2306
- message: `Step ${stepId} cannot be skipped.`,
2307
- repairCommand: `jskit session ${paths.sessionId} step`
2308
- });
2309
- }
2310
- const reason = skipStepReason(options, stepId);
2311
- await writeSkippedStepArtifacts(paths, stepId, reason);
2312
- await writeStepRecord(paths, stepId, `Skipped: ${reason}`);
2313
- await markStatus(paths, SESSION_STATUS.RUNNING);
2314
- return buildSessionResponse(paths, {
2315
- warnings: [
2316
- {
2317
- code: "session_step_skipped",
2318
- message: `${STEP_DEFINITION_BY_ID[stepId]?.label || stepId} was skipped.`
2319
- }
2320
- ]
2321
- });
2322
- }
2323
-
2324
- async function readPrState(paths, prUrl) {
2325
- const prRef = normalizeText(prUrl);
2326
- const args = prRef
2327
- ? ["pr", "view", prRef, "--json", "state,mergedAt,url,baseRefName"]
2328
- : ["pr", "view", "--json", "state,mergedAt,url,baseRefName"];
2329
- const result = await runLoggedCommand(paths, prRef ? "github_pr_view" : "github_pr_view_current_branch", "gh", args, {
2330
- cwd: paths.targetRoot,
2331
- timeout: 1000 * 60
2332
- });
2333
- if (!result.ok) {
2334
- return {
2335
- ok: false,
2336
- output: result.output
2337
- };
2338
- }
2339
- const payload = parseJsonObject(result.stdout);
2340
- return {
2341
- baseRefName: normalizeText(payload?.baseRefName),
2342
- mergedAt: payload?.mergedAt || "",
2343
- ok: Boolean(payload),
2344
- output: result.output,
2345
- state: String(payload?.state || "").toUpperCase(),
2346
- url: payload?.url || prRef
2347
- };
2348
- }
2349
-
2350
- async function readCurrentBranchPrState(paths) {
2351
- const result = await runLoggedCommand(paths, "github_pr_view_current_branch", "gh", ["pr", "view", "--json", "state,mergedAt,url,baseRefName"], {
2352
- cwd: paths.worktree,
2353
- timeout: 1000 * 60
2354
- });
2355
- if (!result.ok) {
2356
- return {
2357
- ok: false,
2358
- output: result.output
2359
- };
2360
- }
2361
- const payload = parseJsonObject(result.stdout);
2362
- return {
2363
- baseRefName: normalizeText(payload?.baseRefName),
2364
- mergedAt: payload?.mergedAt || "",
2365
- ok: Boolean(payload?.url),
2366
- output: result.output,
2367
- state: String(payload?.state || "").toUpperCase(),
2368
- url: payload?.url || ""
2369
- };
2370
- }
2371
-
2372
- function prStateIsMerged(prState) {
2373
- return Boolean(prState?.ok && prState.state === "MERGED");
2374
- }
2375
-
2376
- function prStateIsClosed(prState) {
2377
- return Boolean(prState?.ok && prState.state === "CLOSED");
2378
- }
2379
-
2380
- async function currentTargetBranch(targetRoot) {
2381
- const result = await runGit(targetRoot, ["branch", "--show-current"], {
2382
- timeout: 15000
2383
- });
2384
- return result.ok ? normalizeText(result.stdout) : "";
2385
- }
2386
-
2387
- async function assertTargetRootCleanForBaseUpdate(paths) {
2388
- const status = await runGit(paths.targetRoot, ["status", "--porcelain=v1"], {
2389
- timeout: 15000
2390
- });
2391
- if (!status.ok) {
2392
- return failSession(paths, {
2393
- code: "target_root_status_failed",
2394
- message: status.output || "Failed to inspect target root git status before updating the local base branch.",
2395
- repairCommand: `git -C ${paths.targetRoot} status --short`
2396
- });
2397
- }
2398
- if (status.stdout.trim()) {
2399
- return failSession(paths, {
2400
- code: "target_root_dirty",
2401
- message: "Target root has uncommitted changes; JSKIT cannot update the local base branch after merging the PR.",
2402
- repairCommand: `git -C ${paths.targetRoot} status --short`
2403
- });
2404
- }
2405
- return null;
2406
- }
2407
-
2408
- async function removeSessionWorktree(paths) {
2409
- if (await hasWorktree(paths)) {
2410
- const result = await runLoggedCommand(paths, "git_worktree_remove", "git", ["worktree", "remove", paths.worktree], {
2411
- cwd: paths.targetRoot,
2412
- timeout: 1000 * 60
2413
- });
2414
- if (!result.ok) {
2415
- return failSession(paths, {
2416
- code: "worktree_remove_failed",
2417
- message: result.output || "Failed to remove worktree.",
2418
- repairCommand: `git worktree remove ${paths.worktree}`
2419
- });
2420
- }
2421
- }
2422
- return null;
2423
- }
2424
-
2425
- async function writePrOutcome(paths, outcome) {
2426
- await writeTextFile(path.join(paths.sessionRoot, "pr_outcome.json"), `${JSON.stringify({
2427
- recordedAt: timestampForStepRecord(),
2428
- ...outcome
2429
- }, null, 2)}\n`);
2430
- }
2431
-
2432
- function mainCheckoutSyncPath(paths) {
2433
- return path.join(paths.sessionRoot, "main_checkout_sync.json");
2434
- }
2435
-
2436
- async function writeMainCheckoutSync(paths, payload = {}) {
2437
- await writeTextFile(mainCheckoutSyncPath(paths), `${JSON.stringify({
2438
- recordedAt: timestampForStepRecord(),
2439
- ...payload
2440
- }, null, 2)}\n`);
2441
- }
2442
-
2443
- async function assertTargetRootCanUpdateBase(paths, branch) {
2444
- const cleanFailure = await assertTargetRootCleanForBaseUpdate(paths);
2445
- if (cleanFailure) {
2446
- return cleanFailure;
2447
- }
2448
-
2449
- const currentBranch = await currentTargetBranch(paths.targetRoot);
2450
- if (currentBranch !== branch) {
2451
- return failSession(paths, {
2452
- code: "target_branch_mismatch",
2453
- message: `Target root is on branch ${currentBranch || "(detached)"}, but the merged PR targets ${branch}. JSKIT will not merge origin/${branch} into the wrong branch.`,
2454
- repairCommand: `git -C ${paths.targetRoot} switch ${branch} && git -C ${paths.targetRoot} pull --ff-only origin ${branch}`
2455
- });
2456
- }
2457
-
2458
- return null;
2459
- }
2460
-
2461
- async function updateLocalBaseBranch(paths, baseBranch = "") {
2462
- const branch = normalizeText(baseBranch) || await currentTargetBranch(paths.targetRoot);
2463
- if (!branch) {
2464
- return failSession(paths, {
2465
- code: "target_branch_missing",
2466
- message: "Target root is not on a named branch; JSKIT cannot update the local base branch after merging the PR.",
2467
- repairCommand: `git -C ${paths.targetRoot} branch --show-current`
2468
- });
2469
- }
2470
-
2471
- const branchFailure = await assertTargetRootCanUpdateBase(paths, branch);
2472
- if (branchFailure) {
2473
- return branchFailure;
2474
- }
2475
-
2476
- const fetchResult = await runGit(paths.targetRoot, ["fetch", "origin"], {
2477
- timeout: 1000 * 60 * 5
2478
- });
2479
- if (!fetchResult.ok) {
2480
- return failSession(paths, {
2481
- code: "target_fetch_failed",
2482
- message: fetchResult.output || `Failed to fetch origin before updating local ${branch}.`,
2483
- repairCommand: `git -C ${paths.targetRoot} fetch origin`
2484
- });
2485
- }
2486
-
2487
- const pullResult = await runLoggedCommand(paths, "git_pull_base", "git", ["pull", "--ff-only", "origin", branch], {
2488
- cwd: paths.targetRoot,
2489
- timeout: 1000 * 60 * 5
2490
- });
2491
- if (!pullResult.ok) {
2492
- return failSession(paths, {
2493
- code: "target_pull_failed",
2494
- message: pullResult.output || `Failed to fast-forward local ${branch} after merging the PR.`,
2495
- repairCommand: `git -C ${paths.targetRoot} pull --ff-only origin ${branch}`
2496
- });
2497
- }
2498
-
2499
- await writeTextFile(path.join(paths.sessionRoot, "local_base_updated"), `${branch}\n${pullResult.output}\n`);
2500
- return null;
2501
- }
2502
-
2503
- async function syncMainCheckout(paths, options = {}, context = {}) {
2504
- const prUrl = await readTrimmedFile(path.join(paths.sessionRoot, "pr_url"));
2505
- const prOutcome = parseJsonObject(await readTextIfExists(path.join(paths.sessionRoot, "pr_outcome.json")));
2506
- const preconditions = context.preconditions || [];
2507
- const completeStep = context.completeStep !== false;
2508
- if (!prUrl) {
2509
- return sessionStepError(paths, {
2510
- code: "pr_url_missing",
2511
- message: "Cannot sync the main checkout until the GitHub pull request exists.",
2512
- repairCommand: `jskit session ${paths.sessionId} create_pr_on_gh`
2513
- });
2514
- }
2515
- if (!prOutcome?.outcome) {
2516
- return sessionStepError(paths, {
2517
- code: "pr_outcome_missing",
2518
- message: "Cannot sync the main checkout before the PR merge step records an outcome.",
2519
- repairCommand: `jskit session ${paths.sessionId} next`
2520
- });
2521
- }
2522
-
2523
- void options;
2524
- if (prOutcome.outcome !== "merged") {
2525
- return sessionStepError(paths, {
2526
- code: "main_checkout_sync_unavailable",
2527
- message: `Cannot sync the main checkout because the PR outcome is ${prOutcome.outcome}.`,
2528
- repairCommand: `jskit session ${paths.sessionId} next`
2529
- });
2530
- }
2531
- const baseBranch = prOutcome.baseBranch || await readTrimmedFile(path.join(paths.sessionRoot, "pr_base_branch"));
2532
- const syncFailure = await updateLocalBaseBranch(paths, baseBranch);
2533
- if (syncFailure) {
2534
- return syncFailure;
2535
- }
2536
-
2537
- const branch = normalizeText(baseBranch) || await currentTargetBranch(paths.targetRoot);
2538
- await writeMainCheckoutSync(paths, {
2539
- branch,
2540
- outcome: prOutcome.outcome,
2541
- status: "synced"
2542
- });
2543
- if (completeStep) {
2544
- await writeStepRecord(paths, "main_checkout_synced", `Fast-forwarded target checkout branch ${branch}.`);
2545
- }
2546
- await markStatus(paths, SESSION_STATUS.RUNNING);
2547
- return buildSessionResponse(paths, {
2548
- preconditions
2549
- });
2550
- }
2551
-
2552
- async function updateHelperMapBeforePr(paths) {
2553
- let helperMapPayload;
2554
- try {
2555
- const { updateHelperMap } = await import("./helperMap.js");
2556
- helperMapPayload = await updateHelperMap({
2557
- targetRoot: paths.worktree
2558
- });
2559
- } catch (error) {
2560
- return {
2561
- ok: false,
2562
- code: "helper_map_update_failed",
2563
- message: String(error?.message || error),
2564
- repairCommand: `git -C ${paths.worktree} status --short`
2565
- };
2566
- }
2567
-
2568
- const statusResult = await runGitInWorktree(paths.worktree, [
2569
- "status",
2570
- "--porcelain=v1",
2571
- "--",
2572
- HELPER_MAP_JSON_RELATIVE_PATH,
2573
- HELPER_MAP_MARKDOWN_RELATIVE_PATH
2574
- ], {
2575
- timeout: 15000
2576
- });
2577
- if (!statusResult.ok) {
2578
- return {
2579
- ok: false,
2580
- code: "helper_map_status_failed",
2581
- message: statusResult.output || "Failed to inspect helper-map Git status.",
2582
- repairCommand: `git -C ${paths.worktree} status --short -- ${HELPER_MAP_JSON_RELATIVE_PATH} ${HELPER_MAP_MARKDOWN_RELATIVE_PATH}`
2583
- };
2584
- }
2585
-
2586
- if (!statusResult.stdout.trim()) {
2587
- return {
2588
- ok: true,
2589
- changed: false,
2590
- message: "Helper map already up to date."
2591
- };
2592
- }
2593
-
2594
- const addResult = await runGitInWorktree(paths.worktree, [
2595
- "add",
2596
- HELPER_MAP_JSON_RELATIVE_PATH,
2597
- HELPER_MAP_MARKDOWN_RELATIVE_PATH
2598
- ], {
2599
- timeout: 15000
2600
- });
2601
- if (!addResult.ok) {
2602
- return {
2603
- ok: false,
2604
- code: "helper_map_add_failed",
2605
- message: addResult.output || "Failed to stage helper-map files.",
2606
- repairCommand: `git -C ${paths.worktree} add ${HELPER_MAP_JSON_RELATIVE_PATH} ${HELPER_MAP_MARKDOWN_RELATIVE_PATH}`
2607
- };
2608
- }
2609
-
2610
- const commitResult = await runGitInWorktree(paths.worktree, [
2611
- "commit",
2612
- "-m",
2613
- `Update JSKIT helper map for ${paths.sessionId}`
2614
- ], {
2615
- timeout: 1000 * 60
2616
- });
2617
- if (!commitResult.ok) {
2618
- return {
2619
- ok: false,
2620
- code: "helper_map_commit_failed",
2621
- message: commitResult.output || "Failed to commit helper-map update.",
2622
- repairCommand: `git -C ${paths.worktree} commit -m "Update JSKIT helper map for ${paths.sessionId}"`
2623
- };
2624
- }
2625
-
2626
- return {
2627
- ok: true,
2628
- changed: true,
2629
- message: `Updated helper map at ${path.relative(paths.worktree, helperMapPayload.helperMapMarkdownPath)}.`
2630
- };
2631
- }
2632
-
2633
- async function createPr(paths, _options = {}, context = {}) {
2634
- const preconditions = context.preconditions || [];
2635
- const completeStep = context.completeStep !== false;
2636
- const existingPrUrl = await readTrimmedFile(path.join(paths.sessionRoot, "pr_url"));
2637
- if (existingPrUrl) {
2638
- if (completeStep) {
2639
- await writeStepRecord(paths, "pr_created", `Reused existing PR ${existingPrUrl}.`);
2640
- }
2641
- await markStatus(paths, SESSION_STATUS.RUNNING);
2642
- return buildSessionResponse(paths, {
2643
- preconditions
2644
- });
2645
- }
2646
- const pullRequestPath = path.join(paths.sessionRoot, "pull_request.md");
2647
- const pullRequestText = await readTrimmedFile(pullRequestPath);
2648
- if (!pullRequestText) {
2649
- return sessionStepError(paths, {
2650
- code: "pull_request_file_missing",
2651
- message: "Cannot create the GitHub pull request until pull_request.md exists.",
2652
- repairCommand: `jskit session ${paths.sessionId} create_pull_request_file`
2653
- });
2654
- }
2655
- const helperMapResult = await updateHelperMapBeforePr(paths);
2656
- if (!helperMapResult.ok) {
2657
- return failSession(paths, {
2658
- code: helperMapResult.code,
2659
- message: helperMapResult.message,
2660
- repairCommand: helperMapResult.repairCommand,
2661
- preconditions
2662
- });
2663
- }
2664
-
2665
- const pushResult = await runLoggedCommand(paths, "git_push_branch", "git", ["push", "-u", "origin", "HEAD"], {
2666
- cwd: paths.worktree,
2667
- timeout: 1000 * 60 * 5
2668
- });
2669
- if (!pushResult.ok) {
2670
- return failSession(paths, {
2671
- code: "branch_push_failed",
2672
- message: pushResult.output || "Failed to push session branch.",
2673
- repairCommand: `git -C ${paths.worktree} push -u origin HEAD`,
2674
- preconditions
2675
- });
2676
- }
2677
- const existingPrState = await readCurrentBranchPrState(paths);
2678
- if (existingPrState.ok && existingPrState.url && !prStateIsClosed(existingPrState)) {
2679
- await writeTextFile(path.join(paths.sessionRoot, "pr_url"), existingPrState.url);
2680
- if (completeStep) {
2681
- await writeStepRecord(paths, "pr_created", `Pushed branch ${paths.branch} and reused existing PR ${existingPrState.url}. ${helperMapResult.message}`);
2682
- }
2683
- await markStatus(paths, SESSION_STATUS.RUNNING);
2684
- return buildSessionResponse(paths, {
2685
- preconditions
2686
- });
2687
- }
2688
- const issueText = await readTrimmedFile(path.join(paths.sessionRoot, "issue.md"));
2689
- const issueTitle = await readTrimmedFile(path.join(paths.sessionRoot, "issue_title")) || titleFromIssue(issueText);
2690
- const result = await runLoggedCommand(paths, "github_pr_create", "gh", [
2691
- "pr",
2692
- "create",
2693
- "--title",
2694
- issueTitle,
2695
- "--body-file",
2696
- pullRequestPath
2697
- ], {
2698
- cwd: paths.worktree,
2699
- timeout: 1000 * 60
2700
- });
2701
- if (!result.ok || !result.stdout) {
2702
- const fallbackPrState = await readCurrentBranchPrState(paths);
2703
- if (fallbackPrState.ok && fallbackPrState.url && !prStateIsClosed(fallbackPrState)) {
2704
- await writeTextFile(path.join(paths.sessionRoot, "pr_url"), fallbackPrState.url);
2705
- if (completeStep) {
2706
- await writeStepRecord(paths, "pr_created", `Pushed branch ${paths.branch} and reused existing PR ${fallbackPrState.url}. ${helperMapResult.message}`);
2707
- }
2708
- await markStatus(paths, SESSION_STATUS.RUNNING);
2709
- return buildSessionResponse(paths, {
2710
- preconditions
2711
- });
2712
- }
2713
- const prompt = await renderPrompt(paths, "pr_failure.md", {
2714
- doctor_output: result.output
2715
- });
2716
- await writePromptArtifact(paths, "pr_create_failure", prompt);
2717
- return failSession(paths, {
2718
- code: "pr_create_failed",
2719
- message: result.output || "Failed to create PR.",
2720
- repairCommand: "gh pr create",
2721
- preconditions,
2722
- prompt
2723
- });
2724
- }
2725
- const prUrl = result.stdout.split(/\r?\n/u).map((line) => line.trim()).find(Boolean) || result.stdout;
2726
- await writeTextFile(path.join(paths.sessionRoot, "pr_url"), prUrl);
2727
- if (completeStep) {
2728
- await writeStepRecord(paths, "pr_created", `Pushed branch ${paths.branch} and created PR ${prUrl}. ${helperMapResult.message}`);
2729
- }
2730
- await markStatus(paths, SESSION_STATUS.RUNNING);
2731
- return buildSessionResponse(paths, {
2732
- preconditions
2733
- });
2734
- }
2735
-
2736
- async function preparePrMerge(paths, options = {}, context = {}) {
2737
- const preconditions = context.preconditions || [];
2738
- void options;
2739
- const prUrl = await readTrimmedFile(path.join(paths.sessionRoot, "pr_url"));
2740
- if (!prUrl) {
2741
- return sessionStepError(paths, {
2742
- code: "pr_url_missing",
2743
- message: "Cannot prepare the pull request for merge until the GitHub pull request exists.",
2744
- repairCommand: `jskit session ${paths.sessionId} create_pr_on_gh`
2745
- });
2746
- }
2747
- const baseBranch = await readTrimmedFile(path.join(paths.sessionRoot, "pr_base_branch")) ||
2748
- await readTrimmedFile(path.join(paths.sessionRoot, "base_branch")) ||
2749
- await currentTargetBranch(paths.targetRoot);
2750
- const prompt = await renderPrompt(paths, "pr_merge_prepared.md", {
2751
- base_branch: baseBranch,
2752
- issue_url: await readTrimmedFile(path.join(paths.sessionRoot, "issue_url")),
2753
- pull_request_file: path.join(paths.sessionRoot, "pull_request.md"),
2754
- pr_url: prUrl,
2755
- target_root: paths.targetRoot
2756
- });
2757
- await writePromptArtifact(paths, "pr_merge_prepared", prompt);
2758
- await markStatus(paths, SESSION_STATUS.WAITING_FOR_USER);
2759
- return buildSessionResponse(paths, {
2760
- codex: PR_MERGE_PREP_CODEX_HANDOFF,
2761
- ok: true,
2762
- preconditions,
2763
- prompt,
2764
- status: SESSION_STATUS.WAITING_FOR_USER
2765
- });
2766
- }
2767
-
2768
- async function finalizePr(paths, options = {}, context = {}) {
2769
- const preconditions = context.preconditions || [];
2770
- const completeStep = context.completeStep !== false;
2771
- const prUrl = await readTrimmedFile(path.join(paths.sessionRoot, "pr_url"));
2772
- const mergePr = options.mergePr === true ||
2773
- normalizeText(options["merge-pr"]).toLowerCase() === "true";
2774
- if (!prUrl) {
2775
- return sessionStepError(paths, {
2776
- code: "pr_url_missing",
2777
- message: "Cannot merge the pull request until the GitHub pull request exists.",
2778
- repairCommand: `jskit session ${paths.sessionId} create_pr_on_gh`
2779
- });
2780
- }
2781
- if (!mergePr) {
2782
- return failSession(paths, {
2783
- code: "pr_finalize_decision_required",
2784
- message: "Choose whether to merge the PR or skip merge.",
2785
- repairCommand: `jskit session ${paths.sessionId} merge_pr`,
2786
- preconditions
2787
- });
2788
- }
2789
- const guardResult = await runSessionFinalizationGuard(paths, preconditions);
2790
- if (!guardResult.ok) {
2791
- return guardResult.response;
2792
- }
2793
- const mergeMarkerPath = path.join(paths.sessionRoot, "pr_merge_completed");
2794
- const baseBranchPath = path.join(paths.sessionRoot, "pr_base_branch");
2795
- const mergeAlreadyCompleted = await readTrimmedFile(mergeMarkerPath);
2796
- let baseBranch = await readTrimmedFile(baseBranchPath);
2797
- if (!mergeAlreadyCompleted) {
2798
- const existingPrState = await readPrState(paths, prUrl);
2799
- baseBranch = existingPrState.baseRefName || baseBranch || await currentTargetBranch(paths.targetRoot);
2800
- if (baseBranch) {
2801
- await writeTextFile(baseBranchPath, `${baseBranch}\n`);
2802
- }
2803
- let prMerged = prStateIsMerged(existingPrState);
2804
- let mergeResult = null;
2805
- if (!prMerged) {
2806
- mergeResult = await runLoggedCommand(paths, "github_pr_merge", "gh", ["pr", "merge", prUrl, "--merge", "--delete-branch"], {
2807
- cwd: paths.targetRoot,
2808
- timeout: 1000 * 60 * 5
2809
- });
2810
- if (!mergeResult.ok) {
2811
- prMerged = prStateIsMerged(await readPrState(paths, prUrl));
2812
- } else {
2813
- prMerged = true;
2814
- }
2815
- }
2816
- if (!prMerged) {
2817
- const prompt = await renderPrompt(paths, "pr_failure.md", {
2818
- doctor_output: mergeResult?.output || existingPrState.output
2819
- });
2820
- await writePromptArtifact(paths, "pr_merge_failure", prompt);
2821
- return failSession(paths, {
2822
- code: "pr_merge_failed",
2823
- message: mergeResult?.output || existingPrState.output || "Failed to merge PR.",
2824
- repairCommand: `gh pr merge ${prUrl} --merge --delete-branch`,
2825
- preconditions,
2826
- prompt
2827
- });
2828
- }
2829
- const issueUrl = await readTrimmedFile(path.join(paths.sessionRoot, "issue_url"));
2830
- if (issueUrl) {
2831
- await runLoggedCommand(paths, "github_issue_close", "gh", ["issue", "close", issueUrl, "--comment", `Merged PR ${prUrl}.`], {
2832
- cwd: paths.targetRoot,
2833
- timeout: 1000 * 60
2834
- });
2835
- }
2836
- await writePrOutcome(paths, {
2837
- baseBranch,
2838
- issueUrl,
2839
- outcome: "merged",
2840
- prUrl
2841
- });
2842
- await writeTextFile(mergeMarkerPath, `${prUrl}\n`);
2843
- }
2844
- if (completeStep) {
2845
- await writeStepRecord(paths, "pr_merge_prepared", `Merged PR ${prUrl}.`);
2846
- }
2847
- await markStatus(paths, SESSION_STATUS.RUNNING);
2848
- return buildSessionResponse(paths, {
2849
- preconditions
2850
- });
2851
- }
2852
-
2853
- async function finishSession(paths) {
2854
- const prUrl = await readTrimmedFile(path.join(paths.sessionRoot, "pr_url"));
2855
- const issueUrl = await readTrimmedFile(path.join(paths.sessionRoot, "issue_url"));
2856
- const codexThreadId = await readTrimmedFile(path.join(paths.sessionRoot, "codex_thread_id"));
2857
- const prOutcome = parseJsonObject(await readTextIfExists(path.join(paths.sessionRoot, "pr_outcome.json")));
2858
- const removeFailure = await removeSessionWorktree(paths);
2859
- if (removeFailure) {
2860
- return removeFailure;
2861
- }
2862
- const prompt = await renderPrompt(paths, "final_comment.md", {
2863
- codex_thread_id: codexThreadId,
2864
- issue_url: issueUrl,
2865
- pr_outcome: prOutcome?.outcome || "unknown",
2866
- pr_outcome_reason: prOutcome?.reason || "",
2867
- pr_url: prUrl,
2868
- session_id: paths.sessionId,
2869
- transcript_log: path.join(paths.completedSessionRoot, "transcript.log")
2870
- });
2871
- const finalCommentPath = path.join(paths.sessionRoot, "final_comment");
2872
- await writeTextFile(finalCommentPath, prompt);
2873
- if (issueUrl) {
2874
- await runLoggedCommand(paths, "github_issue_comment", "gh", ["issue", "comment", issueUrl, "--body-file", finalCommentPath], {
2875
- cwd: paths.targetRoot,
2876
- timeout: 1000 * 60
2877
- });
2878
- }
2879
- await writeStepRecord(paths, "session_finished", `Removed worktree ${paths.worktree} and finished session ${paths.sessionId} with PR outcome ${prOutcome?.outcome || "unknown"}.`);
2880
- await markStatus(paths, SESSION_STATUS.FINISHED);
2881
- await markCurrentStep(paths, "");
2882
- const archivedPaths = await archiveSession(paths, "completed");
2883
- return buildSessionResponse(archivedPaths, {
2884
- status: SESSION_STATUS.FINISHED
2885
- });
2886
- }
2887
-
2888
- const STEP_RUNNERS = Object.freeze({
2889
- worktree_created: createWorktree,
2890
- dependencies_installed: installDependencies,
2891
- issue_prompt_rendered: renderIssuePrompt,
2892
- issue_created: createIssue,
2893
- issue_submitted: submitIssue,
2894
- plan_made: makePlan,
2895
- plan_executed: renderPlanExecutionPrompt,
2896
- automated_checks_run: (paths, options, context) => runAutomatedChecks(paths, {
2897
- stepId: "automated_checks_run"
2898
- }, options, context),
2899
- deep_ui_check_run: (paths, options, context) => runDeepUiCheck(paths, {
2900
- phase: "pre_review",
2901
- stepId: "deep_ui_check_run"
2902
- }, options, context),
2903
- review_prompt_rendered: renderReviewPrompt,
2904
- review_changes_accepted: acceptReviewChanges,
2905
- user_check_completed: userCheck,
2906
- changes_committed: commitAcceptedChanges,
2907
- blueprint_updated: updateBlueprint,
2908
- final_report_created: createPullRequestFile,
2909
- pr_created: createPr,
2910
- pr_merge_prepared: preparePrMerge,
2911
- main_checkout_synced: syncMainCheckout,
2912
- session_finished: finishSession
2913
- });
2914
-
2915
- const PRECONDITION_RUNNERS = Object.freeze({
2916
- accepted_changes_committed: assertAcceptedChangesCommitted,
2917
- active_cycle_exists: assertActiveCycleExists,
2918
- active_cycle_user_check_passed: assertActiveCycleUserCheckPassed,
2919
- user_check_passed: assertUserCheckPassed,
2920
- blueprint_update_satisfied: assertBlueprintUpdateSatisfied,
2921
- deep_ui_check_satisfied: assertDeepUiCheckSatisfied,
2922
- dependencies_installed: assertDependenciesInstalled,
2923
- pull_request_file_exists: assertPullRequestFileExists,
2924
- git_current_branch: (paths) => assertGitCurrentBranch(paths.targetRoot),
2925
- git_repository: (paths) => assertGitRepository(paths.targetRoot),
2926
- github_auth: (paths) => assertGhAuth(paths.targetRoot),
2927
- github_origin: (paths) => assertGithubOrigin(paths.targetRoot),
2928
- issue_text_exists: assertIssueTextExists,
2929
- issue_url_exists: assertIssueUrlExists,
2930
- automated_checks_passed: assertAutomatedChecksPassed,
2931
- main_checkout_sync_satisfied: assertMainCheckoutSyncSatisfied,
2932
- pr_url_exists: assertPrUrlExists,
2933
- ready_jskit_app: assertReadyJskitApp,
2934
- session_exists: assertSessionExists,
2935
- worktree_exists: assertWorktreeExists
2936
- });
2937
-
2938
- async function runNamedPreconditions(paths, names = []) {
2939
- return applyPreconditions(
2940
- paths,
2941
- names.map((name) => {
2942
- return async () => PRECONDITION_RUNNERS[name](paths);
2943
- })
2944
- );
2945
- }
2946
-
2947
- function sessionStepError(paths, {
2948
- code,
2949
- message,
2950
- repairCommand = ""
2951
- } = {}) {
2952
- return buildSessionResponse(paths, {
2953
- ok: false,
2954
- errors: [
2955
- createError({
2956
- code,
2957
- message,
2958
- repairCommand
2959
- })
2960
- ]
2961
- });
2962
- }
2963
-
2964
- async function createIssueFileAction(paths, options = {}, context = {}) {
2965
- void options;
2966
- const artifacts = await readSessionArtifacts(paths);
2967
- if (artifacts.nextStep === "issue_prompt_rendered") {
2968
- if (!artifacts.issueDefinitionRequested) {
2969
- return sessionStepError(paths, {
2970
- code: "issue_prompt_missing",
2971
- message: "Cannot create the issue-file prompt until the issue-definition prompt has been created.",
2972
- repairCommand: `jskit session ${paths.sessionId} define_issue --prompt "<what should change>"`
2973
- });
2974
- }
2975
- await writeStepRecord(paths, "issue_prompt_rendered", "Issue scoped in Codex terminal.");
2976
- await markStatus(paths, SESSION_STATUS.RUNNING);
2977
- }
2978
- return renderIssueFilePrompt(paths, context);
2979
- }
2980
-
2981
- async function createGithubIssueAction(paths, options = {}, context = {}) {
2982
- const issueText = await readTrimmedFile(path.join(paths.sessionRoot, "issue.md"));
2983
- if (!issueText) {
2984
- return sessionStepError(paths, {
2985
- code: "issue_file_missing",
2986
- message: "Cannot create the GitHub issue until issue.md exists.",
2987
- repairCommand: `jskit session ${paths.sessionId} create_issue_file`
2988
- });
2989
- }
2990
- return submitIssue(paths, options, context);
2991
- }
2992
-
2993
- const STEP_ACTION_RUNNERS = Object.freeze({
2994
- worktree_created: Object.freeze({
2995
- create_worktree: createWorktree
2996
- }),
2997
- dependencies_installed: Object.freeze({
2998
- run_npm_install: installDependencies
2999
- }),
3000
- issue_prompt_rendered: Object.freeze({
3001
- create_issue_file: createIssueFileAction,
3002
- define_issue: renderIssuePrompt
3003
- }),
3004
- issue_created: Object.freeze({
3005
- create_issue_file: createIssueFileAction
3006
- }),
3007
- issue_submitted: Object.freeze({
3008
- create_issue_on_gh: createGithubIssueAction
3009
- }),
3010
- plan_made: Object.freeze({
3011
- make_plan: makePlan
3012
- }),
3013
- plan_executed: Object.freeze({
3014
- execute_plan: renderPlanExecutionPrompt
3015
- }),
3016
- deep_ui_check_run: Object.freeze({
3017
- run_deep_ui_check: (paths, options, context) => runDeepUiCheck(paths, {
3018
- phase: "pre_review",
3019
- stepId: "deep_ui_check_run"
3020
- }, options, context)
3021
- }),
3022
- review_prompt_rendered: Object.freeze({
3023
- resolve_deslop: (paths, _options, context) => renderResolveDeslopPrompt(paths, context)
3024
- }),
3025
- review_changes_accepted: Object.freeze({
3026
- resolve_deslop: (paths, _options, context) => renderResolveDeslopPrompt(paths, context)
3027
- }),
3028
- automated_checks_run: Object.freeze({
3029
- run_automated_checks: (paths, options, context) => runAutomatedChecks(paths, {
3030
- stepId: "automated_checks_run"
3031
- }, options, context)
3032
- }),
3033
- blueprint_updated: Object.freeze({
3034
- update_blueprint: updateBlueprint
3035
- }),
3036
- changes_committed: Object.freeze({
3037
- commit_changes: commitAcceptedChanges
3038
- }),
3039
- final_report_created: Object.freeze({
3040
- create_pull_request_file: createPullRequestFile
3041
- }),
3042
- pr_created: Object.freeze({
3043
- create_pr_on_gh: createPr
3044
- }),
3045
- pr_merge_prepared: Object.freeze({
3046
- merge_pr: (paths, options, context) => finalizePr(paths, {
3047
- ...options,
3048
- mergePr: true
3049
- }, context),
3050
- prepare_for_merge: preparePrMerge
3051
- }),
3052
- main_checkout_synced: Object.freeze({
3053
- sync_main_checkout: syncMainCheckout
3054
- }),
3055
- session_finished: Object.freeze({
3056
- finish_session: finishSession
3057
- })
3058
- });
3059
-
3060
- async function runSessionStepAction({
3061
- targetRoot = process.cwd(),
3062
- sessionId,
3063
- action,
3064
- options = {}
3065
- } = {}) {
3066
- return withExistingSession({ targetRoot, sessionId }, async (paths) => {
3067
- const artifacts = await readSessionArtifacts(paths);
3068
- if (artifacts.status === SESSION_STATUS.FINISHED || artifacts.status === SESSION_STATUS.ABANDONED) {
3069
- return buildSessionResponse(paths, {
3070
- ok: true,
3071
- status: artifacts.status
3072
- });
3073
- }
3074
- if (artifacts.workflowVersion !== SESSION_WORKFLOW_VERSION) {
3075
- return buildSessionResponse(paths, {
3076
- ok: false,
3077
- errors: [
3078
- createError({
3079
- code: "unsupported_workflow_version",
3080
- message: `Session ${paths.sessionId} uses workflow version ${artifacts.workflowVersion || "unknown"}, but this JSKIT runtime expects ${SESSION_WORKFLOW_VERSION}.`
3081
- })
3082
- ],
3083
- status: SESSION_STATUS.BLOCKED
3084
- });
3085
- }
3086
- const nextStep = artifacts.nextStep;
3087
- const runner = STEP_ACTION_RUNNERS[nextStep]?.[normalizeText(action)];
3088
- if (typeof runner !== "function") {
3089
- return sessionStepError(paths, {
3090
- code: "session_action_not_available",
3091
- message: `Action ${normalizeText(action) || "(missing)"} is not available while the current step is ${nextStep || "complete"}.`,
3092
- repairCommand: `jskit session ${paths.sessionId}`
3093
- });
3094
- }
3095
- const stepPreconditions = await runNamedPreconditions(paths, STEP_PRECONDITION_NAMES[nextStep] || ["session_exists"]);
3096
- if (!stepPreconditions.ok) {
3097
- return sessionStepError(paths, {
3098
- ...stepPreconditions.error,
3099
- repairCommand: stepPreconditions.error?.repairCommand || `jskit session ${paths.sessionId}`
3100
- });
3101
- }
3102
- return runner(paths, options, {
3103
- completeStep: false,
3104
- preconditions: stepPreconditions.preconditions
3105
- });
3106
- });
3107
- }
3108
-
3109
- async function advanceSessionStep({
3110
- targetRoot = process.cwd(),
3111
- sessionId
3112
- } = {}) {
3113
- return withExistingSession({ targetRoot, sessionId }, async (paths) => {
3114
- const artifacts = await readSessionArtifacts(paths);
3115
- if (artifacts.status === SESSION_STATUS.FINISHED || artifacts.status === SESSION_STATUS.ABANDONED) {
3116
- return buildSessionResponse(paths, {
3117
- ok: true,
3118
- status: artifacts.status
3119
- });
3120
- }
3121
- if (artifacts.workflowVersion !== SESSION_WORKFLOW_VERSION) {
3122
- return buildSessionResponse(paths, {
3123
- ok: false,
3124
- errors: [
3125
- createError({
3126
- code: "unsupported_workflow_version",
3127
- message: `Session ${paths.sessionId} uses workflow version ${artifacts.workflowVersion || "unknown"}, but this JSKIT runtime expects ${SESSION_WORKFLOW_VERSION}.`
3128
- })
3129
- ],
3130
- status: SESSION_STATUS.BLOCKED
3131
- });
3132
- }
3133
- const nextStep = artifacts.nextStep;
3134
- if (!nextStep) {
3135
- return finishSession(paths);
3136
- }
3137
- if (nextStep === "worktree_created") {
3138
- if (!await hasWorktree(paths)) {
3139
- return sessionStepError(paths, {
3140
- code: "worktree_not_created",
3141
- message: "Cannot move to the next step until the session worktree exists.",
3142
- repairCommand: `jskit session ${paths.sessionId} create_worktree`
3143
- });
3144
- }
3145
- await writeStepRecord(paths, "worktree_created", `Session worktree is ready at ${paths.worktree}.`);
3146
- await markStatus(paths, SESSION_STATUS.RUNNING);
3147
- return buildSessionResponse(paths);
3148
- }
3149
- if (nextStep === "dependencies_installed") {
3150
- const installResult = await readTextIfExists(path.join(paths.sessionRoot, DEPENDENCIES_INSTALL_RESULT_FILE));
3151
- if (!installResult.trim()) {
3152
- return sessionStepError(paths, {
3153
- code: "dependencies_not_installed",
3154
- message: "Cannot move to the next step until dependencies have been installed in the session worktree.",
3155
- repairCommand: `jskit session ${paths.sessionId} run_npm_install`
3156
- });
3157
- }
3158
- return recordDependenciesInstalled(paths, {
3159
- message: installResult.trim()
3160
- });
3161
- }
3162
- if (nextStep === "issue_prompt_rendered") {
3163
- if (!artifacts.issueDefinitionRequested) {
3164
- return sessionStepError(paths, {
3165
- code: "issue_prompt_missing",
3166
- message: "Cannot move to the next step until the issue-definition prompt has been created.",
3167
- repairCommand: `jskit session ${paths.sessionId} define_issue --prompt "<what should change>"`
3168
- });
3169
- }
3170
- if (!artifacts.issueText) {
3171
- return sessionStepError(paths, {
3172
- code: "issue_file_missing",
3173
- message: "Cannot move to the next step until issue.md exists.",
3174
- repairCommand: `jskit session ${paths.sessionId} create_issue_file`
3175
- });
3176
- }
3177
- await writeStepRecord(paths, "issue_prompt_rendered", "Issue scoped in Codex terminal.");
3178
- await writeStepRecord(paths, "issue_created", "Issue files are ready for review and submission.");
3179
- await markStatus(paths, SESSION_STATUS.RUNNING);
3180
- return buildSessionResponse(paths);
3181
- }
3182
- if (nextStep === "issue_created") {
3183
- if (!artifacts.issueText) {
3184
- return sessionStepError(paths, {
3185
- code: "issue_file_missing",
3186
- message: "Cannot move to the next step until issue.md exists.",
3187
- repairCommand: `jskit session ${paths.sessionId} create_issue_file`
3188
- });
3189
- }
3190
- await writeStepRecord(paths, "issue_created", "Issue files are ready for review and submission.");
3191
- await markStatus(paths, SESSION_STATUS.RUNNING);
3192
- return buildSessionResponse(paths);
3193
- }
3194
- if (nextStep === "issue_submitted") {
3195
- if (!artifacts.issueUrl) {
3196
- return sessionStepError(paths, {
3197
- code: "issue_url_missing",
3198
- message: "Cannot move to the next step until the GitHub issue exists.",
3199
- repairCommand: `jskit session ${paths.sessionId} create_issue_on_gh`
3200
- });
3201
- }
3202
- await writeIssueMetadataFiles(paths, {
3203
- issueTitle: artifacts.issueTitle || titleFromIssue(artifacts.issueText),
3204
- issueUrl: artifacts.issueUrl
3205
- });
3206
- await writeStepRecord(paths, "issue_submitted", `Created GitHub issue ${artifacts.issueUrl}.`);
3207
- await markStatus(paths, SESSION_STATUS.RUNNING);
3208
- return buildSessionResponse(paths);
3209
- }
3210
- if (nextStep === "plan_made") {
3211
- if (!artifacts.makePlanRequested) {
3212
- return sessionStepError(paths, {
3213
- code: "session_step_not_ready",
3214
- message: "Current step plan_made is not ready to advance.",
3215
- repairCommand: `jskit session ${paths.sessionId} make_plan`
3216
- });
3217
- }
3218
- const stepPreconditions = await runNamedPreconditions(paths, STEP_PRECONDITION_NAMES[nextStep] || ["session_exists"]);
3219
- if (!stepPreconditions.ok) {
3220
- return sessionStepError(paths, {
3221
- ...stepPreconditions.error,
3222
- repairCommand: stepPreconditions.error?.repairCommand || `jskit session ${paths.sessionId}`
3223
- });
3224
- }
3225
- return makePlan(paths, {}, {
3226
- preconditions: stepPreconditions.preconditions
3227
- });
3228
- }
3229
- if (nextStep === "plan_executed") {
3230
- if (!artifacts.executePlanRequested) {
3231
- return sessionStepError(paths, {
3232
- code: "session_step_not_ready",
3233
- message: "Current step plan_executed is not ready to advance.",
3234
- repairCommand: `jskit session ${paths.sessionId} execute_plan`
3235
- });
3236
- }
3237
- const stepPreconditions = await runNamedPreconditions(paths, STEP_PRECONDITION_NAMES[nextStep] || ["session_exists"]);
3238
- if (!stepPreconditions.ok) {
3239
- return sessionStepError(paths, {
3240
- ...stepPreconditions.error,
3241
- repairCommand: stepPreconditions.error?.repairCommand || `jskit session ${paths.sessionId}`
3242
- });
3243
- }
3244
- return renderPlanExecutionPrompt(paths, {}, {
3245
- preconditions: stepPreconditions.preconditions
3246
- });
3247
- }
3248
- if (nextStep === "deep_ui_check_run") {
3249
- const stepPreconditions = await runNamedPreconditions(paths, STEP_PRECONDITION_NAMES[nextStep] || ["session_exists"]);
3250
- if (!stepPreconditions.ok) {
3251
- return sessionStepError(paths, {
3252
- ...stepPreconditions.error,
3253
- repairCommand: stepPreconditions.error?.repairCommand || `jskit session ${paths.sessionId}`
3254
- });
3255
- }
3256
- const deepUiCheckPrompted = (artifacts.uiChecks || []).some((entry) => {
3257
- return normalizeText(entry?.stepId) === "deep_ui_check_run" &&
3258
- normalizeText(entry?.status) === "prompted";
3259
- });
3260
- await writeStepRecord(
3261
- paths,
3262
- "deep_ui_check_run",
3263
- deepUiCheckPrompted ? "Run deep UI check completed by Codex." : "Deep UI check skipped."
3264
- );
3265
- await markStatus(paths, SESSION_STATUS.RUNNING);
3266
- return buildSessionResponse(paths, {
3267
- preconditions: stepPreconditions.preconditions
3268
- });
3269
- }
3270
- if (nextStep === "review_prompt_rendered") {
3271
- const stepPreconditions = await runNamedPreconditions(paths, STEP_PRECONDITION_NAMES[nextStep] || ["session_exists"]);
3272
- if (!stepPreconditions.ok) {
3273
- return sessionStepError(paths, {
3274
- ...stepPreconditions.error,
3275
- repairCommand: stepPreconditions.error?.repairCommand || `jskit session ${paths.sessionId}`
3276
- });
3277
- }
3278
- const reviewPrompted = (artifacts.reviewPasses || []).some((entry) => {
3279
- return normalizeText(entry?.status) === "prompted";
3280
- });
3281
- await writeStepRecord(
3282
- paths,
3283
- "review_prompt_rendered",
3284
- reviewPrompted ? "Review/deslop completed by Codex." : "Review/deslop skipped."
3285
- );
3286
- await writeStepRecord(
3287
- paths,
3288
- "review_changes_accepted",
3289
- reviewPrompted ? "Review/deslop accepted." : "No review/deslop pass was requested."
3290
- );
3291
- await markStatus(paths, SESSION_STATUS.RUNNING);
3292
- return buildSessionResponse(paths, {
3293
- preconditions: stepPreconditions.preconditions
3294
- });
3295
- }
3296
- if (nextStep === "automated_checks_run") {
3297
- const stepPreconditions = await runNamedPreconditions(paths, STEP_PRECONDITION_NAMES[nextStep] || ["session_exists"]);
3298
- if (!stepPreconditions.ok) {
3299
- return sessionStepError(paths, {
3300
- ...stepPreconditions.error,
3301
- repairCommand: stepPreconditions.error?.repairCommand || `jskit session ${paths.sessionId}`
3302
- });
3303
- }
3304
- const [command, args] = await doctorCommandForWorktree(paths.worktree);
3305
- const checkCommand = [command, ...args].join(" ");
3306
- const automatedChecksPrompted = (artifacts.checks || []).some((entry) => {
3307
- return normalizeText(entry?.stepId) === "automated_checks_run" &&
3308
- normalizeText(entry?.status) === "prompted";
3309
- });
3310
- if (automatedChecksPrompted) {
3311
- const checksRoot = path.join(paths.sessionRoot, "checks");
3312
- await mkdir(checksRoot, { recursive: true });
3313
- await writeTextFile(
3314
- path.join(checksRoot, "automated_checks_run.json"),
3315
- `${JSON.stringify({
3316
- command: checkCommand,
3317
- ok: true,
3318
- status: "completed_by_codex",
3319
- stepId: "automated_checks_run"
3320
- }, null, 2)}\n`
3321
- );
3322
- }
3323
- await writeStepRecord(
3324
- paths,
3325
- "automated_checks_run",
3326
- automatedChecksPrompted ? `Run automated checks completed by Codex: ${checkCommand}.` : "Automated checks skipped."
3327
- );
3328
- await markStatus(paths, SESSION_STATUS.RUNNING);
3329
- return buildSessionResponse(paths, {
3330
- preconditions: stepPreconditions.preconditions
3331
- });
3332
- }
3333
- if (nextStep === "blueprint_updated") {
3334
- const stepPreconditions = await runNamedPreconditions(paths, STEP_PRECONDITION_NAMES[nextStep] || ["session_exists"]);
3335
- if (!stepPreconditions.ok) {
3336
- return sessionStepError(paths, {
3337
- ...stepPreconditions.error,
3338
- repairCommand: stepPreconditions.error?.repairCommand || `jskit session ${paths.sessionId}`
3339
- });
3340
- }
3341
- await writeStepRecord(paths, "blueprint_updated", "Blueprint update step completed.");
3342
- await markStatus(paths, SESSION_STATUS.RUNNING);
3343
- return buildSessionResponse(paths, {
3344
- preconditions: stepPreconditions.preconditions,
3345
- status: SESSION_STATUS.RUNNING
3346
- });
3347
- }
3348
- if (nextStep === "changes_committed") {
3349
- const stepPreconditions = await runNamedPreconditions(paths, STEP_PRECONDITION_NAMES[nextStep] || ["session_exists"]);
3350
- if (!stepPreconditions.ok) {
3351
- return sessionStepError(paths, {
3352
- ...stepPreconditions.error,
3353
- repairCommand: stepPreconditions.error?.repairCommand || `jskit session ${paths.sessionId}`
3354
- });
3355
- }
3356
- const commitInfo = await readAcceptedChangesCommit(paths);
3357
- if (!commitInfo?.commit) {
3358
- return sessionStepError(paths, {
3359
- code: "changes_not_committed",
3360
- message: "Cannot move to the next step until accepted changes have been committed.",
3361
- repairCommand: `jskit session ${paths.sessionId} commit_changes`
3362
- });
3363
- }
3364
- const warnings = [];
3365
- if (commitInfo.noChanges === true) {
3366
- warnings.push({
3367
- code: "accepted_changes_noop",
3368
- message: "No accepted worktree changes were found; continuing without a new commit."
3369
- });
3370
- }
3371
- await writeStepRecord(
3372
- paths,
3373
- "changes_committed",
3374
- commitInfo.noChanges === true
3375
- ? "No accepted worktree changes were found; continued without a new commit."
3376
- : `Committed accepted changes at ${commitInfo.commit || "unknown"}.`
3377
- );
3378
- await markStatus(paths, SESSION_STATUS.RUNNING);
3379
- return buildSessionResponse(paths, {
3380
- preconditions: stepPreconditions.preconditions,
3381
- warnings
3382
- });
3383
- }
3384
- if (nextStep === "final_report_created") {
3385
- const pullRequestText = await readTrimmedFile(path.join(paths.sessionRoot, "pull_request.md"));
3386
- if (!pullRequestText) {
3387
- return sessionStepError(paths, {
3388
- code: "pull_request_file_missing",
3389
- message: "Cannot move to the next step until pull_request.md exists.",
3390
- repairCommand: `jskit session ${paths.sessionId} create_pull_request_file`
3391
- });
3392
- }
3393
- const stepPreconditions = await runNamedPreconditions(paths, STEP_PRECONDITION_NAMES[nextStep] || ["session_exists"]);
3394
- if (!stepPreconditions.ok) {
3395
- return sessionStepError(paths, {
3396
- ...stepPreconditions.error,
3397
- repairCommand: stepPreconditions.error?.repairCommand || `jskit session ${paths.sessionId}`
3398
- });
3399
- }
3400
- await writeStepRecord(paths, "final_report_created", "Pull request file is ready for review and submission.");
3401
- await markStatus(paths, SESSION_STATUS.RUNNING);
3402
- return buildSessionResponse(paths, {
3403
- preconditions: stepPreconditions.preconditions
3404
- });
3405
- }
3406
- if (nextStep === "pr_created") {
3407
- const stepPreconditions = await runNamedPreconditions(paths, STEP_PRECONDITION_NAMES[nextStep] || ["session_exists"]);
3408
- if (!stepPreconditions.ok) {
3409
- return sessionStepError(paths, {
3410
- ...stepPreconditions.error,
3411
- repairCommand: stepPreconditions.error?.repairCommand || `jskit session ${paths.sessionId}`
3412
- });
3413
- }
3414
- await writeStepRecord(
3415
- paths,
3416
- "pr_created",
3417
- artifacts.prUrl
3418
- ? `Created GitHub pull request ${artifacts.prUrl}.`
3419
- : "Continued without creating a GitHub pull request."
3420
- );
3421
- await markStatus(paths, SESSION_STATUS.RUNNING);
3422
- return buildSessionResponse(paths, {
3423
- preconditions: stepPreconditions.preconditions
3424
- });
3425
- }
3426
- if (nextStep === "pr_merge_prepared") {
3427
- const stepPreconditions = await runNamedPreconditions(paths, STEP_PRECONDITION_NAMES[nextStep] || ["session_exists"]);
3428
- if (!stepPreconditions.ok) {
3429
- return sessionStepError(paths, {
3430
- ...stepPreconditions.error,
3431
- repairCommand: stepPreconditions.error?.repairCommand || `jskit session ${paths.sessionId}`
3432
- });
3433
- }
3434
- let prOutcome = artifacts.prOutcome || parseJsonObject(await readTextIfExists(path.join(paths.sessionRoot, "pr_outcome.json")));
3435
- if (!prOutcome?.outcome) {
3436
- prOutcome = {
3437
- outcome: "skipped",
3438
- prUrl: artifacts.prUrl || await readTrimmedFile(path.join(paths.sessionRoot, "pr_url")),
3439
- reason: "User continued without merging the pull request."
3440
- };
3441
- await writePrOutcome(paths, prOutcome);
3442
- }
3443
- await writeStepRecord(
3444
- paths,
3445
- "pr_merge_prepared",
3446
- prOutcome.outcome === "merged"
3447
- ? `Merged PR ${prOutcome.prUrl || artifacts.prUrl || "unknown"}.`
3448
- : `Merge PR skipped: ${prOutcome.reason || `PR outcome is ${prOutcome.outcome}.`}`
3449
- );
3450
- await markStatus(paths, SESSION_STATUS.RUNNING);
3451
- return buildSessionResponse(paths, {
3452
- preconditions: stepPreconditions.preconditions
3453
- });
3454
- }
3455
- if (nextStep === "main_checkout_synced") {
3456
- const stepPreconditions = await runNamedPreconditions(paths, STEP_PRECONDITION_NAMES[nextStep] || ["session_exists"]);
3457
- if (!stepPreconditions.ok) {
3458
- return sessionStepError(paths, {
3459
- ...stepPreconditions.error,
3460
- repairCommand: stepPreconditions.error?.repairCommand || `jskit session ${paths.sessionId}`
3461
- });
3462
- }
3463
- let mainCheckoutSync = artifacts.mainCheckoutSync || parseJsonObject(await readTextIfExists(path.join(paths.sessionRoot, "main_checkout_sync.json")));
3464
- if (!mainCheckoutSync?.status) {
3465
- const prOutcome = artifacts.prOutcome || parseJsonObject(await readTextIfExists(path.join(paths.sessionRoot, "pr_outcome.json"))) || {};
3466
- const reason = prOutcome.outcome === "merged"
3467
- ? "User skipped main checkout sync."
3468
- : prOutcome.outcome
3469
- ? `PR outcome is ${prOutcome.outcome}; no main checkout sync is required.`
3470
- : "The pull request was not merged; no main checkout sync is required.";
3471
- mainCheckoutSync = {
3472
- branch: prOutcome.baseBranch || "",
3473
- outcome: prOutcome.outcome || "skipped",
3474
- reason,
3475
- status: "skipped"
3476
- };
3477
- await writeMainCheckoutSync(paths, mainCheckoutSync);
3478
- }
3479
- const message = mainCheckoutSync.status === "synced"
3480
- ? `Fast-forwarded target checkout branch ${mainCheckoutSync.branch || "unknown"}.`
3481
- : `Main checkout sync skipped: ${mainCheckoutSync.reason || "No sync was required."}`;
3482
- await writeStepRecord(paths, "main_checkout_synced", message);
3483
- await markStatus(paths, SESSION_STATUS.RUNNING);
3484
- return buildSessionResponse(paths, {
3485
- preconditions: stepPreconditions.preconditions
3486
- });
3487
- }
3488
- if (nextStep === "session_finished") {
3489
- return sessionStepError(paths, {
3490
- code: "finish_session_required",
3491
- message: "Use the Finish action to complete and archive the session.",
3492
- repairCommand: `jskit session ${paths.sessionId} finish_session`
3493
- });
3494
- }
3495
- return sessionStepError(paths, {
3496
- code: "session_step_not_ready",
3497
- message: `Current step ${nextStep} is not ready to advance.`,
3498
- repairCommand: `jskit session ${paths.sessionId}`
3499
- });
3500
- });
3501
- }
3502
-
3503
- async function runSessionStep({
3504
- targetRoot = process.cwd(),
3505
- sessionId,
3506
- options = {}
3507
- } = {}) {
3508
- return withExistingSession({ targetRoot, sessionId }, async (paths) => {
3509
- const artifacts = await readSessionArtifacts(paths);
3510
- if (artifacts.status === SESSION_STATUS.FINISHED || artifacts.status === SESSION_STATUS.ABANDONED) {
3511
- return buildSessionResponse(paths, {
3512
- ok: true,
3513
- status: artifacts.status
3514
- });
3515
- }
3516
- if (artifacts.workflowVersion !== SESSION_WORKFLOW_VERSION) {
3517
- return buildSessionResponse(paths, {
3518
- ok: false,
3519
- errors: [
3520
- createError({
3521
- code: "unsupported_workflow_version",
3522
- message: `Session ${paths.sessionId} uses workflow version ${artifacts.workflowVersion || "unknown"}, but this JSKIT runtime expects ${SESSION_WORKFLOW_VERSION}.`
3523
- })
3524
- ],
3525
- status: SESSION_STATUS.BLOCKED
3526
- });
3527
- }
3528
- const nextStep = artifacts.nextStep;
3529
- if (!nextStep) {
3530
- return finishSession(paths);
3531
- }
3532
- const runner = STEP_RUNNERS[nextStep];
3533
- if (typeof runner !== "function") {
3534
- return failSession(paths, {
3535
- code: "step_not_implemented",
3536
- message: `No runner exists for step ${nextStep}.`,
3537
- status: SESSION_STATUS.FAILED
3538
- });
3539
- }
3540
- if (skipStepRequested(options)) {
3541
- return skipCurrentStep(paths, nextStep, options);
3542
- }
3543
- const stepPreconditions = await runNamedPreconditions(paths, STEP_PRECONDITION_NAMES[nextStep] || ["session_exists"]);
3544
- if (!stepPreconditions.ok) {
3545
- return failSession(paths, {
3546
- ...stepPreconditions.error,
3547
- preconditions: stepPreconditions.preconditions
3548
- });
3549
- }
3550
- return runner(paths, options, {
3551
- preconditions: stepPreconditions.preconditions
3552
- });
3553
- });
3554
- }
3555
-
3556
- async function abandonSession({
3557
- targetRoot = process.cwd(),
3558
- sessionId
3559
- } = {}) {
3560
- return withExistingSession({ targetRoot, sessionId }, async (paths) => {
3561
- const artifacts = await readSessionArtifacts(paths);
3562
- if (artifacts.status === SESSION_STATUS.FINISHED || artifacts.status === SESSION_STATUS.ABANDONED) {
3563
- return buildSessionResponse(paths, {
3564
- status: artifacts.status
3565
- });
3566
- }
3567
- const issueUrl = await readTrimmedFile(path.join(paths.sessionRoot, "issue_url"));
3568
- if (issueUrl) {
3569
- const closeIssueResult = await runLoggedCommand(paths, "github_issue_close", "gh", ["issue", "close", issueUrl, "--comment", `Abandoned JSKIT Studio session ${paths.sessionId}.`], {
3570
- cwd: paths.targetRoot,
3571
- timeout: 1000 * 60
3572
- });
3573
- if (!closeIssueResult.ok) {
3574
- return failSession(paths, {
3575
- code: "issue_close_failed",
3576
- message: closeIssueResult.output || "Failed to close GitHub issue for abandoned session.",
3577
- repairCommand: `gh issue close ${issueUrl}`,
3578
- status: SESSION_STATUS.FAILED
3579
- });
3580
- }
3581
- }
3582
- if (await hasWorktree(paths)) {
3583
- await runLoggedCommand(paths, "git_worktree_remove", "git", ["worktree", "remove", "--force", paths.worktree], {
3584
- cwd: paths.targetRoot,
3585
- timeout: 1000 * 60
3586
- });
3587
- }
3588
- await writeTextFile(
3589
- path.join(paths.sessionRoot, "steps", "abandoned"),
3590
- `${timestampForStepRecord()}\nAbandoned session ${paths.sessionId}.`
3591
- );
3592
- await markStatus(paths, SESSION_STATUS.ABANDONED);
3593
- await markCurrentStep(paths, "");
3594
- const archivedPaths = await archiveSession(paths, "abandoned");
3595
- return buildSessionResponse(archivedPaths, {
3596
- status: SESSION_STATUS.ABANDONED
3597
- });
3598
- });
3599
- }
3600
-
3601
- async function adoptCodexThreadId({
3602
- targetRoot = process.cwd(),
3603
- sessionId,
3604
- codexThreadId
3605
- } = {}) {
3606
- if (!isValidSessionId(sessionId)) {
3607
- return invalidSessionIdResponse({ targetRoot, sessionId });
3608
- }
3609
- const normalizedThreadId = normalizeText(codexThreadId);
3610
- if (!normalizedThreadId) {
3611
- return failSession(resolveSessionPaths({ targetRoot, sessionId }), {
3612
- code: "codex_thread_id_required",
3613
- message: "Codex thread id is required."
3614
- });
3615
- }
3616
- return withExistingSession({ targetRoot, sessionId }, async (paths) => {
3617
- if (paths.archive && paths.archive !== "active") {
3618
- return buildSessionResponse(paths, {
3619
- ok: false,
3620
- errors: [
3621
- createError({
3622
- code: "session_archived_read_only",
3623
- message: `Session ${paths.sessionId} is archived and cannot be mutated.`
3624
- })
3625
- ]
3626
- });
3627
- }
3628
- await writeTextFile(path.join(paths.sessionRoot, "codex_thread_id"), normalizedThreadId);
3629
- return buildSessionResponse(paths);
3630
- });
3631
- }
3632
-
3633
- export {
3634
- SESSION_STATUS,
3635
- STEP_DEFINITIONS,
3636
- STEP_IDS,
3637
- STEP_PRECONDITION_NAMES,
3638
- abandonSession,
3639
- advanceSessionStep,
3640
- adoptDependenciesInstalled,
3641
- adoptCodexThreadId,
3642
- buildSessionResponse,
3643
- buildSessionErrorResponse,
3644
- createSession,
3645
- createSessionId,
3646
- extractIssueTitle,
3647
- extractIssueText,
3648
- inspectSession,
3649
- inspectSessionDiff,
3650
- inspectSessionDetails,
3651
- isValidSessionId,
3652
- listSessions,
3653
- renderTemplate,
3654
- recordDependenciesInstalled,
3655
- rewindSession,
3656
- resolveSessionPaths,
3657
- runSessionStep,
3658
- runSessionStepAction
3659
- };