@jskit-ai/jskit-cli 0.2.79 → 0.2.80
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +4 -4
- package/src/server/appBlueprint.js +126 -0
- package/src/server/commandHandlers/blueprint.js +151 -0
- package/src/server/commandHandlers/session.js +237 -0
- package/src/server/core/argParser.js +2 -2
- package/src/server/core/commandCatalog.js +83 -0
- package/src/server/core/createCommandHandlers.js +7 -1
- package/src/server/index.js +2 -0
- package/src/server/sessionRuntime/constants.js +296 -0
- package/src/server/sessionRuntime/io.js +97 -0
- package/src/server/sessionRuntime/paths.js +165 -0
- package/src/server/sessionRuntime/preconditions.js +372 -0
- package/src/server/sessionRuntime/promptRenderer.js +41 -0
- package/src/server/sessionRuntime/prompts/app_blueprint.md +28 -0
- package/src/server/sessionRuntime/prompts/doctor_failure.md +15 -0
- package/src/server/sessionRuntime/prompts/final_comment.md +8 -0
- package/src/server/sessionRuntime/prompts/implement_issue.md +25 -0
- package/src/server/sessionRuntime/prompts/new_issue.md +13 -0
- package/src/server/sessionRuntime/prompts/pr_failure.md +15 -0
- package/src/server/sessionRuntime/prompts/review_changes.md +22 -0
- package/src/server/sessionRuntime/prompts/user_check.md +9 -0
- package/src/server/sessionRuntime/responses.js +315 -0
- package/src/server/sessionRuntime.js +927 -0
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { fileURLToPath } from "node:url";
|
|
3
|
+
|
|
4
|
+
const SESSION_STATUS = Object.freeze({
|
|
5
|
+
ABANDONED: "abandoned",
|
|
6
|
+
BLOCKED: "blocked",
|
|
7
|
+
FAILED: "failed",
|
|
8
|
+
FINISHED: "finished",
|
|
9
|
+
PENDING: "pending",
|
|
10
|
+
RUNNING: "running",
|
|
11
|
+
WAITING_FOR_USER: "waiting_for_user"
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
const SESSION_ID_PATTERN = /^\d{4}-\d{2}-\d{2}_\d{2}-\d{2}-\d{2}(?:-[a-z0-9]{4})?$/u;
|
|
15
|
+
const SESSION_STATE_RELATIVE_PATH = ".jskit/sessions";
|
|
16
|
+
const PROMPT_DIRECTORY = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "prompts");
|
|
17
|
+
|
|
18
|
+
const INPUT_NONE = Object.freeze({ type: "none" });
|
|
19
|
+
const ISSUE_TEXT_INPUT = Object.freeze({
|
|
20
|
+
extract: "issue_text",
|
|
21
|
+
formatHint: "markdown",
|
|
22
|
+
label: "Approved issue text",
|
|
23
|
+
multiline: true,
|
|
24
|
+
name: "issue",
|
|
25
|
+
required: true,
|
|
26
|
+
type: "text"
|
|
27
|
+
});
|
|
28
|
+
const USER_CHECK_INPUT = Object.freeze({
|
|
29
|
+
label: "User check result",
|
|
30
|
+
name: "userCheck",
|
|
31
|
+
options: Object.freeze([
|
|
32
|
+
Object.freeze({ label: "Passed", value: "passed" }),
|
|
33
|
+
Object.freeze({ label: "Failed", value: "failed" })
|
|
34
|
+
]),
|
|
35
|
+
required: true,
|
|
36
|
+
type: "choice"
|
|
37
|
+
});
|
|
38
|
+
const CODEX_WORKTREE_OUTPUT = Object.freeze({
|
|
39
|
+
field: "worktree",
|
|
40
|
+
formatHint: "git_changes"
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
function codexHandoff(expectedOutput) {
|
|
44
|
+
return Object.freeze({
|
|
45
|
+
expectedOutput,
|
|
46
|
+
mode: "inject_prompt",
|
|
47
|
+
promptField: "prompt"
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function defineStep({
|
|
52
|
+
buttonLabel,
|
|
53
|
+
codex = undefined,
|
|
54
|
+
description,
|
|
55
|
+
id,
|
|
56
|
+
input = INPUT_NONE,
|
|
57
|
+
kind = "automatic",
|
|
58
|
+
label,
|
|
59
|
+
nextCommandTemplate = "jskit session {{session_id}} step",
|
|
60
|
+
preconditions = []
|
|
61
|
+
}) {
|
|
62
|
+
return Object.freeze({
|
|
63
|
+
buttonLabel,
|
|
64
|
+
codex,
|
|
65
|
+
description,
|
|
66
|
+
id,
|
|
67
|
+
input,
|
|
68
|
+
kind,
|
|
69
|
+
label,
|
|
70
|
+
nextCommandTemplate,
|
|
71
|
+
preconditions: Object.freeze([...preconditions])
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const STEP_DEFINITIONS = Object.freeze([
|
|
76
|
+
defineStep({
|
|
77
|
+
buttonLabel: "Create session",
|
|
78
|
+
description: "Create the filesystem-backed session state.",
|
|
79
|
+
id: "session_created",
|
|
80
|
+
label: "Session created"
|
|
81
|
+
}),
|
|
82
|
+
defineStep({
|
|
83
|
+
buttonLabel: "Create worktree",
|
|
84
|
+
description: "Create the isolated session worktree and branch.",
|
|
85
|
+
id: "worktree_created",
|
|
86
|
+
label: "Worktree created",
|
|
87
|
+
preconditions: ["session_exists", "git_repository", "git_current_branch"]
|
|
88
|
+
}),
|
|
89
|
+
defineStep({
|
|
90
|
+
buttonLabel: "Render issue prompt",
|
|
91
|
+
description: "Render the prompt Codex should use to draft the GitHub issue.",
|
|
92
|
+
id: "issue_prompt_rendered",
|
|
93
|
+
input: Object.freeze({
|
|
94
|
+
label: "What should change?",
|
|
95
|
+
multiline: true,
|
|
96
|
+
name: "prompt",
|
|
97
|
+
placeholder: "Describe the feature, bug, or change request.",
|
|
98
|
+
required: true,
|
|
99
|
+
type: "text"
|
|
100
|
+
}),
|
|
101
|
+
kind: "human_input",
|
|
102
|
+
label: "Issue prompt rendered",
|
|
103
|
+
nextCommandTemplate: "jskit session {{session_id}} step --prompt \"<what should change>\"",
|
|
104
|
+
preconditions: ["session_exists"]
|
|
105
|
+
}),
|
|
106
|
+
defineStep({
|
|
107
|
+
buttonLabel: "Save issue text",
|
|
108
|
+
codex: codexHandoff(Object.freeze({
|
|
109
|
+
extract: "issue_text",
|
|
110
|
+
field: "issue",
|
|
111
|
+
formatHint: "markdown"
|
|
112
|
+
})),
|
|
113
|
+
description: "Save the approved issue text produced by Codex.",
|
|
114
|
+
id: "issue_drafted",
|
|
115
|
+
input: ISSUE_TEXT_INPUT,
|
|
116
|
+
kind: "codex_output",
|
|
117
|
+
label: "Issue drafted",
|
|
118
|
+
nextCommandTemplate: "jskit session {{session_id}} step --issue -",
|
|
119
|
+
preconditions: ["session_exists"]
|
|
120
|
+
}),
|
|
121
|
+
defineStep({
|
|
122
|
+
buttonLabel: "Create GitHub issue",
|
|
123
|
+
description: "Create the GitHub issue with gh.",
|
|
124
|
+
id: "issue_created",
|
|
125
|
+
label: "Issue created",
|
|
126
|
+
preconditions: ["session_exists", "issue_text_exists", "github_auth", "github_origin"]
|
|
127
|
+
}),
|
|
128
|
+
defineStep({
|
|
129
|
+
buttonLabel: "Render implementation prompt",
|
|
130
|
+
description: "Render the prompt Codex should use to implement the approved issue.",
|
|
131
|
+
id: "implementation_prompt_rendered",
|
|
132
|
+
kind: "codex_prompt",
|
|
133
|
+
label: "Implementation prompt rendered",
|
|
134
|
+
preconditions: ["session_exists", "issue_artifacts"]
|
|
135
|
+
}),
|
|
136
|
+
defineStep({
|
|
137
|
+
buttonLabel: "Detect implementation changes",
|
|
138
|
+
codex: codexHandoff(CODEX_WORKTREE_OUTPUT),
|
|
139
|
+
description: "Confirm that Codex changed files in the session worktree.",
|
|
140
|
+
id: "implementation_changes_detected",
|
|
141
|
+
kind: "codex_output",
|
|
142
|
+
label: "Changes detected",
|
|
143
|
+
preconditions: ["session_exists", "worktree_exists"]
|
|
144
|
+
}),
|
|
145
|
+
defineStep({
|
|
146
|
+
buttonLabel: "Commit implementation",
|
|
147
|
+
description: "Commit the implementation changes in the session worktree.",
|
|
148
|
+
id: "implementation_changes_committed",
|
|
149
|
+
label: "Changes committed",
|
|
150
|
+
preconditions: ["session_exists", "worktree_exists"]
|
|
151
|
+
}),
|
|
152
|
+
defineStep({
|
|
153
|
+
buttonLabel: "Render review prompt 1",
|
|
154
|
+
description: "Render the first Codex review prompt for the committed changes.",
|
|
155
|
+
id: "initial_review_prompt_rendered",
|
|
156
|
+
kind: "codex_prompt",
|
|
157
|
+
label: "Review prompt rendered 1",
|
|
158
|
+
preconditions: ["session_exists", "worktree_exists"]
|
|
159
|
+
}),
|
|
160
|
+
defineStep({
|
|
161
|
+
buttonLabel: "Detect review changes 1",
|
|
162
|
+
codex: codexHandoff(CODEX_WORKTREE_OUTPUT),
|
|
163
|
+
description: "Commit any changes Codex made after the first review pass.",
|
|
164
|
+
id: "initial_review_changes_detected",
|
|
165
|
+
kind: "codex_output",
|
|
166
|
+
label: "Review changes detected 1",
|
|
167
|
+
preconditions: ["session_exists", "worktree_exists"]
|
|
168
|
+
}),
|
|
169
|
+
defineStep({
|
|
170
|
+
buttonLabel: "Save user check 1",
|
|
171
|
+
description: "Record whether the first user check passed.",
|
|
172
|
+
id: "initial_user_check_completed",
|
|
173
|
+
input: USER_CHECK_INPUT,
|
|
174
|
+
kind: "user_check",
|
|
175
|
+
label: "User check 1",
|
|
176
|
+
nextCommandTemplate: "jskit session {{session_id}} step --user-check passed",
|
|
177
|
+
preconditions: ["session_exists"]
|
|
178
|
+
}),
|
|
179
|
+
defineStep({
|
|
180
|
+
buttonLabel: "Render review prompt 2",
|
|
181
|
+
description: "Render the second Codex review prompt for the committed changes.",
|
|
182
|
+
id: "followup_review_prompt_rendered",
|
|
183
|
+
kind: "codex_prompt",
|
|
184
|
+
label: "Review prompt rendered 2",
|
|
185
|
+
preconditions: ["session_exists", "worktree_exists"]
|
|
186
|
+
}),
|
|
187
|
+
defineStep({
|
|
188
|
+
buttonLabel: "Detect review changes 2",
|
|
189
|
+
codex: codexHandoff(CODEX_WORKTREE_OUTPUT),
|
|
190
|
+
description: "Commit any changes Codex made after the second review pass.",
|
|
191
|
+
id: "followup_review_changes_detected",
|
|
192
|
+
kind: "codex_output",
|
|
193
|
+
label: "Review changes detected 2",
|
|
194
|
+
preconditions: ["session_exists", "worktree_exists"]
|
|
195
|
+
}),
|
|
196
|
+
defineStep({
|
|
197
|
+
buttonLabel: "Save user check 2",
|
|
198
|
+
description: "Record whether the second user check passed.",
|
|
199
|
+
id: "followup_user_check_completed",
|
|
200
|
+
input: USER_CHECK_INPUT,
|
|
201
|
+
kind: "user_check",
|
|
202
|
+
label: "User check 2",
|
|
203
|
+
nextCommandTemplate: "jskit session {{session_id}} step --user-check passed",
|
|
204
|
+
preconditions: ["session_exists"]
|
|
205
|
+
}),
|
|
206
|
+
defineStep({
|
|
207
|
+
buttonLabel: "Render final review prompt",
|
|
208
|
+
description: "Render the final Codex review prompt before verification and PR.",
|
|
209
|
+
id: "final_review_prompt_rendered",
|
|
210
|
+
kind: "codex_prompt",
|
|
211
|
+
label: "Review prompt rendered 3",
|
|
212
|
+
preconditions: ["session_exists", "worktree_exists"]
|
|
213
|
+
}),
|
|
214
|
+
defineStep({
|
|
215
|
+
buttonLabel: "Detect final review changes",
|
|
216
|
+
codex: codexHandoff(CODEX_WORKTREE_OUTPUT),
|
|
217
|
+
description: "Commit any changes Codex made after the final review pass.",
|
|
218
|
+
id: "final_review_changes_detected",
|
|
219
|
+
kind: "codex_output",
|
|
220
|
+
label: "Review changes detected 3",
|
|
221
|
+
preconditions: ["session_exists", "worktree_exists"]
|
|
222
|
+
}),
|
|
223
|
+
defineStep({
|
|
224
|
+
buttonLabel: "Save final user check",
|
|
225
|
+
description: "Record whether the final user check passed.",
|
|
226
|
+
id: "final_user_check_completed",
|
|
227
|
+
input: USER_CHECK_INPUT,
|
|
228
|
+
kind: "user_check",
|
|
229
|
+
label: "User check 3",
|
|
230
|
+
nextCommandTemplate: "jskit session {{session_id}} step --user-check passed",
|
|
231
|
+
preconditions: ["session_exists"]
|
|
232
|
+
}),
|
|
233
|
+
defineStep({
|
|
234
|
+
buttonLabel: "Run verification",
|
|
235
|
+
description: "Run the project verification command in the session worktree.",
|
|
236
|
+
id: "doctor_run",
|
|
237
|
+
label: "Doctor run",
|
|
238
|
+
preconditions: ["session_exists", "worktree_exists"]
|
|
239
|
+
}),
|
|
240
|
+
defineStep({
|
|
241
|
+
buttonLabel: "Push branch",
|
|
242
|
+
description: "Push the session branch to origin.",
|
|
243
|
+
id: "branch_pushed",
|
|
244
|
+
label: "Branch pushed",
|
|
245
|
+
preconditions: ["session_exists", "worktree_exists"]
|
|
246
|
+
}),
|
|
247
|
+
defineStep({
|
|
248
|
+
buttonLabel: "Create PR",
|
|
249
|
+
description: "Create a GitHub pull request for the session branch.",
|
|
250
|
+
id: "pr_created",
|
|
251
|
+
label: "PR created",
|
|
252
|
+
preconditions: ["session_exists", "worktree_exists"]
|
|
253
|
+
}),
|
|
254
|
+
defineStep({
|
|
255
|
+
buttonLabel: "Merge PR",
|
|
256
|
+
description: "Merge the pull request and close the GitHub issue.",
|
|
257
|
+
id: "pr_merged",
|
|
258
|
+
label: "PR merged",
|
|
259
|
+
preconditions: ["session_exists", "pr_url_exists"]
|
|
260
|
+
}),
|
|
261
|
+
defineStep({
|
|
262
|
+
buttonLabel: "Remove worktree",
|
|
263
|
+
description: "Remove the session worktree after the PR has merged.",
|
|
264
|
+
id: "worktree_removed",
|
|
265
|
+
label: "Worktree removed",
|
|
266
|
+
preconditions: ["session_exists"]
|
|
267
|
+
}),
|
|
268
|
+
defineStep({
|
|
269
|
+
buttonLabel: "Finish session",
|
|
270
|
+
description: "Write the final receipt and archive the completed session.",
|
|
271
|
+
id: "session_finished",
|
|
272
|
+
label: "Session finished",
|
|
273
|
+
preconditions: ["session_exists"]
|
|
274
|
+
})
|
|
275
|
+
]);
|
|
276
|
+
|
|
277
|
+
const STEP_IDS = Object.freeze(STEP_DEFINITIONS.map((step) => step.id));
|
|
278
|
+
const STEP_LABEL_BY_ID = Object.freeze(Object.fromEntries(STEP_DEFINITIONS.map((step) => [step.id, step.label])));
|
|
279
|
+
const STEP_DEFINITION_BY_ID = Object.freeze(Object.fromEntries(STEP_DEFINITIONS.map((step) => [step.id, step])));
|
|
280
|
+
const STEP_PRECONDITION_NAMES = Object.freeze(Object.fromEntries(
|
|
281
|
+
STEP_DEFINITIONS
|
|
282
|
+
.filter((step) => step.id !== "session_created")
|
|
283
|
+
.map((step) => [step.id, step.preconditions])
|
|
284
|
+
));
|
|
285
|
+
|
|
286
|
+
export {
|
|
287
|
+
PROMPT_DIRECTORY,
|
|
288
|
+
SESSION_ID_PATTERN,
|
|
289
|
+
SESSION_STATUS,
|
|
290
|
+
STEP_DEFINITION_BY_ID,
|
|
291
|
+
STEP_DEFINITIONS,
|
|
292
|
+
STEP_IDS,
|
|
293
|
+
STEP_LABEL_BY_ID,
|
|
294
|
+
STEP_PRECONDITION_NAMES,
|
|
295
|
+
SESSION_STATE_RELATIVE_PATH
|
|
296
|
+
};
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { access, mkdir, readFile, writeFile } from "node:fs/promises";
|
|
3
|
+
import { constants as fsConstants } from "node:fs";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { promisify } from "node:util";
|
|
6
|
+
|
|
7
|
+
const execFileAsync = promisify(execFile);
|
|
8
|
+
|
|
9
|
+
function normalizeText(value) {
|
|
10
|
+
return String(value || "").trim();
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function timestampForReceipt(now = new Date()) {
|
|
14
|
+
return now.toISOString();
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
async function fileExists(filePath) {
|
|
18
|
+
try {
|
|
19
|
+
await access(filePath, fsConstants.F_OK);
|
|
20
|
+
return true;
|
|
21
|
+
} catch {
|
|
22
|
+
return false;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async function readTextIfExists(filePath) {
|
|
27
|
+
if (!filePath || !(await fileExists(filePath))) {
|
|
28
|
+
return "";
|
|
29
|
+
}
|
|
30
|
+
return readFile(filePath, "utf8");
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async function readTrimmedFile(filePath) {
|
|
34
|
+
return normalizeText(await readTextIfExists(filePath));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async function writeTextFile(filePath, value) {
|
|
38
|
+
await mkdir(path.dirname(filePath), { recursive: true });
|
|
39
|
+
await writeFile(filePath, `${String(value || "").replace(/\s*$/u, "")}\n`, "utf8");
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async function runCommand(command, args = [], { cwd, env = {}, timeout = 30000 } = {}) {
|
|
43
|
+
try {
|
|
44
|
+
const result = await execFileAsync(command, args, {
|
|
45
|
+
cwd,
|
|
46
|
+
env: {
|
|
47
|
+
...process.env,
|
|
48
|
+
...env
|
|
49
|
+
},
|
|
50
|
+
maxBuffer: 1024 * 1024 * 10,
|
|
51
|
+
timeout
|
|
52
|
+
});
|
|
53
|
+
return {
|
|
54
|
+
exitCode: 0,
|
|
55
|
+
ok: true,
|
|
56
|
+
output: String([result.stdout, result.stderr].filter(Boolean).join("\n")).trim(),
|
|
57
|
+
stderr: String(result.stderr || "").trim(),
|
|
58
|
+
stdout: String(result.stdout || "").trim()
|
|
59
|
+
};
|
|
60
|
+
} catch (error) {
|
|
61
|
+
const stdout = String(error?.stdout || "").trim();
|
|
62
|
+
const stderr = String(error?.stderr || "").trim();
|
|
63
|
+
return {
|
|
64
|
+
exitCode: typeof error?.code === "number" ? error.code : 1,
|
|
65
|
+
ok: false,
|
|
66
|
+
output: String(error?.message || [stdout, stderr].filter(Boolean).join("\n")).trim(),
|
|
67
|
+
stderr,
|
|
68
|
+
stdout
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async function runGit(targetRoot, args = [], options = {}) {
|
|
74
|
+
return runCommand("git", args, {
|
|
75
|
+
cwd: targetRoot,
|
|
76
|
+
...options
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async function runGitInWorktree(worktree, args = [], options = {}) {
|
|
81
|
+
return runCommand("git", args, {
|
|
82
|
+
cwd: worktree,
|
|
83
|
+
...options
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export {
|
|
88
|
+
fileExists,
|
|
89
|
+
normalizeText,
|
|
90
|
+
readTextIfExists,
|
|
91
|
+
readTrimmedFile,
|
|
92
|
+
runCommand,
|
|
93
|
+
runGit,
|
|
94
|
+
runGitInWorktree,
|
|
95
|
+
timestampForReceipt,
|
|
96
|
+
writeTextFile
|
|
97
|
+
};
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import { mkdir, rename } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import {
|
|
4
|
+
SESSION_ID_PATTERN,
|
|
5
|
+
SESSION_STATE_RELATIVE_PATH
|
|
6
|
+
} from "./constants.js";
|
|
7
|
+
import {
|
|
8
|
+
fileExists,
|
|
9
|
+
normalizeText
|
|
10
|
+
} from "./io.js";
|
|
11
|
+
|
|
12
|
+
function formatDatePart(value) {
|
|
13
|
+
return String(value).padStart(2, "0");
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function createSessionId(now = new Date()) {
|
|
17
|
+
const year = now.getFullYear();
|
|
18
|
+
const month = formatDatePart(now.getMonth() + 1);
|
|
19
|
+
const day = formatDatePart(now.getDate());
|
|
20
|
+
const hour = formatDatePart(now.getHours());
|
|
21
|
+
const minute = formatDatePart(now.getMinutes());
|
|
22
|
+
const second = formatDatePart(now.getSeconds());
|
|
23
|
+
return `${year}-${month}-${day}_${hour}-${minute}-${second}`;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async function createAvailableSessionId(targetRoot, now = new Date()) {
|
|
27
|
+
const baseSessionId = createSessionId(now);
|
|
28
|
+
const basePaths = resolveSessionPaths({ targetRoot, sessionId: baseSessionId });
|
|
29
|
+
if (
|
|
30
|
+
!(await fileExists(basePaths.sessionRoot)) &&
|
|
31
|
+
!(await fileExists(basePaths.completedSessionRoot)) &&
|
|
32
|
+
!(await fileExists(basePaths.abandonedSessionRoot))
|
|
33
|
+
) {
|
|
34
|
+
return baseSessionId;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
for (let index = 1; index <= 36 ** 4 - 1; index += 1) {
|
|
38
|
+
const suffix = index.toString(36).padStart(4, "0");
|
|
39
|
+
const candidate = `${baseSessionId}-${suffix}`;
|
|
40
|
+
const candidatePaths = resolveSessionPaths({ targetRoot, sessionId: candidate });
|
|
41
|
+
if (
|
|
42
|
+
!(await fileExists(candidatePaths.sessionRoot)) &&
|
|
43
|
+
!(await fileExists(candidatePaths.completedSessionRoot)) &&
|
|
44
|
+
!(await fileExists(candidatePaths.abandonedSessionRoot))
|
|
45
|
+
) {
|
|
46
|
+
return candidate;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
throw new Error(`No available session id found for timestamp ${baseSessionId}.`);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function isValidSessionId(sessionId = "") {
|
|
54
|
+
return SESSION_ID_PATTERN.test(normalizeText(sessionId));
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function normalizeSessionId(sessionId = "") {
|
|
58
|
+
const normalized = normalizeText(sessionId);
|
|
59
|
+
if (!isValidSessionId(normalized)) {
|
|
60
|
+
throw new Error(`Invalid session id "${sessionId}". Expected YYYY-MM-DD_HH-MM-SS.`);
|
|
61
|
+
}
|
|
62
|
+
return normalized;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function resolveSessionPaths({ targetRoot, sessionId = "" } = {}) {
|
|
66
|
+
const normalizedTargetRoot = path.resolve(normalizeText(targetRoot) || process.cwd());
|
|
67
|
+
const sessionStateRoot = path.join(normalizedTargetRoot, SESSION_STATE_RELATIVE_PATH);
|
|
68
|
+
const sessionsRoot = path.join(sessionStateRoot, "active");
|
|
69
|
+
const completedSessionsRoot = path.join(sessionStateRoot, "completed");
|
|
70
|
+
const abandonedSessionsRoot = path.join(sessionStateRoot, "abandoned");
|
|
71
|
+
const worktreesRoot = path.join(sessionStateRoot, "worktrees");
|
|
72
|
+
const normalizedSessionId = sessionId ? normalizeSessionId(sessionId) : "";
|
|
73
|
+
const sessionRoot = normalizedSessionId ? path.join(sessionsRoot, normalizedSessionId) : "";
|
|
74
|
+
const worktree = normalizedSessionId ? path.join(worktreesRoot, normalizedSessionId) : "";
|
|
75
|
+
const branch = normalizedSessionId ? `jskit-studio/${normalizedSessionId}` : "";
|
|
76
|
+
|
|
77
|
+
return Object.freeze({
|
|
78
|
+
abandonedSessionRoot: normalizedSessionId ? path.join(abandonedSessionsRoot, normalizedSessionId) : "",
|
|
79
|
+
abandonedSessionsRoot,
|
|
80
|
+
branch,
|
|
81
|
+
completedSessionRoot: normalizedSessionId ? path.join(completedSessionsRoot, normalizedSessionId) : "",
|
|
82
|
+
completedSessionsRoot,
|
|
83
|
+
sessionId: normalizedSessionId,
|
|
84
|
+
sessionRoot,
|
|
85
|
+
sessionsRoot,
|
|
86
|
+
sessionStateRoot,
|
|
87
|
+
targetRoot: normalizedTargetRoot,
|
|
88
|
+
worktree,
|
|
89
|
+
worktreesRoot
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async function resolveExistingSessionRoot(paths) {
|
|
94
|
+
if (paths.archive && await fileExists(paths.sessionRoot)) {
|
|
95
|
+
return {
|
|
96
|
+
archive: paths.archive,
|
|
97
|
+
root: paths.sessionRoot
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
if (await fileExists(paths.sessionRoot)) {
|
|
101
|
+
return {
|
|
102
|
+
archive: "active",
|
|
103
|
+
root: paths.sessionRoot
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
if (await fileExists(paths.completedSessionRoot)) {
|
|
107
|
+
return {
|
|
108
|
+
archive: "completed",
|
|
109
|
+
root: paths.completedSessionRoot
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
if (await fileExists(paths.abandonedSessionRoot)) {
|
|
113
|
+
return {
|
|
114
|
+
archive: "abandoned",
|
|
115
|
+
root: paths.abandonedSessionRoot
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
return {
|
|
119
|
+
archive: "",
|
|
120
|
+
root: ""
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
async function pathsForExistingSession(paths) {
|
|
125
|
+
const existing = await resolveExistingSessionRoot(paths);
|
|
126
|
+
if (!existing.root || existing.root === paths.sessionRoot) {
|
|
127
|
+
return paths;
|
|
128
|
+
}
|
|
129
|
+
return Object.freeze({
|
|
130
|
+
...paths,
|
|
131
|
+
archive: existing.archive,
|
|
132
|
+
sessionRoot: existing.root
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
async function archiveSession(paths, archive) {
|
|
137
|
+
const archiveRoot = archive === "completed" ? paths.completedSessionRoot : paths.abandonedSessionRoot;
|
|
138
|
+
if (!archiveRoot || paths.sessionRoot === archiveRoot) {
|
|
139
|
+
return paths;
|
|
140
|
+
}
|
|
141
|
+
if (!(await fileExists(paths.sessionRoot))) {
|
|
142
|
+
return pathsForExistingSession(paths);
|
|
143
|
+
}
|
|
144
|
+
if (await fileExists(archiveRoot)) {
|
|
145
|
+
throw new Error(`Cannot archive session ${paths.sessionId}; target already exists: ${archiveRoot}`);
|
|
146
|
+
}
|
|
147
|
+
await mkdir(path.dirname(archiveRoot), { recursive: true });
|
|
148
|
+
await rename(paths.sessionRoot, archiveRoot);
|
|
149
|
+
return Object.freeze({
|
|
150
|
+
...paths,
|
|
151
|
+
archive,
|
|
152
|
+
sessionRoot: archiveRoot
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export {
|
|
157
|
+
archiveSession,
|
|
158
|
+
createAvailableSessionId,
|
|
159
|
+
createSessionId,
|
|
160
|
+
isValidSessionId,
|
|
161
|
+
normalizeSessionId,
|
|
162
|
+
resolveExistingSessionRoot,
|
|
163
|
+
resolveSessionPaths,
|
|
164
|
+
pathsForExistingSession
|
|
165
|
+
};
|