@jskit-ai/jskit-cli 0.2.81 → 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 +110 -3
- package/src/server/commandHandlers/show.js +169 -34
- package/src/server/core/argParser.js +8 -0
- package/src/server/core/commandCatalog.js +58 -2
- 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 +217 -78
- package/src/server/sessionRuntime/preconditions.js +382 -5
- package/src/server/sessionRuntime/promptRenderer.js +15 -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 +11 -2
- package/src/server/sessionRuntime/prompts/execute_plan.md +32 -6
- 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 +15 -2
- package/src/server/sessionRuntime/prompts/plan_issue.md +40 -9
- package/src/server/sessionRuntime/prompts/review_changes.md +46 -5
- package/src/server/sessionRuntime/prompts/update_blueprint.md +36 -0
- package/src/server/sessionRuntime/prompts/user_check.md +15 -1
- package/src/server/sessionRuntime/responses.js +776 -56
- package/src/server/sessionRuntime.js +1658 -123
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import {
|
|
2
|
+
appendFile,
|
|
2
3
|
mkdir,
|
|
3
4
|
readFile,
|
|
4
5
|
readdir,
|
|
@@ -6,14 +7,21 @@ import {
|
|
|
6
7
|
} from "node:fs/promises";
|
|
7
8
|
import path from "node:path";
|
|
8
9
|
import {
|
|
10
|
+
BLUEPRINT_CODEX_HANDOFF,
|
|
11
|
+
AUTOMATED_CHECK_REPAIR_CODEX_HANDOFF,
|
|
12
|
+
DEEP_UI_CHECK_CODEX_HANDOFF,
|
|
13
|
+
ISSUE_DETAILS_CODEX_HANDOFF,
|
|
9
14
|
PLAN_EXECUTION_CODEX_HANDOFF,
|
|
15
|
+
REVIEW_PASS_LIMIT,
|
|
10
16
|
REVIEW_EXECUTION_CODEX_HANDOFF,
|
|
11
17
|
SESSION_STATUS,
|
|
18
|
+
SESSION_WORKFLOW_VERSION,
|
|
12
19
|
STEP_DEFINITIONS,
|
|
13
20
|
STEP_IDS,
|
|
14
21
|
STEP_PRECONDITION_NAMES
|
|
15
22
|
} from "./sessionRuntime/constants.js";
|
|
16
23
|
import {
|
|
24
|
+
fileExists,
|
|
17
25
|
normalizeText,
|
|
18
26
|
readTextIfExists,
|
|
19
27
|
readTrimmedFile,
|
|
@@ -40,19 +48,37 @@ import {
|
|
|
40
48
|
failSession,
|
|
41
49
|
markCurrentStep,
|
|
42
50
|
markStatus,
|
|
51
|
+
normalizeReviewPassNumber,
|
|
52
|
+
readActiveCycle,
|
|
43
53
|
readReceiptSteps,
|
|
54
|
+
readReviewPasses,
|
|
44
55
|
readSessionArtifacts,
|
|
56
|
+
reviewPassRoot,
|
|
57
|
+
writeActiveCycle,
|
|
58
|
+
writeCycleReceipt,
|
|
45
59
|
writeReceipt
|
|
46
60
|
} from "./sessionRuntime/responses.js";
|
|
47
61
|
import {
|
|
48
62
|
applyPreconditions,
|
|
63
|
+
assertAcceptedChangesCommitted,
|
|
64
|
+
assertActiveCycleExists,
|
|
65
|
+
assertActiveCycleUserCheckPassed,
|
|
66
|
+
assertBlueprintUpdateSatisfied,
|
|
67
|
+
assertDeepUiCheckSatisfied,
|
|
68
|
+
assertDependenciesInstalled,
|
|
69
|
+
assertFinalReportExists,
|
|
49
70
|
assertGhAuth,
|
|
50
71
|
assertGitCurrentBranch,
|
|
51
72
|
assertGitRepository,
|
|
52
73
|
assertGithubOrigin,
|
|
74
|
+
assertIssueMetadataExists,
|
|
53
75
|
assertIssueTextExists,
|
|
54
76
|
assertIssueUrlExists,
|
|
77
|
+
assertAutomatedChecksPassed,
|
|
78
|
+
assertIssueDetailsExists,
|
|
79
|
+
assertPlanTextExists,
|
|
55
80
|
assertPrUrlExists,
|
|
81
|
+
assertReadyJskitApp,
|
|
56
82
|
assertSessionExists,
|
|
57
83
|
assertTargetRootWritable,
|
|
58
84
|
assertWorktreeExists,
|
|
@@ -63,6 +89,10 @@ import {
|
|
|
63
89
|
renderPrompt,
|
|
64
90
|
renderTemplate
|
|
65
91
|
} from "./sessionRuntime/promptRenderer.js";
|
|
92
|
+
import {
|
|
93
|
+
HELPER_MAP_JSON_RELATIVE_PATH,
|
|
94
|
+
HELPER_MAP_MARKDOWN_RELATIVE_PATH
|
|
95
|
+
} from "./helperMapPaths.js";
|
|
66
96
|
|
|
67
97
|
function invalidSessionIdError(sessionId = "") {
|
|
68
98
|
return createError({
|
|
@@ -130,9 +160,9 @@ function extractMarkedText(value = "", marker = "") {
|
|
|
130
160
|
if (!normalizedMarker) {
|
|
131
161
|
return "";
|
|
132
162
|
}
|
|
133
|
-
const pattern = new RegExp(`\\[${normalizedMarker}\\]([\\s\\S]*?)\\[/${normalizedMarker}\\]`, "
|
|
134
|
-
const
|
|
135
|
-
return normalizeText(
|
|
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] : "");
|
|
136
166
|
}
|
|
137
167
|
|
|
138
168
|
function extractIssueTitle(value = "") {
|
|
@@ -147,10 +177,252 @@ function extractPlanText(value = "") {
|
|
|
147
177
|
return extractMarkedText(value, "plan") || normalizeText(value);
|
|
148
178
|
}
|
|
149
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
|
+
|
|
150
210
|
async function writePromptArtifact(paths, fileName, prompt) {
|
|
151
211
|
await writeTextFile(path.join(paths.sessionRoot, "prompts", fileName), prompt);
|
|
152
212
|
}
|
|
153
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");
|
|
424
|
+
}
|
|
425
|
+
|
|
154
426
|
async function createSession({
|
|
155
427
|
targetRoot = process.cwd(),
|
|
156
428
|
sessionId = "",
|
|
@@ -186,6 +458,9 @@ async function createSession({
|
|
|
186
458
|
await ensureStudioGitExclude(initialPaths.targetRoot);
|
|
187
459
|
await mkdir(initialPaths.sessionRoot, { recursive: true });
|
|
188
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");
|
|
189
464
|
await markStatus(initialPaths, SESSION_STATUS.PENDING);
|
|
190
465
|
await writeReceipt(initialPaths, "session_created", `Created JSKIT Studio issue session ${initialPaths.sessionId}.`);
|
|
191
466
|
|
|
@@ -294,11 +569,11 @@ async function inspectSessionDetails({
|
|
|
294
569
|
}
|
|
295
570
|
const { paths, preconditions } = context;
|
|
296
571
|
const response = await buildSessionResponse(paths, { preconditions });
|
|
572
|
+
const { planText } = await readCurrentPlan(paths);
|
|
297
573
|
|
|
298
|
-
const [issueText, issueTitle,
|
|
574
|
+
const [issueText, issueTitle, receipts, transcriptLog] = await Promise.all([
|
|
299
575
|
readTextIfExists(path.join(paths.sessionRoot, "issue.md")),
|
|
300
576
|
readTrimmedFile(path.join(paths.sessionRoot, "issue_title")),
|
|
301
|
-
readTextIfExists(path.join(paths.sessionRoot, "plan.md")),
|
|
302
577
|
readReceiptSteps(paths),
|
|
303
578
|
readTextIfExists(path.join(paths.sessionRoot, "transcript.log"))
|
|
304
579
|
]);
|
|
@@ -341,7 +616,19 @@ async function removeEmptyStaleWorktreeDirectory(paths) {
|
|
|
341
616
|
|
|
342
617
|
async function createWorktree(paths, _options = {}, context = {}) {
|
|
343
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);
|
|
344
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
|
+
}
|
|
345
632
|
await writeReceipt(paths, "worktree_created", `Reused existing worktree ${paths.worktree}.`);
|
|
346
633
|
await markStatus(paths, SESSION_STATUS.RUNNING);
|
|
347
634
|
return buildSessionResponse(paths, {
|
|
@@ -359,7 +646,8 @@ async function createWorktree(paths, _options = {}, context = {}) {
|
|
|
359
646
|
preconditions
|
|
360
647
|
});
|
|
361
648
|
}
|
|
362
|
-
const result = await
|
|
649
|
+
const result = await runLoggedCommand(paths, "git_worktree_add", "git", ["worktree", "add", "-b", paths.branch, paths.worktree, "HEAD"], {
|
|
650
|
+
cwd: paths.targetRoot,
|
|
363
651
|
timeout: 30000
|
|
364
652
|
});
|
|
365
653
|
if (!result.ok) {
|
|
@@ -370,6 +658,12 @@ async function createWorktree(paths, _options = {}, context = {}) {
|
|
|
370
658
|
preconditions
|
|
371
659
|
});
|
|
372
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
|
+
}
|
|
373
667
|
await writeReceipt(paths, "worktree_created", `Created worktree ${paths.worktree} on branch ${paths.branch}.`);
|
|
374
668
|
await markStatus(paths, SESSION_STATUS.RUNNING);
|
|
375
669
|
return buildSessionResponse(paths, {
|
|
@@ -388,22 +682,79 @@ async function recordDependenciesInstalled(paths, {
|
|
|
388
682
|
});
|
|
389
683
|
}
|
|
390
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
|
+
|
|
391
741
|
async function installDependencies(paths, _options = {}, context = {}) {
|
|
392
742
|
const preconditions = context.preconditions || [];
|
|
393
|
-
const
|
|
743
|
+
const [command, args] = await dependencyInstallCommandForWorktree(paths.worktree);
|
|
744
|
+
const result = await runLoggedCommand(paths, "dependencies_install", command, args, {
|
|
394
745
|
cwd: paths.worktree,
|
|
395
746
|
timeout: 1000 * 60 * 10
|
|
396
747
|
});
|
|
397
748
|
if (!result.ok) {
|
|
398
749
|
return failSession(paths, {
|
|
399
750
|
code: "dependencies_install_failed",
|
|
400
|
-
message: result.output || "
|
|
401
|
-
repairCommand: `cd ${paths.worktree} &&
|
|
751
|
+
message: result.output || `${command} ${args.join(" ")} failed in the session worktree.`,
|
|
752
|
+
repairCommand: `cd ${paths.worktree} && ${command} ${args.join(" ")}`,
|
|
402
753
|
preconditions
|
|
403
754
|
});
|
|
404
755
|
}
|
|
405
756
|
return recordDependenciesInstalled(paths, {
|
|
406
|
-
message: result.output ||
|
|
757
|
+
message: result.output || `Installed Node dependencies in the session worktree with ${command} ${args.join(" ")}.`,
|
|
407
758
|
preconditions
|
|
408
759
|
});
|
|
409
760
|
}
|
|
@@ -465,7 +816,14 @@ async function draftIssue(paths, options = {}) {
|
|
|
465
816
|
repairCommand: `jskit session ${paths.sessionId} step --issue -`
|
|
466
817
|
});
|
|
467
818
|
}
|
|
468
|
-
const issueTitle = normalizeText(options.issueTitle) || extractIssueTitle(options.issue)
|
|
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
|
+
}
|
|
469
827
|
await writeTextFile(path.join(paths.sessionRoot, "issue.md"), issueText);
|
|
470
828
|
await writeTextFile(path.join(paths.sessionRoot, "issue_title"), issueTitle);
|
|
471
829
|
await writeReceipt(paths, "issue_drafted", "Saved approved issue text.");
|
|
@@ -483,9 +841,25 @@ function titleFromIssue(issueText) {
|
|
|
483
841
|
|
|
484
842
|
async function createIssue(paths, _options = {}, context = {}) {
|
|
485
843
|
const preconditions = context.preconditions || [];
|
|
844
|
+
const existingIssueUrl = await readTrimmedFile(path.join(paths.sessionRoot, "issue_url"));
|
|
486
845
|
const issueText = await readTrimmedFile(path.join(paths.sessionRoot, "issue.md"));
|
|
487
846
|
const issueTitle = await readTrimmedFile(path.join(paths.sessionRoot, "issue_title")) || titleFromIssue(issueText);
|
|
488
|
-
|
|
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", [
|
|
489
863
|
"issue",
|
|
490
864
|
"create",
|
|
491
865
|
"--title",
|
|
@@ -506,6 +880,14 @@ async function createIssue(paths, _options = {}, context = {}) {
|
|
|
506
880
|
}
|
|
507
881
|
const issueUrl = result.stdout.split(/\r?\n/u).map((line) => line.trim()).find(Boolean) || result.stdout;
|
|
508
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);
|
|
509
891
|
await writeReceipt(paths, "issue_created", `Created GitHub issue ${issueUrl}.`);
|
|
510
892
|
await markStatus(paths, SESSION_STATUS.RUNNING);
|
|
511
893
|
return buildSessionResponse(paths, {
|
|
@@ -513,26 +895,142 @@ async function createIssue(paths, _options = {}, context = {}) {
|
|
|
513
895
|
});
|
|
514
896
|
}
|
|
515
897
|
|
|
898
|
+
async function renderIssueDetailsPrompt(paths, _options = {}, context = {}) {
|
|
899
|
+
const preconditions = context.preconditions || [];
|
|
900
|
+
const issueText = await readTrimmedFile(path.join(paths.sessionRoot, "issue.md"));
|
|
901
|
+
const issueTitle = await readTrimmedFile(path.join(paths.sessionRoot, "issue_title")) || titleFromIssue(issueText);
|
|
902
|
+
const issueUrl = await readTrimmedFile(path.join(paths.sessionRoot, "issue_url"));
|
|
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,
|
|
907
|
+
issue_text: issueText,
|
|
908
|
+
issue_title: issueTitle,
|
|
909
|
+
issue_url: issueUrl,
|
|
910
|
+
issue_details_file: path.join(paths.sessionRoot, "issue_details.md"),
|
|
911
|
+
worktree: paths.worktree
|
|
912
|
+
});
|
|
913
|
+
await writePromptArtifact(paths, "issue_details.md", prompt);
|
|
914
|
+
await markStatus(paths, SESSION_STATUS.WAITING_FOR_USER);
|
|
915
|
+
return buildSessionResponse(paths, {
|
|
916
|
+
codex: ISSUE_DETAILS_CODEX_HANDOFF,
|
|
917
|
+
ok: true,
|
|
918
|
+
preconditions,
|
|
919
|
+
prompt,
|
|
920
|
+
status: SESSION_STATUS.WAITING_FOR_USER
|
|
921
|
+
});
|
|
922
|
+
}
|
|
923
|
+
|
|
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
|
+
});
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
const issueCategory = normalizeIssueCategory(structuredIssueCategory || extractIssueCategory(source));
|
|
945
|
+
const uiImpact = normalizeUiImpact(structuredUiImpact || extractUiImpact(source));
|
|
946
|
+
if (!issueCategory) {
|
|
947
|
+
return failSession(paths, {
|
|
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
|
|
952
|
+
});
|
|
953
|
+
}
|
|
954
|
+
if (!uiImpact) {
|
|
955
|
+
return failSession(paths, {
|
|
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"
|
|
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
|
+
}
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
await writeReceipt(paths, "issue_details_gathered", "Saved confirmed issue details and recorded the GitHub issue comment.");
|
|
991
|
+
await markStatus(paths, SESSION_STATUS.RUNNING);
|
|
992
|
+
return buildSessionResponse(paths, {
|
|
993
|
+
preconditions
|
|
994
|
+
});
|
|
995
|
+
}
|
|
996
|
+
|
|
516
997
|
async function makePlan(paths, options = {}, context = {}) {
|
|
517
998
|
const preconditions = context.preconditions || [];
|
|
999
|
+
const activeCycle = await readActiveCycle(paths);
|
|
518
1000
|
const issueText = await readTrimmedFile(path.join(paths.sessionRoot, "issue.md"));
|
|
519
1001
|
const issueTitle = await readTrimmedFile(path.join(paths.sessionRoot, "issue_title")) || titleFromIssue(issueText);
|
|
520
1002
|
const issueUrl = await readTrimmedFile(path.join(paths.sessionRoot, "issue_url"));
|
|
521
1003
|
const issueNumber = issueNumberFromUrl(issueUrl);
|
|
1004
|
+
const issueDetails = await readTrimmedFile(path.join(paths.sessionRoot, "issue_details.md"));
|
|
522
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);
|
|
523
1012
|
|
|
524
1013
|
if (!planText) {
|
|
525
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"),
|
|
526
1019
|
issue_file: path.join(paths.sessionRoot, "issue.md"),
|
|
527
1020
|
issue_number: issueNumber,
|
|
528
1021
|
issue_text: issueText,
|
|
529
1022
|
issue_title: issueTitle,
|
|
530
1023
|
issue_title_file: path.join(paths.sessionRoot, "issue_title"),
|
|
531
1024
|
issue_url: issueUrl,
|
|
532
|
-
|
|
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 : "",
|
|
533
1031
|
worktree: paths.worktree
|
|
534
1032
|
});
|
|
535
|
-
await writePromptArtifact(paths,
|
|
1033
|
+
await writePromptArtifact(paths, cyclePlanPromptFileName(activeCycle), prompt);
|
|
536
1034
|
await markStatus(paths, SESSION_STATUS.WAITING_FOR_USER);
|
|
537
1035
|
return buildSessionResponse(paths, {
|
|
538
1036
|
ok: true,
|
|
@@ -542,31 +1040,48 @@ async function makePlan(paths, options = {}, context = {}) {
|
|
|
542
1040
|
});
|
|
543
1041
|
}
|
|
544
1042
|
|
|
545
|
-
|
|
1043
|
+
await mkdir(currentCycleRoot, { recursive: true });
|
|
546
1044
|
await writeTextFile(planPath, planText);
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
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
|
|
550
1050
|
});
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
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, {
|
|
556
1061
|
preconditions
|
|
557
1062
|
});
|
|
558
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);
|
|
559
1072
|
const executionPrompt = await renderPrompt(paths, "execute_plan.md", {
|
|
1073
|
+
active_cycle: activeCycle,
|
|
560
1074
|
issue_file: path.join(paths.sessionRoot, "issue.md"),
|
|
561
1075
|
issue_number: issueNumber,
|
|
562
1076
|
issue_title: issueTitle,
|
|
563
1077
|
issue_url: issueUrl,
|
|
1078
|
+
issue_details_file: issueDetailsPath,
|
|
1079
|
+
issue_details_text: issueDetails,
|
|
564
1080
|
plan_file: planPath,
|
|
565
1081
|
plan_text: planText,
|
|
566
1082
|
worktree: paths.worktree
|
|
567
1083
|
});
|
|
568
|
-
await writePromptArtifact(paths,
|
|
569
|
-
await writeReceipt(paths, "plan_made", `Saved plan and commented on ${issueUrl}.`);
|
|
1084
|
+
await writePromptArtifact(paths, cyclePlanExecutionPromptFileName(activeCycle), executionPrompt);
|
|
570
1085
|
await markStatus(paths, SESSION_STATUS.WAITING_FOR_USER);
|
|
571
1086
|
return buildSessionResponse(paths, {
|
|
572
1087
|
codex: PLAN_EXECUTION_CODEX_HANDOFF,
|
|
@@ -696,27 +1211,6 @@ async function inspectSessionDiff({
|
|
|
696
1211
|
});
|
|
697
1212
|
}
|
|
698
1213
|
|
|
699
|
-
async function acceptImplementationChanges(paths) {
|
|
700
|
-
const status = await worktreeStatus(paths.worktree);
|
|
701
|
-
if (!status.ok) {
|
|
702
|
-
return failSession(paths, {
|
|
703
|
-
code: "git_status_failed",
|
|
704
|
-
message: status.output || "Failed to inspect worktree changes.",
|
|
705
|
-
repairCommand: `git -C ${paths.worktree} status --short`
|
|
706
|
-
});
|
|
707
|
-
}
|
|
708
|
-
if (status.changedFiles.length < 1) {
|
|
709
|
-
return failSession(paths, {
|
|
710
|
-
code: "changes_missing",
|
|
711
|
-
message: "No worktree changes found. Ask Codex to implement the approved plan, inspect the worktree, then accept changes once ready.",
|
|
712
|
-
repairCommand: `jskit session ${paths.sessionId} step`
|
|
713
|
-
});
|
|
714
|
-
}
|
|
715
|
-
await writeReceipt(paths, "implementation_changes_accepted", `Accepted ${status.changedFiles.length} changed file entries for commit.`);
|
|
716
|
-
await markStatus(paths, SESSION_STATUS.RUNNING);
|
|
717
|
-
return buildSessionResponse(paths);
|
|
718
|
-
}
|
|
719
|
-
|
|
720
1214
|
async function commitWorktree(paths, {
|
|
721
1215
|
message,
|
|
722
1216
|
allowNoChanges = false
|
|
@@ -758,35 +1252,105 @@ async function commitWorktree(paths, {
|
|
|
758
1252
|
};
|
|
759
1253
|
}
|
|
760
1254
|
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
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
|
|
764
1285
|
});
|
|
765
1286
|
if (!result.ok) {
|
|
766
|
-
return
|
|
767
|
-
code: "commit_failed",
|
|
768
|
-
message: result.output || "Failed to commit implementation changes.",
|
|
769
|
-
repairCommand: `git -C ${paths.worktree} status --short`
|
|
770
|
-
});
|
|
1287
|
+
return (await changedFilesInWorktree(paths)).join("\n");
|
|
771
1288
|
}
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
1289
|
+
return uniqueChangedFileList([
|
|
1290
|
+
result.stdout,
|
|
1291
|
+
...(await changedFilesInWorktree(paths))
|
|
1292
|
+
]).join("\n");
|
|
775
1293
|
}
|
|
776
1294
|
|
|
777
|
-
|
|
778
|
-
const
|
|
779
|
-
|
|
780
|
-
|
|
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;
|
|
781
1316
|
}
|
|
782
|
-
return
|
|
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() : "";
|
|
783
1331
|
}
|
|
784
1332
|
|
|
785
1333
|
async function renderReviewPrompt(paths) {
|
|
1334
|
+
const reviewPass = await resolveReviewPassForPrompt(paths);
|
|
1335
|
+
await writeCurrentReviewPass(paths, reviewPass);
|
|
1336
|
+
const changedFiles = await changedFilesSinceBase(paths);
|
|
786
1337
|
const prompt = await renderPrompt(paths, "review_changes.md", {
|
|
787
|
-
changed_files:
|
|
1338
|
+
changed_files: changedFiles,
|
|
1339
|
+
review_pass_limit: String(REVIEW_PASS_LIMIT),
|
|
1340
|
+
review_pass_number: reviewPass
|
|
788
1341
|
});
|
|
1342
|
+
const passRoot = reviewPassRoot(paths, reviewPass);
|
|
789
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
|
+
});
|
|
790
1354
|
await writeReceipt(paths, "review_prompt_rendered", "Started code review.");
|
|
791
1355
|
await markStatus(paths, SESSION_STATUS.WAITING_FOR_USER);
|
|
792
1356
|
return buildSessionResponse(paths, {
|
|
@@ -796,41 +1360,186 @@ async function renderReviewPrompt(paths) {
|
|
|
796
1360
|
});
|
|
797
1361
|
}
|
|
798
1362
|
|
|
799
|
-
async function acceptReviewChanges(paths) {
|
|
800
|
-
const
|
|
801
|
-
|
|
1363
|
+
async function acceptReviewChanges(paths, options = {}) {
|
|
1364
|
+
const reviewDecisionProvided = Object.hasOwn(options, "reviewFindingsRemaining") ||
|
|
1365
|
+
Object.hasOwn(options, "review-findings-remaining");
|
|
1366
|
+
if (!reviewDecisionProvided) {
|
|
802
1367
|
return failSession(paths, {
|
|
803
|
-
code: "
|
|
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",
|
|
804
1378
|
message: status.output || "Failed to inspect review changes.",
|
|
805
1379
|
repairCommand: `git -C ${paths.worktree} status --short`
|
|
806
1380
|
});
|
|
807
1381
|
}
|
|
808
1382
|
const message = status.changedFiles.length > 0
|
|
809
|
-
? `Accepted ${status.changedFiles.length} review changed file entries
|
|
1383
|
+
? `Accepted ${status.changedFiles.length} review changed file entries.`
|
|
810
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
|
+
});
|
|
811
1397
|
await writeReceipt(paths, "review_changes_accepted", message);
|
|
812
1398
|
await markStatus(paths, SESSION_STATUS.RUNNING);
|
|
813
1399
|
return buildSessionResponse(paths);
|
|
814
1400
|
}
|
|
815
1401
|
|
|
816
|
-
async function
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
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
|
|
820
1431
|
});
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
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
|
|
826
1498
|
});
|
|
827
1499
|
}
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
await
|
|
832
|
-
|
|
833
|
-
|
|
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
|
+
});
|
|
834
1543
|
}
|
|
835
1544
|
|
|
836
1545
|
async function userCheck(paths, options = {}) {
|
|
@@ -841,13 +1550,48 @@ async function userCheck(paths, options = {}) {
|
|
|
841
1550
|
return buildSessionResponse(paths);
|
|
842
1551
|
}
|
|
843
1552
|
if (result === "failed" || result === "fail" || result === "no") {
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
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
|
|
848
1577
|
});
|
|
1578
|
+
await markCurrentStep(paths, "plan_made");
|
|
1579
|
+
await markStatus(paths, SESSION_STATUS.RUNNING);
|
|
1580
|
+
return buildSessionResponse(paths);
|
|
849
1581
|
}
|
|
850
|
-
const
|
|
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
|
+
});
|
|
851
1595
|
await writePromptArtifact(paths, "user_check.md", prompt);
|
|
852
1596
|
await markStatus(paths, SESSION_STATUS.WAITING_FOR_USER);
|
|
853
1597
|
return buildSessionResponse(paths, {
|
|
@@ -856,6 +1600,141 @@ async function userCheck(paths, options = {}) {
|
|
|
856
1600
|
});
|
|
857
1601
|
}
|
|
858
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
|
+
|
|
859
1738
|
async function readPackageJson(root) {
|
|
860
1739
|
try {
|
|
861
1740
|
return JSON.parse(await readFile(path.join(root, "package.json"), "utf8"));
|
|
@@ -873,12 +1752,16 @@ async function doctorCommandForWorktree(worktree) {
|
|
|
873
1752
|
if (scripts.verify) {
|
|
874
1753
|
return ["npm", ["run", "verify"]];
|
|
875
1754
|
}
|
|
876
|
-
return ["npx", ["jskit", "app", "verify"]];
|
|
1755
|
+
return ["npx", ["--no-install", "jskit", "app", "verify"]];
|
|
877
1756
|
}
|
|
878
1757
|
|
|
879
1758
|
async function runDoctor(paths) {
|
|
1759
|
+
const repairCommitFailure = await maybeCommitDoctorRepair(paths);
|
|
1760
|
+
if (repairCommitFailure) {
|
|
1761
|
+
return repairCommitFailure;
|
|
1762
|
+
}
|
|
880
1763
|
const [command, args] = await doctorCommandForWorktree(paths.worktree);
|
|
881
|
-
const result = await
|
|
1764
|
+
const result = await runLoggedCommand(paths, "doctor_run", command, args, {
|
|
882
1765
|
cwd: paths.worktree,
|
|
883
1766
|
timeout: 1000 * 60 * 15
|
|
884
1767
|
});
|
|
@@ -900,13 +1783,513 @@ async function runDoctor(paths) {
|
|
|
900
1783
|
return buildSessionResponse(paths);
|
|
901
1784
|
}
|
|
902
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
|
+
|
|
903
1974
|
function issueNumberFromUrl(issueUrl) {
|
|
904
1975
|
const match = /\/issues\/(\d+)(?:\b|$)/u.exec(String(issueUrl || ""));
|
|
905
1976
|
return match ? match[1] : "";
|
|
906
1977
|
}
|
|
907
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) {
|
|
1998
|
+
return {
|
|
1999
|
+
ok: false,
|
|
2000
|
+
output: result.output
|
|
2001
|
+
};
|
|
2002
|
+
}
|
|
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
|
|
2018
|
+
});
|
|
2019
|
+
if (!result.ok) {
|
|
2020
|
+
return {
|
|
2021
|
+
ok: false,
|
|
2022
|
+
output: result.output
|
|
2023
|
+
};
|
|
2024
|
+
}
|
|
2025
|
+
const payload = parseJsonObject(result.stdout);
|
|
2026
|
+
return {
|
|
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 || ""
|
|
2033
|
+
};
|
|
2034
|
+
}
|
|
2035
|
+
|
|
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
|
|
2047
|
+
});
|
|
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) {
|
|
2056
|
+
return failSession(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`
|
|
2060
|
+
});
|
|
2061
|
+
}
|
|
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;
|
|
2070
|
+
}
|
|
2071
|
+
|
|
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
|
+
}
|
|
2085
|
+
}
|
|
2086
|
+
return null;
|
|
2087
|
+
}
|
|
2088
|
+
|
|
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`);
|
|
2094
|
+
}
|
|
2095
|
+
|
|
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
|
+
}
|
|
2110
|
+
|
|
2111
|
+
return null;
|
|
2112
|
+
}
|
|
2113
|
+
|
|
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
|
|
2123
|
+
});
|
|
2124
|
+
if (!fetchResult.ok) {
|
|
2125
|
+
return failSession(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`
|
|
2129
|
+
});
|
|
2130
|
+
}
|
|
2131
|
+
|
|
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
|
+
}
|
|
2143
|
+
|
|
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
|
+
});
|
|
2153
|
+
}
|
|
2154
|
+
|
|
2155
|
+
return null;
|
|
2156
|
+
}
|
|
2157
|
+
|
|
2158
|
+
async function updateLocalBaseBranch(paths, baseBranch = "") {
|
|
2159
|
+
const branch = normalizeText(baseBranch) || await currentTargetBranch(paths.targetRoot);
|
|
2160
|
+
if (!branch) {
|
|
2161
|
+
return failSession(paths, {
|
|
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`
|
|
2165
|
+
});
|
|
2166
|
+
}
|
|
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
|
|
2175
|
+
});
|
|
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
|
|
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;
|
|
2198
|
+
}
|
|
2199
|
+
|
|
2200
|
+
async function updateHelperMapBeforePr(paths) {
|
|
2201
|
+
let helperMapPayload;
|
|
2202
|
+
try {
|
|
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
|
+
};
|
|
2214
|
+
}
|
|
2215
|
+
|
|
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
|
+
};
|
|
2232
|
+
}
|
|
2233
|
+
|
|
2234
|
+
if (!statusResult.stdout.trim()) {
|
|
2235
|
+
return {
|
|
2236
|
+
ok: true,
|
|
2237
|
+
changed: false,
|
|
2238
|
+
message: "Helper map already up to date."
|
|
2239
|
+
};
|
|
2240
|
+
}
|
|
2241
|
+
|
|
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
|
|
2248
|
+
});
|
|
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
|
+
|
|
908
2281
|
async function createPr(paths) {
|
|
909
|
-
const
|
|
2282
|
+
const helperMapResult = await updateHelperMapBeforePr(paths);
|
|
2283
|
+
if (!helperMapResult.ok) {
|
|
2284
|
+
return failSession(paths, {
|
|
2285
|
+
code: helperMapResult.code,
|
|
2286
|
+
message: helperMapResult.message,
|
|
2287
|
+
repairCommand: helperMapResult.repairCommand
|
|
2288
|
+
});
|
|
2289
|
+
}
|
|
2290
|
+
|
|
2291
|
+
const pushResult = await runLoggedCommand(paths, "git_push_branch", "git", ["push", "-u", "origin", "HEAD"], {
|
|
2292
|
+
cwd: paths.worktree,
|
|
910
2293
|
timeout: 1000 * 60 * 5
|
|
911
2294
|
});
|
|
912
2295
|
if (!pushResult.ok) {
|
|
@@ -916,6 +2299,13 @@ async function createPr(paths) {
|
|
|
916
2299
|
repairCommand: `git -C ${paths.worktree} push -u origin HEAD`
|
|
917
2300
|
});
|
|
918
2301
|
}
|
|
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
|
+
}
|
|
919
2309
|
const issueUrl = await readTrimmedFile(path.join(paths.sessionRoot, "issue_url"));
|
|
920
2310
|
const issueText = await readTrimmedFile(path.join(paths.sessionRoot, "issue.md"));
|
|
921
2311
|
const issueTitle = await readTrimmedFile(path.join(paths.sessionRoot, "issue_title")) || titleFromIssue(issueText);
|
|
@@ -927,7 +2317,7 @@ async function createPr(paths) {
|
|
|
927
2317
|
].join("\n").trim();
|
|
928
2318
|
const bodyPath = path.join(paths.sessionRoot, "pr_body.md");
|
|
929
2319
|
await writeTextFile(bodyPath, body);
|
|
930
|
-
const result = await
|
|
2320
|
+
const result = await runLoggedCommand(paths, "github_pr_create", "gh", [
|
|
931
2321
|
"pr",
|
|
932
2322
|
"create",
|
|
933
2323
|
"--title",
|
|
@@ -939,6 +2329,13 @@ async function createPr(paths) {
|
|
|
939
2329
|
timeout: 1000 * 60
|
|
940
2330
|
});
|
|
941
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
|
+
}
|
|
942
2339
|
const prompt = await renderPrompt(paths, "pr_failure.md", {
|
|
943
2340
|
doctor_output: result.output
|
|
944
2341
|
});
|
|
@@ -952,54 +2349,150 @@ async function createPr(paths) {
|
|
|
952
2349
|
}
|
|
953
2350
|
const prUrl = result.stdout.split(/\r?\n/u).map((line) => line.trim()).find(Boolean) || result.stdout;
|
|
954
2351
|
await writeTextFile(path.join(paths.sessionRoot, "pr_url"), prUrl);
|
|
955
|
-
await writeReceipt(paths, "pr_created", `Pushed branch ${paths.branch} and created PR ${prUrl}
|
|
2352
|
+
await writeReceipt(paths, "pr_created", `Pushed branch ${paths.branch} and created PR ${prUrl}. ${helperMapResult.message}`);
|
|
2353
|
+
await markStatus(paths, SESSION_STATUS.RUNNING);
|
|
2354
|
+
return buildSessionResponse(paths);
|
|
2355
|
+
}
|
|
2356
|
+
|
|
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>"`
|
|
2364
|
+
});
|
|
2365
|
+
}
|
|
2366
|
+
const prState = await readPrState(paths, prUrl);
|
|
2367
|
+
if (!prState.ok) {
|
|
2368
|
+
return failSession(paths, {
|
|
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
|
|
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
|
+
}
|
|
2393
|
+
}
|
|
2394
|
+
const issueUrl = await readTrimmedFile(path.join(paths.sessionRoot, "issue_url"));
|
|
2395
|
+
if (issueUrl) {
|
|
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,
|
|
2398
|
+
timeout: 1000 * 60
|
|
2399
|
+
});
|
|
2400
|
+
}
|
|
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}`);
|
|
956
2413
|
await markStatus(paths, SESSION_STATUS.RUNNING);
|
|
957
2414
|
return buildSessionResponse(paths);
|
|
958
2415
|
}
|
|
959
2416
|
|
|
960
|
-
async function
|
|
2417
|
+
async function finalizePr(paths, options = {}) {
|
|
961
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`
|
|
2431
|
+
});
|
|
2432
|
+
}
|
|
962
2433
|
const mergeMarkerPath = path.join(paths.sessionRoot, "pr_merge_completed");
|
|
2434
|
+
const baseBranchPath = path.join(paths.sessionRoot, "pr_base_branch");
|
|
963
2435
|
const mergeAlreadyCompleted = await readTrimmedFile(mergeMarkerPath);
|
|
2436
|
+
let baseBranch = await readTrimmedFile(baseBranchPath);
|
|
964
2437
|
if (!mergeAlreadyCompleted) {
|
|
965
|
-
const
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
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) {
|
|
970
2461
|
const prompt = await renderPrompt(paths, "pr_failure.md", {
|
|
971
|
-
doctor_output: mergeResult.output
|
|
2462
|
+
doctor_output: mergeResult?.output || existingPrState.output
|
|
972
2463
|
});
|
|
973
2464
|
await writePromptArtifact(paths, "pr_merge_failure.md", prompt);
|
|
974
2465
|
return failSession(paths, {
|
|
975
2466
|
code: "pr_merge_failed",
|
|
976
|
-
message: mergeResult.output || "Failed to merge PR.",
|
|
2467
|
+
message: mergeResult?.output || existingPrState.output || "Failed to merge PR.",
|
|
977
2468
|
repairCommand: `gh pr merge ${prUrl} --merge --delete-branch`,
|
|
978
2469
|
prompt
|
|
979
2470
|
});
|
|
980
2471
|
}
|
|
981
2472
|
const issueUrl = await readTrimmedFile(path.join(paths.sessionRoot, "issue_url"));
|
|
982
2473
|
if (issueUrl) {
|
|
983
|
-
await
|
|
984
|
-
cwd: paths.
|
|
2474
|
+
await runLoggedCommand(paths, "github_issue_close", "gh", ["issue", "close", issueUrl, "--comment", `Merged PR ${prUrl}.`], {
|
|
2475
|
+
cwd: paths.targetRoot,
|
|
985
2476
|
timeout: 1000 * 60
|
|
986
2477
|
});
|
|
987
2478
|
}
|
|
2479
|
+
await writePrOutcome(paths, {
|
|
2480
|
+
baseBranch,
|
|
2481
|
+
issueUrl,
|
|
2482
|
+
outcome: "merged",
|
|
2483
|
+
prUrl
|
|
2484
|
+
});
|
|
988
2485
|
await writeTextFile(mergeMarkerPath, `${prUrl}\n`);
|
|
989
2486
|
}
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
});
|
|
994
|
-
if (!result.ok) {
|
|
995
|
-
return failSession(paths, {
|
|
996
|
-
code: "worktree_remove_failed",
|
|
997
|
-
message: result.output || "Failed to remove worktree.",
|
|
998
|
-
repairCommand: `git worktree remove ${paths.worktree}`
|
|
999
|
-
});
|
|
1000
|
-
}
|
|
2487
|
+
const updateFailure = await updateLocalBaseBranch(paths, baseBranch);
|
|
2488
|
+
if (updateFailure) {
|
|
2489
|
+
return updateFailure;
|
|
1001
2490
|
}
|
|
1002
|
-
|
|
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}.`);
|
|
1003
2496
|
await markStatus(paths, SESSION_STATUS.RUNNING);
|
|
1004
2497
|
return buildSessionResponse(paths);
|
|
1005
2498
|
}
|
|
@@ -1008,20 +2501,24 @@ async function finishSession(paths) {
|
|
|
1008
2501
|
const prUrl = await readTrimmedFile(path.join(paths.sessionRoot, "pr_url"));
|
|
1009
2502
|
const issueUrl = await readTrimmedFile(path.join(paths.sessionRoot, "issue_url"));
|
|
1010
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")));
|
|
1011
2505
|
const prompt = await renderPrompt(paths, "final_comment.md", {
|
|
1012
2506
|
codex_thread_id: codexThreadId,
|
|
1013
2507
|
issue_url: issueUrl,
|
|
2508
|
+
pr_outcome: prOutcome?.outcome || "unknown",
|
|
2509
|
+
pr_outcome_reason: prOutcome?.reason || "",
|
|
1014
2510
|
pr_url: prUrl,
|
|
2511
|
+
session_id: paths.sessionId,
|
|
1015
2512
|
transcript_log: path.join(paths.completedSessionRoot, "transcript.log")
|
|
1016
2513
|
});
|
|
1017
2514
|
await writeTextFile(path.join(paths.sessionRoot, "final_comment.md"), prompt);
|
|
1018
2515
|
if (issueUrl) {
|
|
1019
|
-
await
|
|
2516
|
+
await runLoggedCommand(paths, "github_issue_comment", "gh", ["issue", "comment", issueUrl, "--body-file", path.join(paths.sessionRoot, "final_comment.md")], {
|
|
1020
2517
|
cwd: paths.targetRoot,
|
|
1021
2518
|
timeout: 1000 * 60
|
|
1022
2519
|
});
|
|
1023
2520
|
}
|
|
1024
|
-
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"}.`);
|
|
1025
2522
|
await markStatus(paths, SESSION_STATUS.FINISHED);
|
|
1026
2523
|
await markCurrentStep(paths, "");
|
|
1027
2524
|
const archivedPaths = await archiveSession(paths, "completed");
|
|
@@ -1036,27 +2533,50 @@ const STEP_RUNNERS = Object.freeze({
|
|
|
1036
2533
|
issue_prompt_rendered: renderIssuePrompt,
|
|
1037
2534
|
issue_drafted: draftIssue,
|
|
1038
2535
|
issue_created: createIssue,
|
|
2536
|
+
issue_details_gathered: saveIssueDetails,
|
|
1039
2537
|
plan_made: makePlan,
|
|
1040
|
-
|
|
1041
|
-
|
|
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),
|
|
1042
2548
|
review_prompt_rendered: renderReviewPrompt,
|
|
1043
2549
|
review_changes_accepted: acceptReviewChanges,
|
|
1044
|
-
review_changes_committed: commitReviewChanges,
|
|
1045
2550
|
user_check_completed: userCheck,
|
|
2551
|
+
changes_committed: commitAcceptedChanges,
|
|
2552
|
+
blueprint_updated: updateBlueprint,
|
|
1046
2553
|
doctor_run: runDoctor,
|
|
2554
|
+
final_report_created: createFinalReport,
|
|
1047
2555
|
pr_created: createPr,
|
|
1048
|
-
|
|
2556
|
+
pr_finalized: finalizePr,
|
|
1049
2557
|
session_finished: finishSession
|
|
1050
2558
|
});
|
|
1051
2559
|
|
|
1052
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,
|
|
1053
2568
|
git_current_branch: (paths) => assertGitCurrentBranch(paths.targetRoot),
|
|
1054
2569
|
git_repository: (paths) => assertGitRepository(paths.targetRoot),
|
|
1055
2570
|
github_auth: (paths) => assertGhAuth(paths.targetRoot),
|
|
1056
2571
|
github_origin: (paths) => assertGithubOrigin(paths.targetRoot),
|
|
2572
|
+
issue_metadata_exists: assertIssueMetadataExists,
|
|
1057
2573
|
issue_text_exists: assertIssueTextExists,
|
|
1058
2574
|
issue_url_exists: assertIssueUrlExists,
|
|
2575
|
+
automated_checks_passed: assertAutomatedChecksPassed,
|
|
2576
|
+
issue_details_exists: assertIssueDetailsExists,
|
|
2577
|
+
plan_text_exists: assertPlanTextExists,
|
|
1059
2578
|
pr_url_exists: assertPrUrlExists,
|
|
2579
|
+
ready_jskit_app: assertReadyJskitApp,
|
|
1060
2580
|
session_exists: assertSessionExists,
|
|
1061
2581
|
worktree_exists: assertWorktreeExists
|
|
1062
2582
|
});
|
|
@@ -1083,6 +2603,18 @@ async function runSessionStep({
|
|
|
1083
2603
|
status: artifacts.status
|
|
1084
2604
|
});
|
|
1085
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
|
+
}
|
|
1086
2618
|
const nextStep = artifacts.nextStep;
|
|
1087
2619
|
if (!nextStep) {
|
|
1088
2620
|
return finishSession(paths);
|
|
@@ -1109,6 +2641,7 @@ async function runSessionStep({
|
|
|
1109
2641
|
preconditions: stepPreconditions.preconditions
|
|
1110
2642
|
});
|
|
1111
2643
|
}
|
|
2644
|
+
await appendAgentDecisionsInput(paths, options);
|
|
1112
2645
|
return runner(paths, options, {
|
|
1113
2646
|
preconditions: stepPreconditions.preconditions
|
|
1114
2647
|
});
|
|
@@ -1128,7 +2661,7 @@ async function abandonSession({
|
|
|
1128
2661
|
}
|
|
1129
2662
|
const issueUrl = await readTrimmedFile(path.join(paths.sessionRoot, "issue_url"));
|
|
1130
2663
|
if (issueUrl) {
|
|
1131
|
-
const closeIssueResult = await
|
|
2664
|
+
const closeIssueResult = await runLoggedCommand(paths, "github_issue_close", "gh", ["issue", "close", issueUrl, "--comment", `Abandoned JSKIT Studio session ${paths.sessionId}.`], {
|
|
1132
2665
|
cwd: paths.targetRoot,
|
|
1133
2666
|
timeout: 1000 * 60
|
|
1134
2667
|
});
|
|
@@ -1142,7 +2675,8 @@ async function abandonSession({
|
|
|
1142
2675
|
}
|
|
1143
2676
|
}
|
|
1144
2677
|
if (await hasWorktree(paths)) {
|
|
1145
|
-
await
|
|
2678
|
+
await runLoggedCommand(paths, "git_worktree_remove", "git", ["worktree", "remove", "--force", paths.worktree], {
|
|
2679
|
+
cwd: paths.targetRoot,
|
|
1146
2680
|
timeout: 1000 * 60
|
|
1147
2681
|
});
|
|
1148
2682
|
}
|
|
@@ -1205,6 +2739,7 @@ export {
|
|
|
1205
2739
|
createSessionId,
|
|
1206
2740
|
extractIssueTitle,
|
|
1207
2741
|
extractIssueText,
|
|
2742
|
+
extractIssueDetails,
|
|
1208
2743
|
extractPlanText,
|
|
1209
2744
|
inspectSession,
|
|
1210
2745
|
inspectSessionDiff,
|