@litfamily/litopencode 1.0.35 โ 1.0.37
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/CHANGELOG.md +10 -0
- package/README-Ko-KR.md +6 -6
- package/README.md +6 -6
- package/dist/activation-primary-prompts.js +7 -3
- package/dist/activation.js +5 -0
- package/dist/commands.js +17 -4
- package/dist/native-goal-ledger.d.ts +43 -0
- package/dist/native-goal-ledger.js +74 -0
- package/dist/tui/mission-skills.js +3 -1
- package/dist/tui/mission-source-v2.d.ts +23 -0
- package/dist/tui/mission-source-v2.js +29 -4
- package/dist/tui/mission-source.d.ts +1 -0
- package/dist/tui/mission-source.js +3 -2
- package/dist/v2/commands.d.ts +1 -0
- package/dist/v2/commands.js +2 -2
- package/dist/v2/context.d.ts +6 -1
- package/dist/v2/native-goal-runtime.d.ts +74 -0
- package/dist/v2/native-goal-runtime.js +193 -0
- package/dist/v2/native-goal.d.ts +49 -0
- package/dist/v2/native-goal.js +71 -0
- package/dist/v2/server.js +11 -1
- package/dist/v2/session-hooks.d.ts +4 -0
- package/dist/v2/session-hooks.js +13 -0
- package/dist/v2/tui-notices.d.ts +7 -0
- package/dist/v2/tui-notices.js +45 -0
- package/dist/v2/tui.d.ts +37 -1
- package/dist/v2/tui.js +309 -31
- package/docs/assets/readme/badge-version.svg +1 -1
- package/docs/migration.md +1 -1
- package/docs/reference-Ko-KR.md +13 -5
- package/docs/reference.md +22 -9
- package/package.json +2 -2
- package/skills/lit-typographic-motion/engine/cover/render.mjs +1 -1
- package/skills/lit-typographic-motion/engine/stage/capture.mjs +3 -2
- package/skills/lit-typographic-motion/engine/stage/gate.mjs +1 -0
- package/skills/lit-typographic-motion/engine/stage/render.mjs +1 -0
- package/skills/lit-typographic-motion/engine/stage/scan.mjs +109 -5
- package/skills/lit-typographic-motion/engine/stage/stage-kit.js +39 -1
- package/skills/lit-typographic-motion/references/cover.md +2 -1
- package/skills/lit-typographic-motion/references/runtime.md +1 -1
- package/skills/lit-typographic-motion/references/stage.md +48 -2
- package/skills/litgoal/SKILL.md +2 -2
- package/skills/litwork/SKILL.md +4 -0
- package/skills/managed-skill-manifest.json +9 -9
- package/skills/native-goal-verdict/SKILL.md +34 -60
- package/skills/start-work/SKILL.md +4 -0
- package/skills/workflow-loop/SKILL.md +4 -0
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
// Drives OpenCode 2's native `/goal` for lit-loop, start-work and litwork. The host has no goal setter:
|
|
2
|
+
// the goal is read through the `opencode.goal` RPC and changed by sending the `/goal` command to the
|
|
3
|
+
// session (`session.command`). Policy lives in native-goal.ts; this file reads the state, performs the one
|
|
4
|
+
// action it returns, prints one line per change as a notice row, and records it in the ledger. Nothing here
|
|
5
|
+
// throws: a failed step is recorded and the work carries on with the ledger alone.
|
|
6
|
+
import { ledgerGoalDone, nativeGoalOwnership, recordNativeGoal, recordNativeGoalUnavailable } from "../native-goal-ledger.js";
|
|
7
|
+
import { litOpenCodeCommands } from "../commands.js";
|
|
8
|
+
import { decideNativeGoal, nativeGoalLine, nativeGoalObjective, nativeGoalRoute } from "./native-goal.js";
|
|
9
|
+
/** The `opencode.goal` RPC contract as JSON Schema, so the package needs no runtime import of the host's schema library. */
|
|
10
|
+
const goalDefinition = Object.freeze({
|
|
11
|
+
id: "opencode.goal",
|
|
12
|
+
methods: Object.freeze({
|
|
13
|
+
get: Object.freeze({
|
|
14
|
+
input: Object.freeze({ type: "object", properties: Object.freeze({ sessionID: Object.freeze({ type: "string" }) }), required: Object.freeze(["sessionID"]) }),
|
|
15
|
+
output: Object.freeze({})
|
|
16
|
+
})
|
|
17
|
+
}),
|
|
18
|
+
events: Object.freeze({
|
|
19
|
+
changed: Object.freeze({ schema: Object.freeze({ type: "object", properties: Object.freeze({ sessionID: Object.freeze({ type: "string" }) }), required: Object.freeze(["sessionID"]) }) })
|
|
20
|
+
})
|
|
21
|
+
});
|
|
22
|
+
function isRecord(value) {
|
|
23
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
24
|
+
}
|
|
25
|
+
function parseGoal(value) {
|
|
26
|
+
if (value === null || value === undefined)
|
|
27
|
+
return null;
|
|
28
|
+
if (!isRecord(value) || typeof value.id !== "string" || typeof value.objective !== "string")
|
|
29
|
+
return "unreadable";
|
|
30
|
+
if (value.status !== "active" && value.status !== "paused" && value.status !== "complete")
|
|
31
|
+
return "unreadable";
|
|
32
|
+
return { id: value.id, objective: value.objective, status: value.status, ...(typeof value.reason === "string" ? { reason: value.reason } : {}) };
|
|
33
|
+
}
|
|
34
|
+
/** OpenCode 2.2.5 answers an RPC it does not register with type `rpc.unavailable`; any other failure is a failed read. */
|
|
35
|
+
function hostLacksGoalRpc(error) {
|
|
36
|
+
return isRecord(error) && error.type === "rpc.unavailable";
|
|
37
|
+
}
|
|
38
|
+
function failureDetail(error) {
|
|
39
|
+
const text = error instanceof Error ? error.message : isRecord(error) ? String(error.message ?? error.type ?? "failed") : String(error);
|
|
40
|
+
return text.replace(/\s+/gu, " ").slice(0, 160);
|
|
41
|
+
}
|
|
42
|
+
/** A prompt the host expanded from an installed command alias file arrives as the command body, unmarked. */
|
|
43
|
+
export function aliasGoalRoute(input) {
|
|
44
|
+
const text = input.trimStart();
|
|
45
|
+
for (const command of litOpenCodeCommands) {
|
|
46
|
+
const head = command.activationText.trim();
|
|
47
|
+
if (head === "" || !text.startsWith(head))
|
|
48
|
+
continue;
|
|
49
|
+
const route = nativeGoalRoute(command.id, "");
|
|
50
|
+
return route === undefined ? undefined : { route, work: text.slice(head.length) };
|
|
51
|
+
}
|
|
52
|
+
return undefined;
|
|
53
|
+
}
|
|
54
|
+
export function createNativeGoal(options) {
|
|
55
|
+
const { projectRoot, host } = options;
|
|
56
|
+
const client = host.rpc === undefined ? undefined : host.rpc(goalDefinition);
|
|
57
|
+
const said = new Set();
|
|
58
|
+
const running = new Set();
|
|
59
|
+
async function read(sessionID) {
|
|
60
|
+
if (client === undefined)
|
|
61
|
+
return { supported: false };
|
|
62
|
+
try {
|
|
63
|
+
return { supported: true, goal: parseGoal(await client.get({ sessionID })) };
|
|
64
|
+
}
|
|
65
|
+
catch (error) {
|
|
66
|
+
return hostLacksGoalRpc(error) ? { supported: false } : { supported: true, goal: "unreadable" };
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
async function notice(sessionID, line, key) {
|
|
70
|
+
if (line === "" || said.has(key))
|
|
71
|
+
return;
|
|
72
|
+
said.add(key);
|
|
73
|
+
try {
|
|
74
|
+
await host.session.synthetic({ sessionID, text: line, description: line, metadata: { source: "litopencode-goal" }, resume: false });
|
|
75
|
+
}
|
|
76
|
+
catch { // no-excuse-ok: catch -- a missing notice row must not stop the work; the ledger holds the record.
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
async function fail(sessionID, route, step, error) {
|
|
81
|
+
await recordNativeGoal(projectRoot, sessionID, route, { type: "native-goal.failed", step, detail: failureDetail(error) });
|
|
82
|
+
}
|
|
83
|
+
async function perform(sessionID, route, action, goal, work) {
|
|
84
|
+
const record = goal === null || goal === "unreadable" ? undefined : goal;
|
|
85
|
+
const key = (name) => `${sessionID}:${name}:${record?.id ?? ""}:${action.kind === "report-conflict" ? action.reason : ""}`;
|
|
86
|
+
switch (action.kind) {
|
|
87
|
+
case "none":
|
|
88
|
+
case "keep":
|
|
89
|
+
return;
|
|
90
|
+
case "unavailable": {
|
|
91
|
+
if (route !== undefined)
|
|
92
|
+
await recordNativeGoalUnavailable(projectRoot, sessionID, route, "opencode-2-without-goal-rpc");
|
|
93
|
+
await notice(sessionID, nativeGoalLine(action, { route }), key("unavailable"));
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
case "report-user-goal": {
|
|
97
|
+
if (!said.has(key("user-goal")) && record !== undefined) {
|
|
98
|
+
await recordNativeGoal(projectRoot, sessionID, route, { type: "native-goal.left-alone", goalID: record.id, status: record.status });
|
|
99
|
+
}
|
|
100
|
+
await notice(sessionID, nativeGoalLine(action, { route, ...(record === undefined ? {} : { goal: record }) }), key("user-goal"));
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
case "report-conflict": {
|
|
104
|
+
if (!said.has(key("conflict"))) {
|
|
105
|
+
await recordNativeGoal(projectRoot, sessionID, route, { type: "native-goal.conflict", goalID: record?.id ?? "", reason: action.reason });
|
|
106
|
+
}
|
|
107
|
+
await notice(sessionID, nativeGoalLine(action, { route, ...(record === undefined ? {} : { goal: record }) }), key("conflict"));
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
case "create": {
|
|
111
|
+
const objective = nativeGoalObjective(work, sessionID);
|
|
112
|
+
try {
|
|
113
|
+
await host.session.command({ sessionID, name: "goal", text: objective, delivery: "queue" });
|
|
114
|
+
}
|
|
115
|
+
catch (error) {
|
|
116
|
+
await fail(sessionID, route, "create", error);
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
const after = await read(sessionID);
|
|
120
|
+
const created = after.supported && after.goal !== null && after.goal !== "unreadable" && after.goal.objective === objective ? after.goal : undefined;
|
|
121
|
+
if (created === undefined) {
|
|
122
|
+
await fail(sessionID, route, "create", "the goal was not readable after /goal; ownership not claimed");
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
await recordNativeGoal(projectRoot, sessionID, route, { type: "native-goal.created", goalID: created.id });
|
|
126
|
+
await notice(sessionID, nativeGoalLine(action, { route, goal: created }), key("created"));
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
case "resume":
|
|
130
|
+
case "clear": {
|
|
131
|
+
if (record === undefined)
|
|
132
|
+
return;
|
|
133
|
+
const control = action.kind;
|
|
134
|
+
try {
|
|
135
|
+
await host.session.command({ sessionID, name: "goal", text: control, delivery: "queue" });
|
|
136
|
+
}
|
|
137
|
+
catch (error) {
|
|
138
|
+
await fail(sessionID, route, control, error);
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
const event = control === "resume" ? { type: "native-goal.resumed", goalID: record.id } : { type: "native-goal.cleared", goalID: record.id };
|
|
142
|
+
await recordNativeGoal(projectRoot, sessionID, route, event);
|
|
143
|
+
await notice(sessionID, nativeGoalLine(action, { route, goal: record }), key(control));
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
/** One decision for one session: `route` set on an activation, absent on an idle check. */
|
|
149
|
+
async function step(sessionID, route, work) {
|
|
150
|
+
const owned = await nativeGoalOwnership(projectRoot, sessionID);
|
|
151
|
+
if (route === undefined && owned.goalID === undefined)
|
|
152
|
+
return;
|
|
153
|
+
const state = await read(sessionID);
|
|
154
|
+
const goal = state.supported ? state.goal : null;
|
|
155
|
+
const action = decideNativeGoal({
|
|
156
|
+
route,
|
|
157
|
+
supported: state.supported,
|
|
158
|
+
goal,
|
|
159
|
+
ownedGoalID: owned.goalID,
|
|
160
|
+
ledgerDone: owned.goalID === undefined ? false : await ledgerGoalDone(projectRoot, sessionID, owned.createdAt),
|
|
161
|
+
resumes: owned.resumes
|
|
162
|
+
});
|
|
163
|
+
await perform(sessionID, route, action, goal, work);
|
|
164
|
+
}
|
|
165
|
+
/** One step at a time per session, so a repeated activation sees the goal the first one made. */
|
|
166
|
+
const chains = new Map();
|
|
167
|
+
function track(sessionID, run) {
|
|
168
|
+
const previous = chains.get(sessionID) ?? Promise.resolve();
|
|
169
|
+
const safe = previous.then(run).catch(() => undefined).finally(() => {
|
|
170
|
+
running.delete(safe);
|
|
171
|
+
if (chains.get(sessionID) === safe)
|
|
172
|
+
chains.delete(sessionID);
|
|
173
|
+
});
|
|
174
|
+
chains.set(sessionID, safe);
|
|
175
|
+
running.add(safe);
|
|
176
|
+
return safe;
|
|
177
|
+
}
|
|
178
|
+
return {
|
|
179
|
+
/** Starts the goal step for a qualifying activation without holding up the user's prompt. */
|
|
180
|
+
activate(sessionID, route, work) {
|
|
181
|
+
return track(sessionID, () => step(sessionID, route, work));
|
|
182
|
+
},
|
|
183
|
+
/** The session finished its model work: clear a finished owned goal or report a conflict. */
|
|
184
|
+
idle(sessionID) {
|
|
185
|
+
return track(sessionID, () => step(sessionID, undefined, ""));
|
|
186
|
+
},
|
|
187
|
+
/** Resolves when every started goal step has finished. */
|
|
188
|
+
async settled() {
|
|
189
|
+
while (running.size > 0)
|
|
190
|
+
await Promise.all([...running]);
|
|
191
|
+
}
|
|
192
|
+
};
|
|
193
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
export type NativeGoalRoute = "lit-loop" | "start-work" | "litwork";
|
|
2
|
+
export type NativeGoalRecord = {
|
|
3
|
+
readonly id: string;
|
|
4
|
+
readonly objective: string;
|
|
5
|
+
readonly status: "active" | "paused" | "complete";
|
|
6
|
+
readonly reason?: string;
|
|
7
|
+
};
|
|
8
|
+
export type NativeGoalInput = {
|
|
9
|
+
/** The route that is activating now; absent on an idle check. */
|
|
10
|
+
readonly route: string | undefined;
|
|
11
|
+
/** Whether this host answers the `opencode.goal` RPC. */
|
|
12
|
+
readonly supported: boolean;
|
|
13
|
+
/** The host's goal for the session: null when none, "unreadable" when the read failed. */
|
|
14
|
+
readonly goal: NativeGoalRecord | null | "unreadable";
|
|
15
|
+
/** The goal id LitOpenCode created for this session, from its ledger. */
|
|
16
|
+
readonly ownedGoalID: string | undefined;
|
|
17
|
+
/** Whether LitOpenCode's evidence ledger marks the session's goal complete. */
|
|
18
|
+
readonly ledgerDone: boolean;
|
|
19
|
+
/** How many times LitOpenCode already resumed the owned goal. */
|
|
20
|
+
readonly resumes: number;
|
|
21
|
+
};
|
|
22
|
+
export type NativeGoalAction = {
|
|
23
|
+
readonly kind: "none";
|
|
24
|
+
} | {
|
|
25
|
+
readonly kind: "create";
|
|
26
|
+
} | {
|
|
27
|
+
readonly kind: "keep";
|
|
28
|
+
} | {
|
|
29
|
+
readonly kind: "resume";
|
|
30
|
+
} | {
|
|
31
|
+
readonly kind: "clear";
|
|
32
|
+
} | {
|
|
33
|
+
readonly kind: "report-user-goal";
|
|
34
|
+
} | {
|
|
35
|
+
readonly kind: "report-conflict";
|
|
36
|
+
readonly reason: string;
|
|
37
|
+
} | {
|
|
38
|
+
readonly kind: "unavailable";
|
|
39
|
+
};
|
|
40
|
+
/** The qualifying route for a chat mode or command id, or undefined for every other route. */
|
|
41
|
+
export declare function nativeGoalRoute(mode: string, text: string): NativeGoalRoute | undefined;
|
|
42
|
+
export declare function decideNativeGoal(input: NativeGoalInput): NativeGoalAction;
|
|
43
|
+
/** The text sent to `/goal`: the work, then the conditions OpenCode's reviewer judges it against. */
|
|
44
|
+
export declare function nativeGoalObjective(work: string, sessionID?: string): string;
|
|
45
|
+
/** The single line announced for an action; an empty string when nothing changed. */
|
|
46
|
+
export declare function nativeGoalLine(action: NativeGoalAction, detail: {
|
|
47
|
+
readonly route?: string;
|
|
48
|
+
readonly goal?: Pick<NativeGoalRecord, "id" | "status" | "objective">;
|
|
49
|
+
}): string;
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// Policy for OpenCode 2's native `/goal`. It is pure: the route, the goal record the host reports and the
|
|
2
|
+
// state of LitOpenCode's own ledger go in; one action comes out. The host has no setter for a goal, only
|
|
3
|
+
// the `/goal` command, and that command replaces any goal that exists, so every create is decided here
|
|
4
|
+
// first. LitOpenCode manages only a goal it created (the ledger remembers the id); a goal the user made
|
|
5
|
+
// is reported and never touched. Done needs two things: OpenCode's verdict and the ledger.
|
|
6
|
+
import { containsSecret } from "../secret-shapes.js";
|
|
7
|
+
const goalRoutes = new Set(["lit-loop", "start-work", "litwork"]);
|
|
8
|
+
/** The host pauses a goal on a restart; it is the only pause that is safe to resume without asking. */
|
|
9
|
+
const restartPause = "The server restarted while the goal was active";
|
|
10
|
+
const maxResumes = 2;
|
|
11
|
+
const maxObjective = 4000;
|
|
12
|
+
const completionConditions = "Done only when all of these hold: the change works and the project's tests pass; a real-surface check of the path a user touches has run; temporary files are cleaned up with a cleanup receipt; and LitOpenCode's evidence ledger (.litopencode/litgoal) marks the goal complete.";
|
|
13
|
+
/** The qualifying route for a chat mode or command id, or undefined for every other route. */
|
|
14
|
+
export function nativeGoalRoute(mode, text) {
|
|
15
|
+
if (mode === "lit-loop" || mode === "start-work")
|
|
16
|
+
return mode;
|
|
17
|
+
if (mode === "litwork" || mode === "lit-work")
|
|
18
|
+
return "litwork";
|
|
19
|
+
if (mode !== "lit-task")
|
|
20
|
+
return undefined;
|
|
21
|
+
const first = text.trim().replace(/^\//u, "").split(/\s+/u)[0]?.toLowerCase();
|
|
22
|
+
return first === "litwork" || first === "lit-work" ? "litwork" : undefined;
|
|
23
|
+
}
|
|
24
|
+
export function decideNativeGoal(input) {
|
|
25
|
+
const routed = input.route !== undefined;
|
|
26
|
+
if (routed && !goalRoutes.has(input.route))
|
|
27
|
+
return { kind: "none" };
|
|
28
|
+
if (!input.supported)
|
|
29
|
+
return routed ? { kind: "unavailable" } : { kind: "none" };
|
|
30
|
+
const goal = input.goal;
|
|
31
|
+
if (goal === "unreadable")
|
|
32
|
+
return routed ? { kind: "report-conflict", reason: "the goal state could not be read, so none was set" } : { kind: "none" };
|
|
33
|
+
if (goal === null)
|
|
34
|
+
return routed ? { kind: "create" } : { kind: "none" };
|
|
35
|
+
if (input.ownedGoalID === undefined || goal.id !== input.ownedGoalID)
|
|
36
|
+
return routed ? { kind: "report-user-goal" } : { kind: "none" };
|
|
37
|
+
if (goal.status === "complete") {
|
|
38
|
+
return input.ledgerDone ? { kind: "clear" } : { kind: "report-conflict", reason: "OpenCode says the goal is met but the ledger is still open" };
|
|
39
|
+
}
|
|
40
|
+
if (goal.status === "paused") {
|
|
41
|
+
if (goal.reason === restartPause && input.resumes < maxResumes)
|
|
42
|
+
return routed ? { kind: "resume" } : { kind: "none" };
|
|
43
|
+
return routed ? { kind: "report-conflict", reason: `the goal is paused${goal.reason === undefined ? "" : ` (${oneLine(goal.reason, 60)})`} and needs a person to resume it` } : { kind: "none" };
|
|
44
|
+
}
|
|
45
|
+
return { kind: "keep" };
|
|
46
|
+
}
|
|
47
|
+
function oneLine(value, limit) {
|
|
48
|
+
const flat = value.replace(/\s+/gu, " ").trim();
|
|
49
|
+
return flat.length <= limit ? flat : `${flat.slice(0, limit - 1)}โฆ`;
|
|
50
|
+
}
|
|
51
|
+
/** The text sent to `/goal`: the work, then the conditions OpenCode's reviewer judges it against. */
|
|
52
|
+
export function nativeGoalObjective(work, sessionID) {
|
|
53
|
+
const flat = work.replace(/\s+/gu, " ").trim();
|
|
54
|
+
const subject = flat === "" || containsSecret(flat) ? "the work requested in this session" : flat;
|
|
55
|
+
const conditions = sessionID === undefined || !/^[A-Za-z0-9_-]{1,80}$/u.test(sessionID) ? completionConditions : `${completionConditions} Open that ledger goal with --session-id ${sessionID}.`;
|
|
56
|
+
const room = maxObjective - conditions.length - 2;
|
|
57
|
+
return `${oneLine(subject, room)}\n\n${conditions}`;
|
|
58
|
+
}
|
|
59
|
+
/** The single line announced for an action; an empty string when nothing changed. */
|
|
60
|
+
export function nativeGoalLine(action, detail) {
|
|
61
|
+
const id = detail.goal === undefined ? "" : ` ${detail.goal.id.slice(0, 8)}`;
|
|
62
|
+
switch (action.kind) {
|
|
63
|
+
case "create": return `LitOpenCode goal: created for ${detail.route ?? "this work"}; OpenCode judges it, the ledger must agree`;
|
|
64
|
+
case "resume": return `LitOpenCode goal: resumed${id} after a server restart`;
|
|
65
|
+
case "clear": return `LitOpenCode goal: cleared${id}; OpenCode's verdict and the ledger both say done`;
|
|
66
|
+
case "report-user-goal": return `LitOpenCode goal: left yours alone${detail.goal === undefined ? "" : ` (${detail.goal.status}: ${oneLine(detail.goal.objective, 60)})`}`;
|
|
67
|
+
case "report-conflict": return `LitOpenCode goal: conflict, ${oneLine(action.reason, 120)}; not reporting done`;
|
|
68
|
+
case "unavailable": return "LitOpenCode goal: native-goal-unavailable (this OpenCode has no /goal); the ledger carries the work";
|
|
69
|
+
default: return "";
|
|
70
|
+
}
|
|
71
|
+
}
|
package/dist/v2/server.js
CHANGED
|
@@ -11,6 +11,7 @@ import { applyLitAgents, createSessionModelRoute, enforcePlanningOnly, planLitAg
|
|
|
11
11
|
import { createSessionLookupV2, projectRootFromLocation } from "./context.js";
|
|
12
12
|
import { createV2Commands } from "./commands.js";
|
|
13
13
|
import { createV2Events } from "./events.js";
|
|
14
|
+
import { createNativeGoal } from "./native-goal-runtime.js";
|
|
14
15
|
import { createV2ToolHooks } from "./hooks.js";
|
|
15
16
|
import { createV2SessionHooks } from "./session-hooks.js";
|
|
16
17
|
import { addLitTools, litToolsForProject } from "./tools.js";
|
|
@@ -46,6 +47,7 @@ export async function setupLitOpenCodeServer(context) {
|
|
|
46
47
|
model: context.model,
|
|
47
48
|
onSessionDeleted: (sessionID) => sessionHooks.clear(sessionID)
|
|
48
49
|
});
|
|
50
|
+
const nativeGoal = createNativeGoal({ projectRoot: root, host: { ...(context.rpc === undefined ? {} : { rpc: context.rpc }), session: context.session } });
|
|
49
51
|
const sessionHooks = createV2SessionHooks({
|
|
50
52
|
projectRoot: root,
|
|
51
53
|
config: loaded.config,
|
|
@@ -54,14 +56,22 @@ export async function setupLitOpenCodeServer(context) {
|
|
|
54
56
|
requestOptions: agentPlan.requestOptions,
|
|
55
57
|
autoHandoffRoute: events.route,
|
|
56
58
|
extraSystem: events.systemText,
|
|
57
|
-
acceptCommandMark: commands.acceptMark
|
|
59
|
+
acceptCommandMark: commands.acceptMark,
|
|
60
|
+
nativeGoal
|
|
58
61
|
});
|
|
59
62
|
await context.session.hook("prompt", sessionHooks.prompt);
|
|
60
63
|
await context.session.hook("context", sessionHooks.context);
|
|
61
64
|
await context.session.hook("compaction", sessionHooks.compaction);
|
|
65
|
+
try {
|
|
66
|
+
await context.session.hook("experimental.idle", ({ sessionID }) => nativeGoal.idle(sessionID));
|
|
67
|
+
}
|
|
68
|
+
catch { // no-excuse-ok: catch -- an OpenCode 2 build without the idle hook has no native /goal either; the plugin still starts.
|
|
69
|
+
// Activation-time goal handling still runs; only the finished-goal check is missing.
|
|
70
|
+
}
|
|
62
71
|
const stopEvents = events.start(context.event);
|
|
63
72
|
return async () => {
|
|
64
73
|
await stopEvents();
|
|
74
|
+
await nativeGoal.settled();
|
|
65
75
|
deliverableHedgeGuard.dispose();
|
|
66
76
|
await logger.dispose();
|
|
67
77
|
};
|
|
@@ -2,6 +2,7 @@ import { type ChatActivationMode } from "../activation-routing.ts";
|
|
|
2
2
|
import { type AutoHandoffRouteMode } from "../auto-handoff.ts";
|
|
3
3
|
import type { LitOpenCodeConfig } from "../config.ts";
|
|
4
4
|
import type { AgentRequestOptions } from "./agents.ts";
|
|
5
|
+
import { type NativeGoal } from "./native-goal-runtime.ts";
|
|
5
6
|
export type SystemPartLike = {
|
|
6
7
|
readonly type: "text";
|
|
7
8
|
readonly text: string;
|
|
@@ -45,7 +46,10 @@ export type V2SessionHookOptions = {
|
|
|
45
46
|
readonly acceptCommandMark?: (sessionID: string, marker: unknown) => {
|
|
46
47
|
readonly command: string;
|
|
47
48
|
readonly banner: string;
|
|
49
|
+
readonly work?: string;
|
|
48
50
|
} | undefined;
|
|
51
|
+
/** Sets and manages OpenCode 2's native goal for lit-loop, start-work and litwork. */
|
|
52
|
+
readonly nativeGoal?: NativeGoal;
|
|
49
53
|
};
|
|
50
54
|
/**
|
|
51
55
|
* `opencode2 run "lit fix it"` sends the message wrapped in double quotes (each multi-word argument is
|
package/dist/v2/session-hooks.js
CHANGED
|
@@ -17,6 +17,8 @@ import { startTypedWork, typedStartArgument } from "../start-work-typed.js";
|
|
|
17
17
|
import { createLitGoalOperations } from "../ledger.js";
|
|
18
18
|
import { applyCompactionRuleReset, applyStaticRuleInjection } from "../rules/hooks.js";
|
|
19
19
|
import { isReadOnlyWorkflowFamilyPlan } from "../workflow-families.js";
|
|
20
|
+
import { aliasGoalRoute } from "./native-goal-runtime.js";
|
|
21
|
+
import { nativeGoalRoute } from "./native-goal.js";
|
|
20
22
|
const bundledRulesDir = fileURLToPath(new URL("../../rules/bundled-rules", import.meta.url));
|
|
21
23
|
/**
|
|
22
24
|
* `opencode2 run "lit fix it"` sends the message wrapped in double quotes (each multi-word argument is
|
|
@@ -63,6 +65,12 @@ export function createV2SessionHooks(options) {
|
|
|
63
65
|
}
|
|
64
66
|
return state;
|
|
65
67
|
}
|
|
68
|
+
/** Starts the native-goal step for a qualifying route; it never holds up or fails the prompt. */
|
|
69
|
+
function startNativeGoal(sessionID, route, work) {
|
|
70
|
+
if (route === undefined || options.nativeGoal === undefined)
|
|
71
|
+
return;
|
|
72
|
+
void options.nativeGoal.activate(sessionID, route, work);
|
|
73
|
+
}
|
|
66
74
|
async function lookup(sessionID) {
|
|
67
75
|
try {
|
|
68
76
|
const session = await options.getSession(sessionID);
|
|
@@ -88,6 +96,7 @@ export function createV2SessionHooks(options) {
|
|
|
88
96
|
}
|
|
89
97
|
event.metadata = { ...rest, litopencode: { command: accepted.command, banner: accepted.banner, source: "command" } };
|
|
90
98
|
options.onActivation?.(event.sessionID, accepted.command);
|
|
99
|
+
startNativeGoal(event.sessionID, nativeGoalRoute(accepted.command, ""), accepted.work ?? "");
|
|
91
100
|
return true;
|
|
92
101
|
}
|
|
93
102
|
async function prompt(event) {
|
|
@@ -143,9 +152,13 @@ export function createV2SessionHooks(options) {
|
|
|
143
152
|
options.onActivation?.(event.sessionID, "lit-adhd");
|
|
144
153
|
return;
|
|
145
154
|
}
|
|
155
|
+
const alias = aliasGoalRoute(input);
|
|
156
|
+
if (alias !== undefined)
|
|
157
|
+
startNativeGoal(event.sessionID, alias.route, alias.work);
|
|
146
158
|
const mode = detectChatActivationMode(input, session.agent);
|
|
147
159
|
if (mode === undefined)
|
|
148
160
|
return;
|
|
161
|
+
startNativeGoal(event.sessionID, nativeGoalRoute(mode, input), input);
|
|
149
162
|
const trigger = mode === "lit-handoff" ? "handoff" : mode === "lit-scientific-visualization" ? mode : "lit";
|
|
150
163
|
let activation = promptForChatActivationMode(mode, input);
|
|
151
164
|
if (mode === "lit-adhd")
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { type MissionNotice, type MissionView } from "../tui/mission-model.ts";
|
|
2
|
+
import type { ThreadRow } from "../tui/mission-threads.ts";
|
|
3
|
+
/**
|
|
4
|
+
* Stage, discipline and lane changes between two views. A first view never toasts its history. A lane
|
|
5
|
+
* counts as started when its key is new and working, and as finished when a working lane is gone or done.
|
|
6
|
+
*/
|
|
7
|
+
export declare function v2StageNotices(prev: MissionView | undefined, next: MissionView, before: readonly ThreadRow[], after: readonly ThreadRow[]): readonly MissionNotice[];
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// The toasts OpenCode 2 raises beyond the shared gate events (plan saved, goal set, criterion passed,
|
|
2
|
+
// blockers, complete): the workflow stage moving, a discipline switching, a lane starting or finishing.
|
|
3
|
+
// They are pure: the TUI hands in the previous and next view and thread rows and gets notices back,
|
|
4
|
+
// which leadingNotice folds into one toast with a `+N more` tail.
|
|
5
|
+
import { stepName } from "../tui/mission-model.js";
|
|
6
|
+
const maxNamed = 1;
|
|
7
|
+
function runningStage(view) {
|
|
8
|
+
const step = view.steps.find((candidate) => candidate.state === "now");
|
|
9
|
+
if (step === undefined)
|
|
10
|
+
return undefined;
|
|
11
|
+
const count = view.progress !== undefined && view.progress.total > 0 ? ` ${view.progress.done}/${view.progress.total}` : "";
|
|
12
|
+
return { id: `${view.family}:${step.id}`, label: `${stepName(step.id, view)}${count}` };
|
|
13
|
+
}
|
|
14
|
+
function laneNotice(rows, verb) {
|
|
15
|
+
if (rows.length === 0)
|
|
16
|
+
return undefined;
|
|
17
|
+
const message = rows.length > maxNamed ? `${rows.length} lanes ${verb}` : `lane ${verb} ยท ${rows[0].name}`;
|
|
18
|
+
return { variant: "info", title: "๐ฅ Lane", message };
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Stage, discipline and lane changes between two views. A first view never toasts its history. A lane
|
|
22
|
+
* counts as started when its key is new and working, and as finished when a working lane is gone or done.
|
|
23
|
+
*/
|
|
24
|
+
export function v2StageNotices(prev, next, before, after) {
|
|
25
|
+
if (prev === undefined || !next.active)
|
|
26
|
+
return [];
|
|
27
|
+
const notices = [];
|
|
28
|
+
if (prev.active && prev.discipline !== next.discipline && next.discipline !== "") {
|
|
29
|
+
notices.push({ variant: "info", title: `๐ฅ ${prev.discipline} โ ${next.discipline}`, message: "Discipline switched." });
|
|
30
|
+
}
|
|
31
|
+
else if (prev.active && !next.complete) {
|
|
32
|
+
const was = runningStage(prev);
|
|
33
|
+
const now = runningStage(next);
|
|
34
|
+
if (was !== undefined && now !== undefined && was.id !== now.id)
|
|
35
|
+
notices.push({ variant: "info", title: "๐ฅ Stage", message: now.label });
|
|
36
|
+
}
|
|
37
|
+
const priorKeys = new Set(before.map((row) => row.key));
|
|
38
|
+
const nextByKey = new Map(after.map((row) => [row.key, row]));
|
|
39
|
+
const started = after.filter((row) => row.status === "working" && !priorKeys.has(row.key));
|
|
40
|
+
const finished = before.filter((row) => row.status === "working" && (nextByKey.get(row.key) === undefined || nextByKey.get(row.key)?.status === "done"));
|
|
41
|
+
for (const notice of [laneNotice(started, "started"), laneNotice(finished, "finished")])
|
|
42
|
+
if (notice !== undefined)
|
|
43
|
+
notices.push(notice);
|
|
44
|
+
return notices;
|
|
45
|
+
}
|
package/dist/v2/tui.d.ts
CHANGED
|
@@ -5,6 +5,11 @@ export type TuiRuntime = {
|
|
|
5
5
|
readonly createSignal: <T>(value: T) => readonly [() => T, (value: T) => T];
|
|
6
6
|
readonly createEffect: (fn: () => void) => void;
|
|
7
7
|
readonly createRoot: <T>(fn: (dispose: () => void) => T) => T;
|
|
8
|
+
/** The host's terminal size, reactive; a stand-in runtime may leave it out. */
|
|
9
|
+
readonly useTerminalDimensions?: () => () => {
|
|
10
|
+
readonly width: number;
|
|
11
|
+
readonly height: number;
|
|
12
|
+
};
|
|
8
13
|
};
|
|
9
14
|
type PromptFooterInput = {
|
|
10
15
|
readonly sessionID?: string;
|
|
@@ -19,15 +24,38 @@ export type SlotClaimLike = {
|
|
|
19
24
|
} | {
|
|
20
25
|
readonly append: "prompt.footer.status";
|
|
21
26
|
readonly render: (input: PromptFooterInput) => unknown;
|
|
27
|
+
} | {
|
|
28
|
+
readonly append: "app";
|
|
29
|
+
readonly render: (input: Record<string, never>) => unknown;
|
|
30
|
+
} | {
|
|
31
|
+
readonly append: "home.footer";
|
|
32
|
+
readonly render: (input: Record<string, never>) => unknown;
|
|
22
33
|
};
|
|
23
34
|
export type TuiContextLike = {
|
|
35
|
+
readonly options?: unknown;
|
|
24
36
|
readonly location: {
|
|
25
37
|
readonly directory: string;
|
|
26
38
|
} | undefined;
|
|
27
39
|
readonly theme: unknown;
|
|
28
40
|
readonly themeMode: "dark" | "light";
|
|
41
|
+
readonly attention?: {
|
|
42
|
+
readonly notify: (options: {
|
|
43
|
+
readonly title?: string;
|
|
44
|
+
readonly message: string;
|
|
45
|
+
readonly notification?: boolean;
|
|
46
|
+
readonly sound?: boolean | {
|
|
47
|
+
readonly name?: "done";
|
|
48
|
+
};
|
|
49
|
+
}) => Promise<unknown>;
|
|
50
|
+
};
|
|
29
51
|
readonly ui: {
|
|
30
52
|
readonly slot: (claim: SlotClaimLike) => () => void;
|
|
53
|
+
readonly router?: {
|
|
54
|
+
current(): {
|
|
55
|
+
readonly type: string;
|
|
56
|
+
readonly sessionID?: string;
|
|
57
|
+
};
|
|
58
|
+
};
|
|
31
59
|
readonly toast: {
|
|
32
60
|
readonly show: (options: {
|
|
33
61
|
readonly title?: string;
|
|
@@ -49,11 +77,19 @@ export type TuiContextLike = {
|
|
|
49
77
|
readonly type: string;
|
|
50
78
|
};
|
|
51
79
|
}) => void) => () => void;
|
|
52
|
-
readonly location: {
|
|
80
|
+
readonly location: MissionDataV2["location"] & {
|
|
53
81
|
default(): {
|
|
54
82
|
readonly directory: string;
|
|
55
83
|
};
|
|
56
84
|
};
|
|
85
|
+
readonly session: MissionDataV2["session"] & {
|
|
86
|
+
get?(sessionID: string): {
|
|
87
|
+
readonly parentID?: string;
|
|
88
|
+
readonly time?: {
|
|
89
|
+
readonly created?: number;
|
|
90
|
+
};
|
|
91
|
+
} | undefined;
|
|
92
|
+
};
|
|
57
93
|
};
|
|
58
94
|
};
|
|
59
95
|
/** OpenCode 2 theme tokens under the names the mission palette reads. */
|