@jskit-ai/jskit-cli 0.2.78 → 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 +5 -5
- package/src/server/appBlueprint.js +126 -0
- package/src/server/cliRuntime/mutationWhen.js +16 -0
- package/src/server/cliRuntime/packageInstallFlow.js +43 -5
- package/src/server/commandHandlers/blueprint.js +151 -0
- package/src/server/commandHandlers/session.js +237 -0
- package/src/server/commandHandlers/show/renderPackageText.js +11 -2
- 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
|
@@ -143,7 +143,7 @@ function parseArgs(argv, { createCliError } = {}) {
|
|
|
143
143
|
} else {
|
|
144
144
|
const hasNextStringToken = typeof args[0] === "string";
|
|
145
145
|
const nextToken = hasNextStringToken ? String(args[0]) : "";
|
|
146
|
-
if (hasNextStringToken && !nextToken.startsWith("-")) {
|
|
146
|
+
if (hasNextStringToken && (!nextToken.startsWith("-") || nextToken === "-")) {
|
|
147
147
|
optionValueRaw = args.shift();
|
|
148
148
|
}
|
|
149
149
|
}
|
|
@@ -153,7 +153,7 @@ function parseArgs(argv, { createCliError } = {}) {
|
|
|
153
153
|
}
|
|
154
154
|
if (typeof optionValueRaw === "string") {
|
|
155
155
|
const optionValue = optionValueRaw.trim();
|
|
156
|
-
if (!hasInlineValue && optionValue.startsWith("-")) {
|
|
156
|
+
if (!hasInlineValue && optionValue.startsWith("-") && optionValue !== "-") {
|
|
157
157
|
throw createCliError(`--${optionName} requires a value.`, { showUsage: true });
|
|
158
158
|
}
|
|
159
159
|
options.inlineOptions[optionName] = optionValue;
|
|
@@ -198,6 +198,89 @@ const COMMAND_DESCRIPTORS = Object.freeze({
|
|
|
198
198
|
allowedValueOptionNames: Object.freeze([]),
|
|
199
199
|
canDelegateInlineOptions: (positional = []) => Array.isArray(positional) && positional.length > 0
|
|
200
200
|
}),
|
|
201
|
+
session: Object.freeze({
|
|
202
|
+
command: "session",
|
|
203
|
+
aliases: Object.freeze([]),
|
|
204
|
+
showInOverview: true,
|
|
205
|
+
summary: "Run file-backed JSKIT issue sessions.",
|
|
206
|
+
minimalUse: "jskit session create",
|
|
207
|
+
parameters: Object.freeze([
|
|
208
|
+
Object.freeze({
|
|
209
|
+
name: "create | <sessionId>",
|
|
210
|
+
description: "Create a session, inspect a session, or run a session subcommand."
|
|
211
|
+
}),
|
|
212
|
+
Object.freeze({
|
|
213
|
+
name: "[step|abandon|adopt-codex-thread]",
|
|
214
|
+
description: "Run the next step, abandon a session, or attach a Codex thread id."
|
|
215
|
+
})
|
|
216
|
+
]),
|
|
217
|
+
defaults: Object.freeze([
|
|
218
|
+
"Active session state lives in .jskit/sessions/active/<session_id> under the current target app.",
|
|
219
|
+
"Session worktrees live in .jskit/sessions/worktrees/<session_id>.",
|
|
220
|
+
"The session id is timestamp-based and is the primary key.",
|
|
221
|
+
"Use --json for the stable machine-readable contract consumed by JSKIT AI Studio.",
|
|
222
|
+
"Use --issue - to read approved issue text from stdin."
|
|
223
|
+
]),
|
|
224
|
+
examples: Object.freeze([
|
|
225
|
+
Object.freeze({
|
|
226
|
+
label: "Manual CLI flow",
|
|
227
|
+
lines: Object.freeze([
|
|
228
|
+
"jskit session create",
|
|
229
|
+
"jskit session 2026-05-11_21-42-08 step",
|
|
230
|
+
"jskit session 2026-05-11_21-42-08 step --prompt \"Fix the customer filters\"",
|
|
231
|
+
"jskit session 2026-05-11_21-42-08 step --issue -"
|
|
232
|
+
])
|
|
233
|
+
})
|
|
234
|
+
]),
|
|
235
|
+
fullUse:
|
|
236
|
+
"jskit session [create|<sessionId>] [step|abandon|adopt-codex-thread] [--prompt <text>] [--issue <text>|--issue-file <path>] [--user-check <passed|failed>] [--codex-thread-id <id>] [--json]",
|
|
237
|
+
showHelpOnBareInvocation: false,
|
|
238
|
+
handlerName: "commandSession",
|
|
239
|
+
allowedFlagKeys: Object.freeze(["json"]),
|
|
240
|
+
inlineOptionMode: "delegate",
|
|
241
|
+
allowedValueOptionNames: Object.freeze([]),
|
|
242
|
+
canDelegateInlineOptions: (positional = []) => Array.isArray(positional) && positional.length > 0
|
|
243
|
+
}),
|
|
244
|
+
blueprint: Object.freeze({
|
|
245
|
+
command: "blueprint",
|
|
246
|
+
aliases: Object.freeze([]),
|
|
247
|
+
showInOverview: true,
|
|
248
|
+
summary: "Read, prompt, or set the app-level JSKIT blueprint.",
|
|
249
|
+
minimalUse: "jskit blueprint",
|
|
250
|
+
parameters: Object.freeze([
|
|
251
|
+
Object.freeze({
|
|
252
|
+
name: "[prompt|set]",
|
|
253
|
+
description: "Without a subcommand, prints the saved app blueprint. prompt renders the AI prompt; set writes the approved blueprint."
|
|
254
|
+
})
|
|
255
|
+
]),
|
|
256
|
+
defaults: Object.freeze([
|
|
257
|
+
"The app blueprint is product-level app state, not a feature-session step.",
|
|
258
|
+
"The saved blueprint lives at .jskit/APP_BLUEPRINT.md in the current target app.",
|
|
259
|
+
"Project prompt overrides live at .jskit/prompts/app_blueprint.md.",
|
|
260
|
+
"Use --json for a stable machine-readable response."
|
|
261
|
+
]),
|
|
262
|
+
examples: Object.freeze([
|
|
263
|
+
Object.freeze({
|
|
264
|
+
label: "Manual blueprint flow",
|
|
265
|
+
lines: Object.freeze([
|
|
266
|
+
"jskit blueprint prompt --brief \"A field app for customer visits\"",
|
|
267
|
+
"jskit blueprint set --blueprint -",
|
|
268
|
+
"jskit blueprint --json"
|
|
269
|
+
])
|
|
270
|
+
})
|
|
271
|
+
]),
|
|
272
|
+
fullUse:
|
|
273
|
+
"jskit blueprint [prompt|set] [--brief <text>|--brief-file <path>] [--blueprint <text>|--blueprint-file <path>] [--json]",
|
|
274
|
+
showHelpOnBareInvocation: false,
|
|
275
|
+
handlerName: "commandBlueprint",
|
|
276
|
+
allowedFlagKeys: Object.freeze(["json"]),
|
|
277
|
+
inlineOptionMode: "delegate",
|
|
278
|
+
allowedValueOptionNames: Object.freeze([]),
|
|
279
|
+
canDelegateInlineOptions: (positional = []) => {
|
|
280
|
+
const subcommand = String(Array.isArray(positional) ? positional[0] || "" : "").trim();
|
|
281
|
+
return subcommand === "prompt" || subcommand === "set";
|
|
282
|
+
}
|
|
283
|
+
}),
|
|
201
284
|
add: Object.freeze({
|
|
202
285
|
command: "add",
|
|
203
286
|
aliases: Object.freeze([]),
|
|
@@ -6,6 +6,8 @@ import { createAppCommands } from "../commandHandlers/app.js";
|
|
|
6
6
|
import { createMobileCommands } from "../commandHandlers/mobile.js";
|
|
7
7
|
import { createHealthCommands } from "../commandHandlers/health.js";
|
|
8
8
|
import { createCompletionCommands } from "../commandHandlers/completion.js";
|
|
9
|
+
import { createSessionCommands } from "../commandHandlers/session.js";
|
|
10
|
+
import { createBlueprintCommands } from "../commandHandlers/blueprint.js";
|
|
9
11
|
|
|
10
12
|
function createCommandHandlers(deps = {}) {
|
|
11
13
|
const shared = createCommandHandlerShared(deps);
|
|
@@ -29,6 +31,8 @@ function createCommandHandlers(deps = {}) {
|
|
|
29
31
|
const { commandMobile } = createMobileCommands(commandContext, { commandAdd });
|
|
30
32
|
const { commandDoctor, commandLintDescriptors } = createHealthCommands(commandContext);
|
|
31
33
|
const { commandCompletion } = createCompletionCommands(commandContext);
|
|
34
|
+
const { commandSession } = createSessionCommands(commandContext);
|
|
35
|
+
const { commandBlueprint } = createBlueprintCommands(commandContext);
|
|
32
36
|
|
|
33
37
|
return {
|
|
34
38
|
commandList,
|
|
@@ -46,7 +50,9 @@ function createCommandHandlers(deps = {}) {
|
|
|
46
50
|
commandUpdate,
|
|
47
51
|
commandRemove,
|
|
48
52
|
commandDoctor,
|
|
49
|
-
commandLintDescriptors
|
|
53
|
+
commandLintDescriptors,
|
|
54
|
+
commandSession,
|
|
55
|
+
commandBlueprint
|
|
50
56
|
};
|
|
51
57
|
}
|
|
52
58
|
|
package/src/server/index.js
CHANGED
|
@@ -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
|
+
};
|