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