jorgex-stack 1.0.2 → 1.0.4
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 +16 -3
- package/README.md +44 -2
- package/dist/cli.js +32 -4
- package/package.json +2 -2
- package/stack/agents/code-simplifier.md +21 -10
- package/stack/agents/implementer.md +1 -0
- package/stack/agents/orchestrator.md +2 -0
- package/stack/agents/security-auditor.md +7 -0
- package/stack/agents/silent-failure-hunter.md +7 -0
- package/stack/agents/test-analyzer.md +7 -0
- package/stack/agents/type-design-analyzer.md +1 -1
- package/stack/commands/lean-audit.md +59 -0
- package/stack/commands/xreview.md +4 -2
- 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 +272 -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
- package/stack/scripts/post-pr-review.cjs +6 -3
- package/stack/skills/lean-code/SKILL.md +69 -0
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import type { GoalRecord, GoalStatus, GoalStore, NextAction } from "./types.js";
|
|
4
|
+
import { assertSafeArtifactPath, createMasterArtifacts } from "./artifacts.js";
|
|
5
|
+
|
|
6
|
+
const COMMANDS = new Set(["status", "plan", "history", "pause", "resume", "cancel", "merged"]);
|
|
7
|
+
const EXPLICITLY_UNSUPPORTED = new Set(["quick", "work"]);
|
|
8
|
+
|
|
9
|
+
export interface GoalCommandHandlersOptions {
|
|
10
|
+
store: GoalStore;
|
|
11
|
+
project: string;
|
|
12
|
+
artifactsRootDir?: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface GoalCommandResponse {
|
|
16
|
+
message: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface GoalCommandHandlers {
|
|
20
|
+
handleGoalCommand(input: string): GoalCommandResponse;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function createGoalCommandHandlers(options: GoalCommandHandlersOptions): GoalCommandHandlers {
|
|
24
|
+
let currentGoalId: string | undefined;
|
|
25
|
+
|
|
26
|
+
const currentGoal = () => {
|
|
27
|
+
const byId = currentGoalId ? options.store.getGoal(currentGoalId) : undefined;
|
|
28
|
+
const active = byId ?? options.store.getCurrentGoal(options.project);
|
|
29
|
+
currentGoalId = active?.id ?? currentGoalId;
|
|
30
|
+
return active;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const requireCurrentGoal = () => {
|
|
34
|
+
const goal = currentGoal();
|
|
35
|
+
if (!goal) {
|
|
36
|
+
throw new Error("No active goal found. Start one with /goal <objective>.");
|
|
37
|
+
}
|
|
38
|
+
return goal;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
return {
|
|
42
|
+
handleGoalCommand(rawInput: string): GoalCommandResponse {
|
|
43
|
+
const input = rawInput.trim();
|
|
44
|
+
const [firstToken = "", ...args] = input.split(/\s+/);
|
|
45
|
+
const firstLower = firstToken.toLowerCase();
|
|
46
|
+
const normalized = firstLower === "merged" ? "merged" : input.toLowerCase();
|
|
47
|
+
|
|
48
|
+
if (input.length === 0) {
|
|
49
|
+
throw new Error("Goal objective cannot be empty.");
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (EXPLICITLY_UNSUPPORTED.has(normalized)) {
|
|
53
|
+
throw new Error("/goal quick and /goal work are not supported. Use /goal <objective> for large goals.");
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (!COMMANDS.has(normalized)) {
|
|
57
|
+
let artifactRootDir: string | undefined;
|
|
58
|
+
const goal = options.store.transaction(() => {
|
|
59
|
+
const createdGoal = options.store.createGoal({
|
|
60
|
+
objective: input,
|
|
61
|
+
project: options.project,
|
|
62
|
+
});
|
|
63
|
+
artifactRootDir = options.artifactsRootDir
|
|
64
|
+
? path.join(options.artifactsRootDir, createdGoal.id)
|
|
65
|
+
: undefined;
|
|
66
|
+
try {
|
|
67
|
+
if (artifactRootDir) {
|
|
68
|
+
createMasterArtifacts({
|
|
69
|
+
store: options.store,
|
|
70
|
+
goalId: createdGoal.id,
|
|
71
|
+
rootDir: artifactRootDir,
|
|
72
|
+
allowedRootDir: options.artifactsRootDir,
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
options.store.appendEvent(createdGoal.id, {
|
|
76
|
+
type: "goal.created",
|
|
77
|
+
message: `Goal created: ${createdGoal.objective}`,
|
|
78
|
+
data: { objective: createdGoal.objective, project: createdGoal.project },
|
|
79
|
+
});
|
|
80
|
+
} catch (error) {
|
|
81
|
+
let cleanupFailure: string | undefined;
|
|
82
|
+
if (artifactRootDir && options.artifactsRootDir) {
|
|
83
|
+
cleanupFailure = cleanupBootstrappedArtifactDir(artifactRootDir, options.artifactsRootDir);
|
|
84
|
+
}
|
|
85
|
+
const rootCause = error instanceof Error ? error.message : String(error);
|
|
86
|
+
throw new Error(
|
|
87
|
+
`Goal bootstrap failed: ${rootCause}${cleanupFailure ? ` Cleanup also failed: ${cleanupFailure}` : ""}`,
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
return createdGoal;
|
|
91
|
+
});
|
|
92
|
+
currentGoalId = goal.id;
|
|
93
|
+
|
|
94
|
+
return {
|
|
95
|
+
message: `Goal created: ${goal.objective}\nStatus: ${goal.status}\nNext action: ${formatNextAction(options.store.nextAction(goal.id))}`,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (normalized === "status") return statusResponse(currentGoal(), options.store);
|
|
100
|
+
if (normalized === "plan") {
|
|
101
|
+
return planResponse(requireCurrentGoal(), options.store, options.artifactsRootDir);
|
|
102
|
+
}
|
|
103
|
+
if (normalized === "history") return historyResponse(requireCurrentGoal(), options.store);
|
|
104
|
+
|
|
105
|
+
const goal = requireCurrentGoal();
|
|
106
|
+
if (normalized === "pause") {
|
|
107
|
+
return transitionResponse(options.store, goal, "paused", "Goal paused by user.");
|
|
108
|
+
}
|
|
109
|
+
if (normalized === "resume") {
|
|
110
|
+
if (goal.status === "waiting_for_merge") {
|
|
111
|
+
throw new Error("Cannot resume while the goal is waiting for an external PR merge.");
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const nextStatus = options.store.getOpenPullRequest(goal.id) ? "waiting_for_merge" : "active";
|
|
115
|
+
return transitionResponse(options.store, goal, nextStatus, "Goal resumed by user.");
|
|
116
|
+
}
|
|
117
|
+
if (normalized === "cancel") {
|
|
118
|
+
return transitionResponse(options.store, goal, "cancelled", "Goal cancelled by user.");
|
|
119
|
+
}
|
|
120
|
+
if (normalized === "merged") {
|
|
121
|
+
return mergedResponse(options.store, goal, args.join(" "));
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
throw new Error(`Unsupported /goal command: ${input}`);
|
|
125
|
+
},
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function statusResponse(goal: GoalRecord | undefined, store: GoalStore): GoalCommandResponse {
|
|
130
|
+
if (!goal) {
|
|
131
|
+
return { message: "No active goal. Start one with /goal <objective>." };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const lines = [
|
|
135
|
+
`Goal: ${goal.objective}`,
|
|
136
|
+
`Status: ${goal.status}`,
|
|
137
|
+
`Next action: ${formatNextAction(store.nextAction(goal.id))}`,
|
|
138
|
+
];
|
|
139
|
+
const issue = latestAutoContinueIssue(store, goal.id);
|
|
140
|
+
if (issue) lines.push(`Operational issue: ${issue.message}`);
|
|
141
|
+
|
|
142
|
+
return {
|
|
143
|
+
message: lines.join("\n"),
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function planResponse(goal: GoalRecord, store: GoalStore, artifactsRootDir: string | undefined): GoalCommandResponse {
|
|
148
|
+
const plan = store.getArtifact(goal.id, "plan");
|
|
149
|
+
if (plan) {
|
|
150
|
+
if (!fs.existsSync(plan.path)) {
|
|
151
|
+
throw new Error(`Registered goal plan is not readable at ${plan.path}. Recreate or fix the artifact path.`);
|
|
152
|
+
}
|
|
153
|
+
if (artifactsRootDir) {
|
|
154
|
+
assertSafeArtifactPath(plan.path, artifactsRootDir, "Registered goal plan");
|
|
155
|
+
}
|
|
156
|
+
return {
|
|
157
|
+
message: [`Plan for goal: ${goal.objective}`, fs.readFileSync(plan.path, "utf8")].join("\n\n"),
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
if (artifactsRootDir) {
|
|
162
|
+
throw new Error("Goal master plan artifact is not registered. Recreate or repair the goal artifacts.");
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
return {
|
|
166
|
+
message: [
|
|
167
|
+
`Plan for goal: ${goal.objective}`,
|
|
168
|
+
"Phases: master PRD/plan generation, slice execution, PR review, waiting for merge, final verification.",
|
|
169
|
+
"Detailed master artifacts are created by the next Goal Mode slice.",
|
|
170
|
+
].join("\n"),
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function cleanupBootstrappedArtifactDir(rootDir: string, allowedRootDir: string): string | undefined {
|
|
175
|
+
try {
|
|
176
|
+
assertSafeArtifactPath(rootDir, allowedRootDir, "Goal artifact cleanup path");
|
|
177
|
+
fs.rmSync(rootDir, { recursive: true, force: true });
|
|
178
|
+
return undefined;
|
|
179
|
+
} catch (error) {
|
|
180
|
+
return error instanceof Error ? error.message : String(error);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function mergedResponse(store: GoalStore, goal: GoalRecord, mergeCommit: string): GoalCommandResponse {
|
|
185
|
+
const pullRequest = store.getOpenPullRequest(goal.id);
|
|
186
|
+
if (!pullRequest) {
|
|
187
|
+
throw new Error("No open pull request is waiting for merge.");
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const merged = store.recordPullRequestMerged(pullRequest.id, {
|
|
191
|
+
mergedAt: new Date().toISOString(),
|
|
192
|
+
mergeCommit: mergeCommit.trim() || "manual",
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
return {
|
|
196
|
+
message: `Pull request #${merged.number} marked as merged. Goal status: ${store.getGoal(goal.id)?.status ?? "unknown"}`,
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function historyResponse(goal: GoalRecord, store: GoalStore): GoalCommandResponse {
|
|
201
|
+
const events = store.listEvents(goal.id);
|
|
202
|
+
const lines = events.length === 0
|
|
203
|
+
? ["No history events recorded yet."]
|
|
204
|
+
: events.map((event) => `- ${event.createdAt} ${event.type}: ${event.message}`);
|
|
205
|
+
|
|
206
|
+
return {
|
|
207
|
+
message: [`History for goal: ${goal.objective}`, ...lines].join("\n"),
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function transitionResponse(
|
|
212
|
+
store: GoalStore,
|
|
213
|
+
goal: GoalRecord,
|
|
214
|
+
status: GoalStatus,
|
|
215
|
+
reason: string,
|
|
216
|
+
): GoalCommandResponse {
|
|
217
|
+
const updated = store.transitionGoal(goal.id, status, { reason });
|
|
218
|
+
return {
|
|
219
|
+
message: `Goal ${updated.status}: ${updated.objective}`,
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function latestAutoContinueIssue(store: GoalStore, goalId: string) {
|
|
224
|
+
const events = store.listEvents(goalId);
|
|
225
|
+
const currentStateSequence = Math.max(
|
|
226
|
+
0,
|
|
227
|
+
...events
|
|
228
|
+
.filter((event) => !event.type.startsWith("goal.auto_continue_"))
|
|
229
|
+
.map((event) => event.sequence),
|
|
230
|
+
);
|
|
231
|
+
return events
|
|
232
|
+
.filter((event) =>
|
|
233
|
+
(
|
|
234
|
+
event.type === "goal.auto_continue_unavailable" ||
|
|
235
|
+
event.type === "goal.auto_continue_failed" ||
|
|
236
|
+
event.type === "goal.auto_continue_skipped"
|
|
237
|
+
) &&
|
|
238
|
+
readEventStateSequence(event.data) === currentStateSequence,
|
|
239
|
+
)
|
|
240
|
+
.at(-1);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function readEventStateSequence(data: unknown): number | undefined {
|
|
244
|
+
if (typeof data !== "object" || data === null || Array.isArray(data)) return undefined;
|
|
245
|
+
const value = (data as { stateSequence?: unknown }).stateSequence;
|
|
246
|
+
return typeof value === "number" ? value : undefined;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function formatNextAction(action: NextAction): string {
|
|
250
|
+
if (action.type === "wait_for_merge") {
|
|
251
|
+
return `waiting for external merge of ${action.pullRequestId}`;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
return "continue";
|
|
255
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import { createRequire } from "node:module";
|
|
4
|
+
|
|
5
|
+
type SqliteRow = Record<string, unknown>;
|
|
6
|
+
|
|
7
|
+
interface SqliteStatement {
|
|
8
|
+
run(...params: unknown[]): unknown;
|
|
9
|
+
get(...params: unknown[]): SqliteRow | undefined;
|
|
10
|
+
all(...params: unknown[]): SqliteRow[];
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
interface SqliteDatabase {
|
|
14
|
+
exec(sql: string): void;
|
|
15
|
+
close(): void;
|
|
16
|
+
prepare?(sql: string): SqliteStatement;
|
|
17
|
+
query?(sql: string): SqliteStatement;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const require = createRequire(import.meta.url);
|
|
21
|
+
|
|
22
|
+
function loadDatabaseCtor(): new (databasePath: string, options?: Record<string, unknown>) => SqliteDatabase {
|
|
23
|
+
if (typeof (globalThis as { Bun?: unknown }).Bun !== "undefined") {
|
|
24
|
+
return require("bun:sqlite").Database;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
return require("node:sqlite").DatabaseSync;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export class GoalDb {
|
|
31
|
+
private readonly db: SqliteDatabase;
|
|
32
|
+
|
|
33
|
+
constructor(databasePath: string) {
|
|
34
|
+
fs.mkdirSync(path.dirname(databasePath), { recursive: true });
|
|
35
|
+
const Database = loadDatabaseCtor();
|
|
36
|
+
this.db = new Database(databasePath, { create: true });
|
|
37
|
+
this.exec("PRAGMA foreign_keys = ON;");
|
|
38
|
+
this.exec("PRAGMA busy_timeout = 5000;");
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
exec(sql: string): void {
|
|
42
|
+
this.db.exec(sql);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
prepare(sql: string): SqliteStatement {
|
|
46
|
+
const statement = this.db.prepare?.(sql) ?? this.db.query?.(sql);
|
|
47
|
+
if (!statement) {
|
|
48
|
+
throw new Error("SQLite runtime does not expose prepare/query.");
|
|
49
|
+
}
|
|
50
|
+
return statement;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
get(sql: string, ...params: unknown[]): SqliteRow | undefined {
|
|
54
|
+
return this.prepare(sql).get(...params);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
all(sql: string, ...params: unknown[]): SqliteRow[] {
|
|
58
|
+
return this.prepare(sql).all(...params);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
run(sql: string, ...params: unknown[]): unknown {
|
|
62
|
+
return this.prepare(sql).run(...params);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
close(): void {
|
|
66
|
+
this.db.close();
|
|
67
|
+
}
|
|
68
|
+
}
|
|
@@ -0,0 +1,272 @@
|
|
|
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
|
+
appendHookText(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 appendHookText(input: unknown, output: HookOutput, text: string): void {
|
|
173
|
+
if (Array.isArray(output.parts)) {
|
|
174
|
+
output.parts.push({
|
|
175
|
+
id: `part_${randomUUID()}`,
|
|
176
|
+
sessionID: extractHookSessionID(input, output),
|
|
177
|
+
messageID: extractHookMessageID(input, output),
|
|
178
|
+
type: "text",
|
|
179
|
+
text,
|
|
180
|
+
synthetic: true,
|
|
181
|
+
});
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
if (typeof output.message === "string") {
|
|
185
|
+
output.message = output.message ? `${output.message}\n\n${text}` : text;
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
if (typeof output.output === "string") {
|
|
189
|
+
output.output = output.output ? `${output.output}\n\n${text}` : text;
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
if (Array.isArray(output.content)) {
|
|
193
|
+
output.content.push({ type: "text", text });
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
throw new Error("Unsupported OpenCode command output contract for Goal Mode.");
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function extractHookSessionID(input: unknown, output: HookOutput): string {
|
|
200
|
+
const inputRecord = isRecord(input) ? input : undefined;
|
|
201
|
+
const outputRecord = output;
|
|
202
|
+
const direct = inputRecord?.sessionID ?? inputRecord?.sessionId ?? outputRecord.sessionID ?? outputRecord.sessionId;
|
|
203
|
+
if (typeof direct === "string" && direct.trim()) return direct;
|
|
204
|
+
|
|
205
|
+
const message = outputRecord.message ?? outputRecord.info;
|
|
206
|
+
if (isRecord(message)) {
|
|
207
|
+
const sessionID = message.sessionID ?? message.sessionId;
|
|
208
|
+
if (typeof sessionID === "string" && sessionID.trim()) return sessionID;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
return `session_${randomUUID()}`;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function extractHookMessageID(input: unknown, output: HookOutput): string {
|
|
215
|
+
const inputRecord = isRecord(input) ? input : undefined;
|
|
216
|
+
const outputRecord = output;
|
|
217
|
+
const direct = inputRecord?.messageID ?? inputRecord?.messageId ?? outputRecord.messageID ?? outputRecord.messageId;
|
|
218
|
+
if (typeof direct === "string" && direct.trim()) return direct;
|
|
219
|
+
|
|
220
|
+
const message = outputRecord.message ?? outputRecord.info;
|
|
221
|
+
if (isRecord(message)) {
|
|
222
|
+
const id = message.id ?? message.messageID ?? message.messageId;
|
|
223
|
+
if (typeof id === "string" && id.trim()) return id;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
return `msg_${randomUUID()}`;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function upsertMarkedBlock(text: string, block: string): string {
|
|
230
|
+
const start = text.indexOf(GOAL_MODE_MARKER_START);
|
|
231
|
+
const end = text.indexOf(GOAL_MODE_MARKER_END);
|
|
232
|
+
if (start !== -1 && end !== -1 && end > start) {
|
|
233
|
+
return `${text.slice(0, start).trimEnd()}\n\n${block}${text.slice(end + GOAL_MODE_MARKER_END.length)}`;
|
|
234
|
+
}
|
|
235
|
+
return `${text.trimEnd()}\n\n${block}`;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function extractSessionID(properties: unknown): string | undefined {
|
|
239
|
+
if (!isRecord(properties)) return undefined;
|
|
240
|
+
if (typeof properties.sessionID === "string") return properties.sessionID;
|
|
241
|
+
const info = properties.info;
|
|
242
|
+
if (isRecord(info) && typeof info.id === "string") return info.id;
|
|
243
|
+
return undefined;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const AUTO_CONTINUE_EVENT_PREFIX = "goal.auto_continue_";
|
|
247
|
+
|
|
248
|
+
function latestNonAutoContinueSequence(events: Array<{ type: string; sequence: number }>): number {
|
|
249
|
+
return Math.max(
|
|
250
|
+
0,
|
|
251
|
+
...events
|
|
252
|
+
.filter((event) => !event.type.startsWith(AUTO_CONTINUE_EVENT_PREFIX))
|
|
253
|
+
.map((event) => event.sequence),
|
|
254
|
+
);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function hasAutoContinueEventForState(
|
|
258
|
+
events: Array<{ type: string; data?: unknown }>,
|
|
259
|
+
type: string,
|
|
260
|
+
stateSequence: number,
|
|
261
|
+
): boolean {
|
|
262
|
+
return events.some((event) => event.type === type && readEventStateSequence(event.data) === stateSequence);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function readEventStateSequence(data: unknown): number | undefined {
|
|
266
|
+
if (!isRecord(data)) return undefined;
|
|
267
|
+
return typeof data.stateSequence === "number" ? data.stateSequence : undefined;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
271
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
272
|
+
}
|
|
@@ -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
|
+
}
|