@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.
- package/package.json +4 -4
- package/src/server/appBlueprint.js +37 -14
- package/src/server/core/argParser.js +0 -12
- package/src/server/core/commandCatalog.js +2 -92
- package/src/server/core/createCommandHandlers.js +0 -3
- package/src/server/helperMap.js +214 -56
- package/src/server/index.js +0 -1
- package/src/server/{sessionRuntime/prompts → prompts}/app_blueprint.md +1 -1
- package/src/server/commandHandlers/session.js +0 -471
- package/src/server/sessionRuntime/appReadiness.js +0 -55
- package/src/server/sessionRuntime/constants.js +0 -377
- package/src/server/sessionRuntime/io.js +0 -97
- package/src/server/sessionRuntime/paths.js +0 -163
- package/src/server/sessionRuntime/preconditions.js +0 -663
- package/src/server/sessionRuntime/promptRenderer.js +0 -41
- package/src/server/sessionRuntime/prompts/automated_checks_run.md +0 -28
- package/src/server/sessionRuntime/prompts/blueprint_updated.md +0 -29
- package/src/server/sessionRuntime/prompts/deep_ui_check_run.md +0 -40
- package/src/server/sessionRuntime/prompts/final_comment.md +0 -10
- package/src/server/sessionRuntime/prompts/final_report_created.md +0 -44
- package/src/server/sessionRuntime/prompts/issue_created.md +0 -26
- package/src/server/sessionRuntime/prompts/issue_prompt_rendered.md +0 -1
- package/src/server/sessionRuntime/prompts/make_plan.md +0 -57
- package/src/server/sessionRuntime/prompts/plan_executed.md +0 -39
- package/src/server/sessionRuntime/prompts/pr_failure.md +0 -28
- package/src/server/sessionRuntime/prompts/pr_merge_prepared.md +0 -22
- package/src/server/sessionRuntime/prompts/review_changes_accepted_resolve.md +0 -12
- package/src/server/sessionRuntime/prompts/review_prompt_rendered.md +0 -61
- package/src/server/sessionRuntime/prompts/user_check_completed.md +0 -17
- package/src/server/sessionRuntime/responses.js +0 -1481
- package/src/server/sessionRuntime/worktrees.js +0 -31
- 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
|
-
};
|