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