opencode-empirical-plan 0.3.0 → 0.3.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/hooks/skill-reminder.ts +66 -0
- package/src/hooks/system-transform.ts +21 -0
- package/src/index.ts +15 -0
- package/src/state/lifecycle-state.ts +20 -1
- package/src/tools/lifecycle-record-execution.ts +12 -10
- package/src/tools/lifecycle-record-reflection.ts +14 -14
- package/src/tools/lifecycle-start.ts +1 -1
package/package.json
CHANGED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
const KEY = Symbol.for("opencode-empirical-plan.skillReminder");
|
|
2
|
+
|
|
3
|
+
interface SkillReminderStore {
|
|
4
|
+
armedSessions: Set<string>;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
if (!(globalThis as Record<symbol, unknown>)[KEY]) {
|
|
8
|
+
(globalThis as Record<symbol, unknown>)[KEY] = {
|
|
9
|
+
armedSessions: new Set<string>(),
|
|
10
|
+
} satisfies SkillReminderStore;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const store = (globalThis as Record<symbol, unknown>)[KEY] as SkillReminderStore;
|
|
14
|
+
|
|
15
|
+
const ENGLISH_KEYWORD_REGEX = /\b(plan|planning)\b/i;
|
|
16
|
+
const CHINESE_KEYWORDS = ["计划", "规划", "制定计划", "做计划"];
|
|
17
|
+
|
|
18
|
+
interface TextPartLike {
|
|
19
|
+
type?: string;
|
|
20
|
+
text?: unknown;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function containsPlanningKeywords(text: string): boolean {
|
|
24
|
+
if (ENGLISH_KEYWORD_REGEX.test(text)) {
|
|
25
|
+
return true;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
return CHINESE_KEYWORDS.some((keyword) => text.includes(keyword));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function extractText(parts: unknown[]): string {
|
|
32
|
+
const texts: string[] = [];
|
|
33
|
+
for (const part of parts) {
|
|
34
|
+
const candidate = part as TextPartLike;
|
|
35
|
+
if (candidate.type === "text" && typeof candidate.text === "string") {
|
|
36
|
+
texts.push(candidate.text);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return texts.join("\n");
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export async function handleSkillReminderChatMessage(
|
|
43
|
+
sessionID: string,
|
|
44
|
+
parts: unknown[],
|
|
45
|
+
): Promise<void> {
|
|
46
|
+
try {
|
|
47
|
+
const text = extractText(parts);
|
|
48
|
+
if (!text) return;
|
|
49
|
+
if (!containsPlanningKeywords(text)) return;
|
|
50
|
+
store.armedSessions.add(sessionID);
|
|
51
|
+
} catch (err) {
|
|
52
|
+
console.error("[empirical-plan] skill-reminder chat.message error:", err);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function consumeSkillReminderArmed(sessionID: string): boolean {
|
|
57
|
+
if (!store.armedSessions.has(sessionID)) {
|
|
58
|
+
return false;
|
|
59
|
+
}
|
|
60
|
+
store.armedSessions.delete(sessionID);
|
|
61
|
+
return true;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function cleanupSkillReminderSession(sessionID: string): void {
|
|
65
|
+
store.armedSessions.delete(sessionID);
|
|
66
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { consumeSkillReminderArmed } from "./skill-reminder.ts";
|
|
2
|
+
|
|
3
|
+
const SKILL_REMINDER_TEXT = `<skill-reminder>
|
|
4
|
+
User message contains planning-related keywords. BEFORE responding:
|
|
5
|
+
1. Call skill(name="empirical-plan") to load the full planning workflow
|
|
6
|
+
2. Only skip if: this is a clarification question (not a task), or empirical-plan is already loaded this session
|
|
7
|
+
</skill-reminder>`;
|
|
8
|
+
|
|
9
|
+
export async function handleSystemTransform(
|
|
10
|
+
sessionID: string | undefined,
|
|
11
|
+
system: string[],
|
|
12
|
+
): Promise<void> {
|
|
13
|
+
try {
|
|
14
|
+
if (!sessionID) return;
|
|
15
|
+
const armed = consumeSkillReminderArmed(sessionID);
|
|
16
|
+
if (!armed) return;
|
|
17
|
+
system.push(SKILL_REMINDER_TEXT);
|
|
18
|
+
} catch (err) {
|
|
19
|
+
console.error("[empirical-plan] system-transform error:", err);
|
|
20
|
+
}
|
|
21
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -6,6 +6,8 @@ import { handleToolAfter } from "./hooks/tool-after.ts";
|
|
|
6
6
|
import { recordCallStart } from "./hooks/tool-before.ts";
|
|
7
7
|
import { handleSessionIdle } from "./hooks/session-idle.ts";
|
|
8
8
|
import { cleanup, cleanupChild } from "./state/active-lifecycles.ts";
|
|
9
|
+
import { handleSkillReminderChatMessage, cleanupSkillReminderSession } from "./hooks/skill-reminder.ts";
|
|
10
|
+
import { handleSystemTransform } from "./hooks/system-transform.ts";
|
|
9
11
|
|
|
10
12
|
export const EmpiricalPlanPlugin: Plugin = async ({ client }) => {
|
|
11
13
|
return {
|
|
@@ -15,6 +17,18 @@ export const EmpiricalPlanPlugin: Plugin = async ({ client }) => {
|
|
|
15
17
|
lifecycle_record_reflection: lifecycleRecordReflectionTool,
|
|
16
18
|
},
|
|
17
19
|
|
|
20
|
+
"chat.message": async (input, output) => {
|
|
21
|
+
try {
|
|
22
|
+
await handleSkillReminderChatMessage(input.sessionID, output.parts);
|
|
23
|
+
} catch (err) {
|
|
24
|
+
console.error("[empirical-plan] chat.message error:", err);
|
|
25
|
+
}
|
|
26
|
+
},
|
|
27
|
+
|
|
28
|
+
"experimental.chat.system.transform": async (input, output) => {
|
|
29
|
+
await handleSystemTransform(input.sessionID, output.system);
|
|
30
|
+
},
|
|
31
|
+
|
|
18
32
|
"tool.execute.before": async (input) => {
|
|
19
33
|
try {
|
|
20
34
|
recordCallStart(input.callID);
|
|
@@ -49,6 +63,7 @@ export const EmpiricalPlanPlugin: Plugin = async ({ client }) => {
|
|
|
49
63
|
if (sessionID) {
|
|
50
64
|
cleanup(sessionID);
|
|
51
65
|
cleanupChild(sessionID);
|
|
66
|
+
cleanupSkillReminderSession(sessionID);
|
|
52
67
|
}
|
|
53
68
|
}
|
|
54
69
|
} catch (err) {
|
|
@@ -1,6 +1,25 @@
|
|
|
1
1
|
import * as fs from "node:fs/promises";
|
|
2
2
|
import * as path from "node:path";
|
|
3
|
-
import { type LifecycleState, STATE_FILENAME, validateLifecycleState } from "../schema.ts";
|
|
3
|
+
import { type LifecyclePhase, type LifecycleState, STATE_FILENAME, validateLifecycleState } from "../schema.ts";
|
|
4
|
+
|
|
5
|
+
const PHASE_ORDER: LifecyclePhase[] = ["plan", "execute", "reflect", "done"];
|
|
6
|
+
|
|
7
|
+
export async function assertPhaseAtLeast(
|
|
8
|
+
runDir: string,
|
|
9
|
+
required: LifecyclePhase,
|
|
10
|
+
): Promise<{ ok: true } | { ok: false; reason: string }> {
|
|
11
|
+
const state = await readLifecycleState(runDir);
|
|
12
|
+
if (!state) return { ok: true };
|
|
13
|
+
const current = PHASE_ORDER.indexOf(state.phase);
|
|
14
|
+
const needed = PHASE_ORDER.indexOf(required);
|
|
15
|
+
if (current < needed) {
|
|
16
|
+
return {
|
|
17
|
+
ok: false,
|
|
18
|
+
reason: `lifecycle phase is "${state.phase}", expected at least "${required}"`,
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
return { ok: true };
|
|
22
|
+
}
|
|
4
23
|
|
|
5
24
|
export async function readLifecycleState(runDir: string): Promise<LifecycleState | null> {
|
|
6
25
|
const stateFile = path.join(runDir, STATE_FILENAME);
|
|
@@ -3,6 +3,7 @@ import * as fs from "node:fs/promises";
|
|
|
3
3
|
import * as path from "node:path";
|
|
4
4
|
import { atomicWrite } from "../state/frontmatter.ts";
|
|
5
5
|
import { ensureExecutionMd } from "../hooks/tool-after.ts";
|
|
6
|
+
import { assertPhaseAtLeast } from "../state/lifecycle-state.ts";
|
|
6
7
|
|
|
7
8
|
const SUMMARY_MARKER = "<!-- EXECUTION:SUMMARY -->";
|
|
8
9
|
|
|
@@ -25,8 +26,9 @@ export const lifecycleRecordExecutionTool = tool({
|
|
|
25
26
|
execution.md is the dedicated execution record file — separate from plan.md (which stays read-only after Phase 1-3).
|
|
26
27
|
|
|
27
28
|
Guards (hard-block):
|
|
28
|
-
-
|
|
29
|
-
-
|
|
29
|
+
- lifecycle-state.json phase must be >= "execute" (fail-open if state file missing)
|
|
30
|
+
- plan.md must exist
|
|
31
|
+
- execution.md must exist (auto-created if missing)
|
|
30
32
|
- <!-- EXECUTION:SUMMARY --> must NOT already exist in execution.md (idempotent guard)
|
|
31
33
|
|
|
32
34
|
On success: appends the execution_summary section into execution.md and updates its Phase frontmatter field to "summarized".
|
|
@@ -47,33 +49,33 @@ The execution_summary should compare each Plan Step against what actually happen
|
|
|
47
49
|
const planFile = path.join(runDir, "plan.md");
|
|
48
50
|
const executionFile = path.join(runDir, "execution.md");
|
|
49
51
|
|
|
50
|
-
const result = await doRecord(planFile, executionFile, args.execution_summary);
|
|
52
|
+
const result = await doRecord(runDir, planFile, executionFile, args.execution_summary);
|
|
51
53
|
return JSON.stringify(result);
|
|
52
54
|
},
|
|
53
55
|
});
|
|
54
56
|
|
|
55
57
|
async function doRecord(
|
|
58
|
+
runDir: string,
|
|
56
59
|
planFile: string,
|
|
57
60
|
executionFile: string,
|
|
58
61
|
executionSummary: string,
|
|
59
62
|
): Promise<RecordResult> {
|
|
60
|
-
|
|
61
|
-
if (!
|
|
63
|
+
const phaseCheck = await assertPhaseAtLeast(runDir, "execute");
|
|
64
|
+
if (!phaseCheck.ok) {
|
|
62
65
|
return {
|
|
63
66
|
success: false,
|
|
64
67
|
code: "PHASE_NOT_READY",
|
|
65
|
-
reason:
|
|
68
|
+
reason: phaseCheck.reason,
|
|
66
69
|
next_action: "Complete the PLAN phase first — run empirical-plan skill to produce plan.md",
|
|
67
70
|
};
|
|
68
71
|
}
|
|
69
72
|
|
|
70
|
-
|
|
71
|
-
if (!planContent.includes("## Plan Steps")) {
|
|
73
|
+
if (!(await fileExists(planFile))) {
|
|
72
74
|
return {
|
|
73
75
|
success: false,
|
|
74
76
|
code: "PHASE_NOT_READY",
|
|
75
|
-
reason:
|
|
76
|
-
next_action: "Complete the PLAN phase
|
|
77
|
+
reason: `plan.md not found at ${planFile}`,
|
|
78
|
+
next_action: "Complete the PLAN phase first — run empirical-plan skill to produce plan.md",
|
|
77
79
|
};
|
|
78
80
|
}
|
|
79
81
|
|
|
@@ -2,8 +2,8 @@ import { tool } from "@opencode-ai/plugin";
|
|
|
2
2
|
import * as fs from "node:fs/promises";
|
|
3
3
|
import * as path from "node:path";
|
|
4
4
|
import { applyFrontmatterLifecycle, atomicWrite } from "../state/frontmatter.ts";
|
|
5
|
+
import { assertPhaseAtLeast } from "../state/lifecycle-state.ts";
|
|
5
6
|
|
|
6
|
-
const EXECUTION_SUMMARY_MARKER = "<!-- EXECUTION:SUMMARY -->";
|
|
7
7
|
const REFLECTION_MARKER = "<!-- LIFECYCLE:REFLECTION -->";
|
|
8
8
|
|
|
9
9
|
async function fileExists(p: string): Promise<boolean> {
|
|
@@ -19,7 +19,8 @@ export const lifecycleRecordReflectionTool = tool({
|
|
|
19
19
|
description: `Record Phase 5 (Reflection & Optimization) into plan.md.
|
|
20
20
|
|
|
21
21
|
Guards (hard-block):
|
|
22
|
-
-
|
|
22
|
+
- lifecycle-state.json phase must be >= "reflect" (fail-open if state file missing)
|
|
23
|
+
- execution.md must exist
|
|
23
24
|
- reflect.md must exist in run_dir (REFLECT phase complete)
|
|
24
25
|
- <!-- LIFECYCLE:REFLECTION --> must NOT already exist in plan.md (idempotent guard)
|
|
25
26
|
|
|
@@ -40,7 +41,16 @@ The reflection_record should contain: execution quality assessment, plan design
|
|
|
40
41
|
const executionFile = path.join(runDir, "execution.md");
|
|
41
42
|
const reflectFile = path.join(runDir, "reflect.md");
|
|
42
43
|
|
|
43
|
-
|
|
44
|
+
const phaseCheck = await assertPhaseAtLeast(runDir, "reflect");
|
|
45
|
+
if (!phaseCheck.ok) {
|
|
46
|
+
return JSON.stringify({
|
|
47
|
+
success: false,
|
|
48
|
+
code: "PHASE_NOT_READY",
|
|
49
|
+
reason: phaseCheck.reason,
|
|
50
|
+
next_action: "Complete EXECUTE phase and call lifecycle_record_execution first",
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
|
|
44
54
|
if (!(await fileExists(planFile))) {
|
|
45
55
|
return JSON.stringify({
|
|
46
56
|
success: false,
|
|
@@ -50,7 +60,7 @@ The reflection_record should contain: execution quality assessment, plan design
|
|
|
50
60
|
});
|
|
51
61
|
}
|
|
52
62
|
|
|
53
|
-
// Guard 2: execution.md must exist
|
|
63
|
+
// Guard 2: execution.md must exist
|
|
54
64
|
if (!(await fileExists(executionFile))) {
|
|
55
65
|
return JSON.stringify({
|
|
56
66
|
success: false,
|
|
@@ -60,16 +70,6 @@ The reflection_record should contain: execution quality assessment, plan design
|
|
|
60
70
|
});
|
|
61
71
|
}
|
|
62
72
|
|
|
63
|
-
const executionContent = await fs.readFile(executionFile, "utf-8");
|
|
64
|
-
if (!executionContent.includes(EXECUTION_SUMMARY_MARKER)) {
|
|
65
|
-
return JSON.stringify({
|
|
66
|
-
success: false,
|
|
67
|
-
code: "EXECUTION_NOT_RECORDED",
|
|
68
|
-
reason: "Phase 4 summary (EXECUTION:SUMMARY) not yet recorded in execution.md",
|
|
69
|
-
next_action: "Call lifecycle_record_execution first to record Phase 4",
|
|
70
|
-
});
|
|
71
|
-
}
|
|
72
|
-
|
|
73
73
|
// Guard 3: reflect.md must exist
|
|
74
74
|
if (!(await fileExists(reflectFile))) {
|
|
75
75
|
return JSON.stringify({
|
|
@@ -58,7 +58,7 @@ advances lifecycle-state.json to the next phase (plan→execute→reflect→done
|
|
|
58
58
|
next_action: "Wait for the current lifecycle to complete before starting a new one.",
|
|
59
59
|
});
|
|
60
60
|
}
|
|
61
|
-
|
|
61
|
+
|
|
62
62
|
}
|
|
63
63
|
|
|
64
64
|
await fs.mkdir(runDir, { recursive: true });
|