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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-empirical-plan",
3
- "version": "0.3.0",
3
+ "version": "0.3.2",
4
4
  "description": "OpenCode lifecycle plugin for empirical plan orchestration.",
5
5
  "author": "chuck",
6
6
  "license": "MIT",
@@ -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
- - plan.md must exist and contain "## Plan Steps" (Phase 1-3 complete)
29
- - execution.md must already exist (initialized by the tool.execute.after hook when EXECUTE phase began)
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
- // Guard 1: plan.md must exist with Phase 1-3 complete
61
- if (!(await fileExists(planFile))) {
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: `plan.md not found at ${planFile}`,
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
- const planContent = await fs.readFile(planFile, "utf-8");
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: "plan.md does not contain '## Plan Steps' — Phase 1-3 incomplete",
76
- next_action: "Complete the PLAN phase using the empirical-plan skill",
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
- - execution.md must exist and contain <!-- EXECUTION:SUMMARY --> (Phase 4 already recorded)
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
- // Guard 1: plan.md must exist
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 and contain EXECUTION:SUMMARY
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
- console.log(`[empirical-plan] lifecycle_start: stale map entry for session ${ctx.sessionID} (phase=${existingState?.phase ?? "missing"}), proceeding with new lifecycle`);
61
+
62
62
  }
63
63
 
64
64
  await fs.mkdir(runDir, { recursive: true });