jorgex-stack 1.9.15 → 1.9.16
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/README.md +5 -27
- package/package.json +1 -1
- package/stack/skills/work-lifecycle/references/pr-continuation.md +1 -1
- package/stack/commands/opencode/goal.md +0 -7
- package/stack/plugins/opencode/goal/artifacts.ts +0 -142
- package/stack/plugins/opencode/goal/command.ts +0 -255
- package/stack/plugins/opencode/goal/db.ts +0 -68
- package/stack/plugins/opencode/goal/opencode-hooks.ts +0 -311
- package/stack/plugins/opencode/goal/state.ts +0 -85
- package/stack/plugins/opencode/goal/store.ts +0 -906
- package/stack/plugins/opencode/goal/supervisor.ts +0 -269
- package/stack/plugins/opencode/goal/types.ts +0 -187
- package/stack/plugins/opencode/goal-plugin.ts +0 -184
|
@@ -1,311 +0,0 @@
|
|
|
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
|
-
// ─── Safe Hook Wrapper ─────────────────────────────────────────
|
|
48
|
-
// Wraps every hook to catch errors and log them via the logger instead
|
|
49
|
-
// of letting them propagate. This prevents OpenCode from crashing when
|
|
50
|
-
// Goal Mode hooks encounter malformed payloads or internal errors.
|
|
51
|
-
// Errors are logged (if a logger is provided) and swallowed (promise
|
|
52
|
-
// resolves cleanly even on failure).
|
|
53
|
-
const safe = <A extends unknown[]>(name: string, fn: (...args: A) => Promise<void>) =>
|
|
54
|
-
async (...args: A): Promise<void> => {
|
|
55
|
-
try {
|
|
56
|
-
await fn(...args);
|
|
57
|
-
} catch (error) {
|
|
58
|
-
try { deps.logger?.error?.(`Goal Mode hook ${name} failed`, error); } catch {}
|
|
59
|
-
}
|
|
60
|
-
};
|
|
61
|
-
|
|
62
|
-
return {
|
|
63
|
-
"command.execute.before": safe("command.execute.before", async (input, output) => {
|
|
64
|
-
const command = extractCommandName(input);
|
|
65
|
-
if (command !== "goal") return;
|
|
66
|
-
|
|
67
|
-
const response = commands.handleGoalCommand(extractCommandArguments(input));
|
|
68
|
-
replaceGoalCommandPrompt(input, output, response.message);
|
|
69
|
-
}),
|
|
70
|
-
|
|
71
|
-
"experimental.chat.system.transform": safe("experimental.chat.system.transform", async (_input, output) => {
|
|
72
|
-
const block = supervisor.renderSystemContext();
|
|
73
|
-
if (!block) return;
|
|
74
|
-
|
|
75
|
-
if (output.system.length === 0) {
|
|
76
|
-
output.system.push(block);
|
|
77
|
-
return;
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
const lastIndex = output.system.length - 1;
|
|
81
|
-
output.system[lastIndex] = upsertMarkedBlock(output.system[lastIndex]!, block);
|
|
82
|
-
}),
|
|
83
|
-
|
|
84
|
-
"experimental.session.compacting": safe("experimental.session.compacting", async (_input, output) => {
|
|
85
|
-
const block = supervisor.renderSystemContext();
|
|
86
|
-
if (!block) return;
|
|
87
|
-
|
|
88
|
-
if (!output.context.some((entry) => entry.includes(GOAL_MODE_MARKER_START))) {
|
|
89
|
-
output.context.push(block);
|
|
90
|
-
}
|
|
91
|
-
}),
|
|
92
|
-
|
|
93
|
-
event: safe("event", async ({ event }) => {
|
|
94
|
-
if (event.type !== "session.idle") return;
|
|
95
|
-
|
|
96
|
-
const decision = supervisor.decide();
|
|
97
|
-
if (!decision || decision.type === "pause_for_merge") return;
|
|
98
|
-
if (decision.state.goal.status !== "active") return;
|
|
99
|
-
const sessionID = extractSessionID(event.properties);
|
|
100
|
-
const stateSequence = latestNonAutoContinueSequence(decision.state.events);
|
|
101
|
-
if (hasAutoContinueEventForState(decision.state.events, "goal.auto_continue_requested", stateSequence)) {
|
|
102
|
-
if (!hasAutoContinueEventForState(decision.state.events, "goal.auto_continue_deduped", stateSequence)) {
|
|
103
|
-
deps.store.appendEvent(decision.state.goal.id, {
|
|
104
|
-
type: "goal.auto_continue_deduped",
|
|
105
|
-
message: "Auto-continue skipped because this goal state already requested a continuation.",
|
|
106
|
-
data: { sessionID, stateSequence },
|
|
107
|
-
});
|
|
108
|
-
}
|
|
109
|
-
return;
|
|
110
|
-
}
|
|
111
|
-
if (!deps.sessionClient?.promptAsync) {
|
|
112
|
-
if (!hasAutoContinueEventForState(decision.state.events, "goal.auto_continue_unavailable", stateSequence)) {
|
|
113
|
-
deps.store.appendEvent(decision.state.goal.id, {
|
|
114
|
-
type: "goal.auto_continue_unavailable",
|
|
115
|
-
message: "Auto-continue unavailable: session prompt client missing.",
|
|
116
|
-
data: { sessionID, stateSequence },
|
|
117
|
-
});
|
|
118
|
-
deps.logger?.warn?.("Goal Mode auto-continue unavailable", {
|
|
119
|
-
goalId: decision.state.goal.id,
|
|
120
|
-
sessionID,
|
|
121
|
-
stateSequence,
|
|
122
|
-
});
|
|
123
|
-
}
|
|
124
|
-
return;
|
|
125
|
-
}
|
|
126
|
-
const prompt = supervisor.renderContinuationPrompt(decision.state.goal.id);
|
|
127
|
-
if (!prompt?.trim()) {
|
|
128
|
-
deps.store.appendEvent(decision.state.goal.id, {
|
|
129
|
-
type: "goal.auto_continue_skipped",
|
|
130
|
-
message: "Auto-continue skipped because the continuation prompt was empty.",
|
|
131
|
-
});
|
|
132
|
-
return;
|
|
133
|
-
}
|
|
134
|
-
const dedupeKey = `${decision.state.goal.id}:${sessionID ?? "unknown"}:${stateSequence}`;
|
|
135
|
-
if (autoContinueInFlight.has(dedupeKey)) {
|
|
136
|
-
deps.store.appendEvent(decision.state.goal.id, {
|
|
137
|
-
type: "goal.auto_continue_deduped",
|
|
138
|
-
message: "Auto-continue skipped because a continuation is already in flight.",
|
|
139
|
-
data: { sessionID, stateSequence },
|
|
140
|
-
});
|
|
141
|
-
return;
|
|
142
|
-
}
|
|
143
|
-
autoContinueInFlight.add(dedupeKey);
|
|
144
|
-
|
|
145
|
-
try {
|
|
146
|
-
deps.store.appendEvent(decision.state.goal.id, {
|
|
147
|
-
type: "goal.auto_continue_requested",
|
|
148
|
-
message: `Auto-continue requested for session ${sessionID ?? "unknown"}.`,
|
|
149
|
-
data: { sessionID, stateSequence },
|
|
150
|
-
});
|
|
151
|
-
await deps.sessionClient.promptAsync({
|
|
152
|
-
sessionID,
|
|
153
|
-
prompt,
|
|
154
|
-
});
|
|
155
|
-
} catch (error) {
|
|
156
|
-
deps.store.appendEvent(decision.state.goal.id, {
|
|
157
|
-
type: "goal.auto_continue_failed",
|
|
158
|
-
message: `Auto-continue failed for session ${sessionID ?? "unknown"}.`,
|
|
159
|
-
data: { error: error instanceof Error ? error.message : String(error), sessionID, stateSequence },
|
|
160
|
-
});
|
|
161
|
-
deps.logger?.error?.("Goal Mode auto-continue failed", error);
|
|
162
|
-
} finally {
|
|
163
|
-
autoContinueInFlight.delete(dedupeKey);
|
|
164
|
-
}
|
|
165
|
-
}),
|
|
166
|
-
};
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
const autoContinueInFlight = new Set<string>();
|
|
170
|
-
|
|
171
|
-
function extractCommandName(input: unknown): string {
|
|
172
|
-
if (!isRecord(input)) return "";
|
|
173
|
-
const command = input.command ?? input.name;
|
|
174
|
-
return typeof command === "string" ? command.trim().toLowerCase() : "";
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
function extractCommandArguments(input: unknown): string {
|
|
178
|
-
if (!isRecord(input)) return "";
|
|
179
|
-
const direct = input.arguments ?? input.argument ?? input.input;
|
|
180
|
-
if (typeof direct === "string") return direct;
|
|
181
|
-
const args = input.args;
|
|
182
|
-
if (!isRecord(args)) return "";
|
|
183
|
-
const nested = args.arguments ?? args.argument ?? args.input;
|
|
184
|
-
return typeof nested === "string" ? nested : "";
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
function replaceGoalCommandPrompt(input: unknown, output: HookOutput, text: string): void {
|
|
188
|
-
const prompt = renderGoalCommandPrompt(input, text);
|
|
189
|
-
|
|
190
|
-
if (Array.isArray(output.parts)) {
|
|
191
|
-
output.parts.splice(0, output.parts.length, {
|
|
192
|
-
id: createOpenCodeID("prt"),
|
|
193
|
-
sessionID: extractHookSessionID(input, output),
|
|
194
|
-
messageID: extractHookMessageID(input, output),
|
|
195
|
-
type: "text",
|
|
196
|
-
text: prompt,
|
|
197
|
-
synthetic: true,
|
|
198
|
-
});
|
|
199
|
-
return;
|
|
200
|
-
}
|
|
201
|
-
if (typeof output.message === "string") {
|
|
202
|
-
output.message = output.message ? `${output.message}\n\n${prompt}` : prompt;
|
|
203
|
-
return;
|
|
204
|
-
}
|
|
205
|
-
if (typeof output.output === "string") {
|
|
206
|
-
output.output = output.output ? `${output.output}\n\n${prompt}` : prompt;
|
|
207
|
-
return;
|
|
208
|
-
}
|
|
209
|
-
if (Array.isArray(output.content)) {
|
|
210
|
-
output.content.push({ type: "text", text: prompt });
|
|
211
|
-
return;
|
|
212
|
-
}
|
|
213
|
-
throw new Error("Unsupported OpenCode command output contract for Goal Mode.");
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
function renderGoalCommandPrompt(input: unknown, commandResult: string): string {
|
|
217
|
-
const args = extractCommandArguments(input).trim();
|
|
218
|
-
const firstToken = args.split(/\s+/, 1)[0]?.toLowerCase() ?? "";
|
|
219
|
-
const isControlCommand = new Set(["status", "plan", "history", "pause", "resume", "cancel", "merged"]).has(firstToken);
|
|
220
|
-
|
|
221
|
-
return [
|
|
222
|
-
"Goal Mode command result (authoritative):",
|
|
223
|
-
commandResult,
|
|
224
|
-
"",
|
|
225
|
-
"Instructions:",
|
|
226
|
-
isControlCommand
|
|
227
|
-
? "Reply with the Goal Mode command result only. Do not inspect files, run tools, continue implementation, create branches, open PRs, or change repository state."
|
|
228
|
-
: "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.",
|
|
229
|
-
].join("\n");
|
|
230
|
-
}
|
|
231
|
-
|
|
232
|
-
function extractHookSessionID(input: unknown, output: HookOutput): string {
|
|
233
|
-
const inputRecord = isRecord(input) ? input : undefined;
|
|
234
|
-
const outputRecord = output;
|
|
235
|
-
const direct = inputRecord?.sessionID ?? inputRecord?.sessionId ?? outputRecord.sessionID ?? outputRecord.sessionId;
|
|
236
|
-
if (typeof direct === "string" && direct.trim()) return direct;
|
|
237
|
-
|
|
238
|
-
const message = outputRecord.message ?? outputRecord.info;
|
|
239
|
-
if (isRecord(message)) {
|
|
240
|
-
const sessionID = message.sessionID ?? message.sessionId;
|
|
241
|
-
if (typeof sessionID === "string" && sessionID.trim()) return sessionID;
|
|
242
|
-
}
|
|
243
|
-
|
|
244
|
-
return createOpenCodeID("ses");
|
|
245
|
-
}
|
|
246
|
-
|
|
247
|
-
function extractHookMessageID(input: unknown, output: HookOutput): string {
|
|
248
|
-
const inputRecord = isRecord(input) ? input : undefined;
|
|
249
|
-
const outputRecord = output;
|
|
250
|
-
const direct = inputRecord?.messageID ?? inputRecord?.messageId ?? outputRecord.messageID ?? outputRecord.messageId;
|
|
251
|
-
if (typeof direct === "string" && direct.trim()) return direct;
|
|
252
|
-
|
|
253
|
-
const message = outputRecord.message ?? outputRecord.info;
|
|
254
|
-
if (isRecord(message)) {
|
|
255
|
-
const id = message.id ?? message.messageID ?? message.messageId;
|
|
256
|
-
if (typeof id === "string" && id.trim()) return id;
|
|
257
|
-
}
|
|
258
|
-
|
|
259
|
-
return createOpenCodeID("msg");
|
|
260
|
-
}
|
|
261
|
-
|
|
262
|
-
function upsertMarkedBlock(text: string, block: string): string {
|
|
263
|
-
const start = text.indexOf(GOAL_MODE_MARKER_START);
|
|
264
|
-
const end = text.indexOf(GOAL_MODE_MARKER_END);
|
|
265
|
-
if (start !== -1 && end !== -1 && end > start) {
|
|
266
|
-
return `${text.slice(0, start).trimEnd()}\n\n${block}${text.slice(end + GOAL_MODE_MARKER_END.length)}`;
|
|
267
|
-
}
|
|
268
|
-
return `${text.trimEnd()}\n\n${block}`;
|
|
269
|
-
}
|
|
270
|
-
|
|
271
|
-
function extractSessionID(properties: unknown): string | undefined {
|
|
272
|
-
if (!isRecord(properties)) return undefined;
|
|
273
|
-
if (typeof properties.sessionID === "string") return properties.sessionID;
|
|
274
|
-
const info = properties.info;
|
|
275
|
-
if (isRecord(info) && typeof info.id === "string") return info.id;
|
|
276
|
-
return undefined;
|
|
277
|
-
}
|
|
278
|
-
|
|
279
|
-
const AUTO_CONTINUE_EVENT_PREFIX = "goal.auto_continue_";
|
|
280
|
-
|
|
281
|
-
function latestNonAutoContinueSequence(events: Array<{ type: string; sequence: number }>): number {
|
|
282
|
-
return Math.max(
|
|
283
|
-
0,
|
|
284
|
-
...events
|
|
285
|
-
.filter((event) => !event.type.startsWith(AUTO_CONTINUE_EVENT_PREFIX))
|
|
286
|
-
.map((event) => event.sequence),
|
|
287
|
-
);
|
|
288
|
-
}
|
|
289
|
-
|
|
290
|
-
function hasAutoContinueEventForState(
|
|
291
|
-
events: Array<{ type: string; data?: unknown }>,
|
|
292
|
-
type: string,
|
|
293
|
-
stateSequence: number,
|
|
294
|
-
): boolean {
|
|
295
|
-
return events.some((event) => event.type === type && readEventStateSequence(event.data) === stateSequence);
|
|
296
|
-
}
|
|
297
|
-
|
|
298
|
-
function readEventStateSequence(data: unknown): number | undefined {
|
|
299
|
-
if (!isRecord(data)) return undefined;
|
|
300
|
-
return typeof data.stateSequence === "number" ? data.stateSequence : undefined;
|
|
301
|
-
}
|
|
302
|
-
|
|
303
|
-
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
304
|
-
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
305
|
-
}
|
|
306
|
-
|
|
307
|
-
// OpenCode validates entity IDs by prefix: parts must start with "prt", sessions with "ses", messages with "msg".
|
|
308
|
-
// The UUID is hex-only (dashes removed) to avoid confusion with uuid format.
|
|
309
|
-
function createOpenCodeID(prefix: "prt" | "ses" | "msg"): string {
|
|
310
|
-
return `${prefix}_${randomUUID().replace(/-/g, "")}`;
|
|
311
|
-
}
|
|
@@ -1,85 +0,0 @@
|
|
|
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
|
-
}
|