jorgex-stack 1.0.3 → 1.0.5
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/PRD.md +2 -2
- package/README.md +33 -3
- package/dist/cli.js +13 -6
- package/package.json +2 -2
- package/stack/commands/opencode/goal.md +7 -0
- package/stack/plugins/opencode/goal/artifacts.ts +142 -0
- package/stack/plugins/opencode/goal/command.ts +255 -0
- package/stack/plugins/opencode/goal/db.ts +68 -0
- package/stack/plugins/opencode/goal/opencode-hooks.ts +290 -0
- package/stack/plugins/opencode/goal/state.ts +85 -0
- package/stack/plugins/opencode/goal/store.ts +906 -0
- package/stack/plugins/opencode/goal/supervisor.ts +269 -0
- package/stack/plugins/opencode/goal/types.ts +187 -0
- package/stack/plugins/opencode/goal-plugin.ts +176 -0
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import type { GoalStore } from "./types.js";
|
|
3
|
+
import { createGoalCommandHandlers } from "./command.js";
|
|
4
|
+
import {
|
|
5
|
+
GOAL_MODE_MARKER_END,
|
|
6
|
+
GOAL_MODE_MARKER_START,
|
|
7
|
+
createGoalSupervisor,
|
|
8
|
+
} from "./supervisor.js";
|
|
9
|
+
|
|
10
|
+
type HookOutput = Record<string, unknown>;
|
|
11
|
+
|
|
12
|
+
interface GoalSessionClient {
|
|
13
|
+
promptAsync?: (input: { prompt: string; sessionID?: string }) => Promise<unknown> | unknown;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
interface GoalLogger {
|
|
17
|
+
warn?: (message: string, details?: unknown) => void;
|
|
18
|
+
error?: (message: string, details?: unknown) => void;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface OpenCodeGoalHooksDeps {
|
|
22
|
+
store: GoalStore;
|
|
23
|
+
project: string;
|
|
24
|
+
artifactsRootDir?: string;
|
|
25
|
+
sessionClient?: GoalSessionClient;
|
|
26
|
+
logger?: GoalLogger;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface OpenCodeGoalHooks {
|
|
30
|
+
event?: (input: { event: { type: string; properties?: unknown } }) => Promise<void>;
|
|
31
|
+
"command.execute.before"?: (input: unknown, output: HookOutput) => Promise<void>;
|
|
32
|
+
"experimental.chat.system.transform"?: (input: unknown, output: { system: string[] }) => Promise<void>;
|
|
33
|
+
"experimental.session.compacting"?: (input: { sessionID?: string }, output: { context: string[] }) => Promise<void>;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function createOpenCodeGoalHooks(deps: OpenCodeGoalHooksDeps): OpenCodeGoalHooks {
|
|
37
|
+
const commands = createGoalCommandHandlers({
|
|
38
|
+
store: deps.store,
|
|
39
|
+
project: deps.project,
|
|
40
|
+
artifactsRootDir: deps.artifactsRootDir,
|
|
41
|
+
});
|
|
42
|
+
const supervisor = createGoalSupervisor({
|
|
43
|
+
store: deps.store,
|
|
44
|
+
project: deps.project,
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
return {
|
|
48
|
+
"command.execute.before": async (input, output) => {
|
|
49
|
+
const command = extractCommandName(input);
|
|
50
|
+
if (command !== "goal") return;
|
|
51
|
+
|
|
52
|
+
const response = commands.handleGoalCommand(extractCommandArguments(input));
|
|
53
|
+
replaceGoalCommandPrompt(input, output, response.message);
|
|
54
|
+
},
|
|
55
|
+
|
|
56
|
+
"experimental.chat.system.transform": async (_input, output) => {
|
|
57
|
+
const block = supervisor.renderSystemContext();
|
|
58
|
+
if (!block) return;
|
|
59
|
+
|
|
60
|
+
if (output.system.length === 0) {
|
|
61
|
+
output.system.push(block);
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const lastIndex = output.system.length - 1;
|
|
66
|
+
output.system[lastIndex] = upsertMarkedBlock(output.system[lastIndex]!, block);
|
|
67
|
+
},
|
|
68
|
+
|
|
69
|
+
"experimental.session.compacting": async (_input, output) => {
|
|
70
|
+
const block = supervisor.renderSystemContext();
|
|
71
|
+
if (!block) return;
|
|
72
|
+
|
|
73
|
+
if (!output.context.some((entry) => entry.includes(GOAL_MODE_MARKER_START))) {
|
|
74
|
+
output.context.push(block);
|
|
75
|
+
}
|
|
76
|
+
},
|
|
77
|
+
|
|
78
|
+
event: async ({ event }) => {
|
|
79
|
+
if (event.type !== "session.idle") return;
|
|
80
|
+
|
|
81
|
+
const decision = supervisor.decide();
|
|
82
|
+
if (!decision || decision.type === "pause_for_merge") return;
|
|
83
|
+
if (decision.state.goal.status !== "active") return;
|
|
84
|
+
const sessionID = extractSessionID(event.properties);
|
|
85
|
+
const stateSequence = latestNonAutoContinueSequence(decision.state.events);
|
|
86
|
+
if (hasAutoContinueEventForState(decision.state.events, "goal.auto_continue_requested", stateSequence)) {
|
|
87
|
+
if (!hasAutoContinueEventForState(decision.state.events, "goal.auto_continue_deduped", stateSequence)) {
|
|
88
|
+
deps.store.appendEvent(decision.state.goal.id, {
|
|
89
|
+
type: "goal.auto_continue_deduped",
|
|
90
|
+
message: "Auto-continue skipped because this goal state already requested a continuation.",
|
|
91
|
+
data: { sessionID, stateSequence },
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
if (!deps.sessionClient?.promptAsync) {
|
|
97
|
+
if (!hasAutoContinueEventForState(decision.state.events, "goal.auto_continue_unavailable", stateSequence)) {
|
|
98
|
+
deps.store.appendEvent(decision.state.goal.id, {
|
|
99
|
+
type: "goal.auto_continue_unavailable",
|
|
100
|
+
message: "Auto-continue unavailable: session prompt client missing.",
|
|
101
|
+
data: { sessionID, stateSequence },
|
|
102
|
+
});
|
|
103
|
+
deps.logger?.warn?.("Goal Mode auto-continue unavailable", {
|
|
104
|
+
goalId: decision.state.goal.id,
|
|
105
|
+
sessionID,
|
|
106
|
+
stateSequence,
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
const prompt = supervisor.renderContinuationPrompt(decision.state.goal.id);
|
|
112
|
+
if (!prompt?.trim()) {
|
|
113
|
+
deps.store.appendEvent(decision.state.goal.id, {
|
|
114
|
+
type: "goal.auto_continue_skipped",
|
|
115
|
+
message: "Auto-continue skipped because the continuation prompt was empty.",
|
|
116
|
+
});
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
const dedupeKey = `${decision.state.goal.id}:${sessionID ?? "unknown"}:${stateSequence}`;
|
|
120
|
+
if (autoContinueInFlight.has(dedupeKey)) {
|
|
121
|
+
deps.store.appendEvent(decision.state.goal.id, {
|
|
122
|
+
type: "goal.auto_continue_deduped",
|
|
123
|
+
message: "Auto-continue skipped because a continuation is already in flight.",
|
|
124
|
+
data: { sessionID, stateSequence },
|
|
125
|
+
});
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
autoContinueInFlight.add(dedupeKey);
|
|
129
|
+
|
|
130
|
+
try {
|
|
131
|
+
deps.store.appendEvent(decision.state.goal.id, {
|
|
132
|
+
type: "goal.auto_continue_requested",
|
|
133
|
+
message: `Auto-continue requested for session ${sessionID ?? "unknown"}.`,
|
|
134
|
+
data: { sessionID, stateSequence },
|
|
135
|
+
});
|
|
136
|
+
await deps.sessionClient.promptAsync({
|
|
137
|
+
sessionID,
|
|
138
|
+
prompt,
|
|
139
|
+
});
|
|
140
|
+
} catch (error) {
|
|
141
|
+
deps.store.appendEvent(decision.state.goal.id, {
|
|
142
|
+
type: "goal.auto_continue_failed",
|
|
143
|
+
message: `Auto-continue failed for session ${sessionID ?? "unknown"}.`,
|
|
144
|
+
data: { error: error instanceof Error ? error.message : String(error), sessionID, stateSequence },
|
|
145
|
+
});
|
|
146
|
+
deps.logger?.error?.("Goal Mode auto-continue failed", error);
|
|
147
|
+
} finally {
|
|
148
|
+
autoContinueInFlight.delete(dedupeKey);
|
|
149
|
+
}
|
|
150
|
+
},
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const autoContinueInFlight = new Set<string>();
|
|
155
|
+
|
|
156
|
+
function extractCommandName(input: unknown): string {
|
|
157
|
+
if (!isRecord(input)) return "";
|
|
158
|
+
const command = input.command ?? input.name;
|
|
159
|
+
return typeof command === "string" ? command.trim().toLowerCase() : "";
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function extractCommandArguments(input: unknown): string {
|
|
163
|
+
if (!isRecord(input)) return "";
|
|
164
|
+
const direct = input.arguments ?? input.argument ?? input.input;
|
|
165
|
+
if (typeof direct === "string") return direct;
|
|
166
|
+
const args = input.args;
|
|
167
|
+
if (!isRecord(args)) return "";
|
|
168
|
+
const nested = args.arguments ?? args.argument ?? args.input;
|
|
169
|
+
return typeof nested === "string" ? nested : "";
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function replaceGoalCommandPrompt(input: unknown, output: HookOutput, text: string): void {
|
|
173
|
+
const prompt = renderGoalCommandPrompt(input, text);
|
|
174
|
+
|
|
175
|
+
if (Array.isArray(output.parts)) {
|
|
176
|
+
output.parts.splice(0, output.parts.length, {
|
|
177
|
+
id: `part_${randomUUID()}`,
|
|
178
|
+
sessionID: extractHookSessionID(input, output),
|
|
179
|
+
messageID: extractHookMessageID(input, output),
|
|
180
|
+
type: "text",
|
|
181
|
+
text: prompt,
|
|
182
|
+
synthetic: true,
|
|
183
|
+
});
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
if (typeof output.message === "string") {
|
|
187
|
+
output.message = output.message ? `${output.message}\n\n${prompt}` : prompt;
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
if (typeof output.output === "string") {
|
|
191
|
+
output.output = output.output ? `${output.output}\n\n${prompt}` : prompt;
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
if (Array.isArray(output.content)) {
|
|
195
|
+
output.content.push({ type: "text", text: prompt });
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
throw new Error("Unsupported OpenCode command output contract for Goal Mode.");
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function renderGoalCommandPrompt(input: unknown, commandResult: string): string {
|
|
202
|
+
const args = extractCommandArguments(input).trim();
|
|
203
|
+
const firstToken = args.split(/\s+/, 1)[0]?.toLowerCase() ?? "";
|
|
204
|
+
const isControlCommand = new Set(["status", "plan", "history", "pause", "resume", "cancel", "merged"]).has(firstToken);
|
|
205
|
+
|
|
206
|
+
return [
|
|
207
|
+
"Goal Mode command result (authoritative):",
|
|
208
|
+
commandResult,
|
|
209
|
+
"",
|
|
210
|
+
"Instructions:",
|
|
211
|
+
isControlCommand
|
|
212
|
+
? "Reply with the Goal Mode command result only. Do not inspect files, run tools, continue implementation, create branches, open PRs, or change repository state."
|
|
213
|
+
: "A persistent Goal Mode objective has been created. Acknowledge the created goal, then continue only according to the injected Goal Mode context and the project work-lifecycle rules.",
|
|
214
|
+
].join("\n");
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function extractHookSessionID(input: unknown, output: HookOutput): string {
|
|
218
|
+
const inputRecord = isRecord(input) ? input : undefined;
|
|
219
|
+
const outputRecord = output;
|
|
220
|
+
const direct = inputRecord?.sessionID ?? inputRecord?.sessionId ?? outputRecord.sessionID ?? outputRecord.sessionId;
|
|
221
|
+
if (typeof direct === "string" && direct.trim()) return direct;
|
|
222
|
+
|
|
223
|
+
const message = outputRecord.message ?? outputRecord.info;
|
|
224
|
+
if (isRecord(message)) {
|
|
225
|
+
const sessionID = message.sessionID ?? message.sessionId;
|
|
226
|
+
if (typeof sessionID === "string" && sessionID.trim()) return sessionID;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
return `session_${randomUUID()}`;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function extractHookMessageID(input: unknown, output: HookOutput): string {
|
|
233
|
+
const inputRecord = isRecord(input) ? input : undefined;
|
|
234
|
+
const outputRecord = output;
|
|
235
|
+
const direct = inputRecord?.messageID ?? inputRecord?.messageId ?? outputRecord.messageID ?? outputRecord.messageId;
|
|
236
|
+
if (typeof direct === "string" && direct.trim()) return direct;
|
|
237
|
+
|
|
238
|
+
const message = outputRecord.message ?? outputRecord.info;
|
|
239
|
+
if (isRecord(message)) {
|
|
240
|
+
const id = message.id ?? message.messageID ?? message.messageId;
|
|
241
|
+
if (typeof id === "string" && id.trim()) return id;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
return `msg_${randomUUID()}`;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function upsertMarkedBlock(text: string, block: string): string {
|
|
248
|
+
const start = text.indexOf(GOAL_MODE_MARKER_START);
|
|
249
|
+
const end = text.indexOf(GOAL_MODE_MARKER_END);
|
|
250
|
+
if (start !== -1 && end !== -1 && end > start) {
|
|
251
|
+
return `${text.slice(0, start).trimEnd()}\n\n${block}${text.slice(end + GOAL_MODE_MARKER_END.length)}`;
|
|
252
|
+
}
|
|
253
|
+
return `${text.trimEnd()}\n\n${block}`;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function extractSessionID(properties: unknown): string | undefined {
|
|
257
|
+
if (!isRecord(properties)) return undefined;
|
|
258
|
+
if (typeof properties.sessionID === "string") return properties.sessionID;
|
|
259
|
+
const info = properties.info;
|
|
260
|
+
if (isRecord(info) && typeof info.id === "string") return info.id;
|
|
261
|
+
return undefined;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const AUTO_CONTINUE_EVENT_PREFIX = "goal.auto_continue_";
|
|
265
|
+
|
|
266
|
+
function latestNonAutoContinueSequence(events: Array<{ type: string; sequence: number }>): number {
|
|
267
|
+
return Math.max(
|
|
268
|
+
0,
|
|
269
|
+
...events
|
|
270
|
+
.filter((event) => !event.type.startsWith(AUTO_CONTINUE_EVENT_PREFIX))
|
|
271
|
+
.map((event) => event.sequence),
|
|
272
|
+
);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function hasAutoContinueEventForState(
|
|
276
|
+
events: Array<{ type: string; data?: unknown }>,
|
|
277
|
+
type: string,
|
|
278
|
+
stateSequence: number,
|
|
279
|
+
): boolean {
|
|
280
|
+
return events.some((event) => event.type === type && readEventStateSequence(event.data) === stateSequence);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function readEventStateSequence(data: unknown): number | undefined {
|
|
284
|
+
if (!isRecord(data)) return undefined;
|
|
285
|
+
return typeof data.stateSequence === "number" ? data.stateSequence : undefined;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
289
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
290
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import {
|
|
2
|
+
GOAL_STORE_SCHEMA_VERSION,
|
|
3
|
+
type GoalStatus,
|
|
4
|
+
type GoalStoreSnapshot,
|
|
5
|
+
} from "./types.js";
|
|
6
|
+
|
|
7
|
+
export const GOAL_STATUSES: readonly GoalStatus[] = [
|
|
8
|
+
"active",
|
|
9
|
+
"paused",
|
|
10
|
+
"blocked",
|
|
11
|
+
"waiting_for_merge",
|
|
12
|
+
"budget_limited",
|
|
13
|
+
"failed",
|
|
14
|
+
"complete",
|
|
15
|
+
"cancelled",
|
|
16
|
+
];
|
|
17
|
+
|
|
18
|
+
export const TERMINAL_GOAL_STATUSES = new Set<GoalStatus>([
|
|
19
|
+
"failed",
|
|
20
|
+
"complete",
|
|
21
|
+
"cancelled",
|
|
22
|
+
]);
|
|
23
|
+
|
|
24
|
+
const ALLOWED_TRANSITIONS: Record<GoalStatus, readonly GoalStatus[]> = {
|
|
25
|
+
active: [
|
|
26
|
+
"paused",
|
|
27
|
+
"blocked",
|
|
28
|
+
"waiting_for_merge",
|
|
29
|
+
"budget_limited",
|
|
30
|
+
"failed",
|
|
31
|
+
"complete",
|
|
32
|
+
"cancelled",
|
|
33
|
+
],
|
|
34
|
+
paused: [
|
|
35
|
+
"active",
|
|
36
|
+
"blocked",
|
|
37
|
+
"waiting_for_merge",
|
|
38
|
+
"budget_limited",
|
|
39
|
+
"failed",
|
|
40
|
+
"cancelled",
|
|
41
|
+
],
|
|
42
|
+
blocked: [
|
|
43
|
+
"active",
|
|
44
|
+
"paused",
|
|
45
|
+
"waiting_for_merge",
|
|
46
|
+
"budget_limited",
|
|
47
|
+
"failed",
|
|
48
|
+
"complete",
|
|
49
|
+
"cancelled",
|
|
50
|
+
],
|
|
51
|
+
waiting_for_merge: ["active", "blocked", "budget_limited", "failed", "cancelled"],
|
|
52
|
+
budget_limited: ["active", "blocked", "waiting_for_merge", "failed", "cancelled"],
|
|
53
|
+
failed: [],
|
|
54
|
+
complete: [],
|
|
55
|
+
cancelled: [],
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
export function createEmptyGoalStoreSnapshot(): GoalStoreSnapshot {
|
|
59
|
+
return {
|
|
60
|
+
schemaVersion: GOAL_STORE_SCHEMA_VERSION,
|
|
61
|
+
nextEventSequence: 1,
|
|
62
|
+
goals: [],
|
|
63
|
+
events: [],
|
|
64
|
+
phases: [],
|
|
65
|
+
worktrees: [],
|
|
66
|
+
pullRequests: [],
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function isTerminalGoalStatus(status: GoalStatus): boolean {
|
|
71
|
+
return TERMINAL_GOAL_STATUSES.has(status);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function assertGoalTransition(from: GoalStatus, to: GoalStatus): void {
|
|
75
|
+
if (from === to) return;
|
|
76
|
+
|
|
77
|
+
if (isTerminalGoalStatus(from)) {
|
|
78
|
+
throw new Error(`Cannot transition terminal goal from ${from} to ${to}.`);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const allowed = ALLOWED_TRANSITIONS[from];
|
|
82
|
+
if (!allowed.includes(to)) {
|
|
83
|
+
throw new Error(`Invalid transition from ${from} to ${to}.`);
|
|
84
|
+
}
|
|
85
|
+
}
|