opencode-empirical-plan 0.2.0
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 +27 -0
- package/src/hooks/session-idle.ts +15 -0
- package/src/hooks/tool-after.ts +176 -0
- package/src/hooks/tool-before.ts +11 -0
- package/src/index.ts +61 -0
- package/src/schema.ts +73 -0
- package/src/state/active-lifecycles.ts +44 -0
- package/src/state/frontmatter.ts +23 -0
- package/src/state/lifecycle-state.ts +21 -0
- package/src/tools/lifecycle-record-execution.ts +119 -0
- package/src/tools/lifecycle-record-reflection.ts +100 -0
- package/src/tools/lifecycle-start.ts +94 -0
package/package.json
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "opencode-empirical-plan",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "OpenCode lifecycle plugin for empirical plan orchestration.",
|
|
5
|
+
"author": "chuck",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"type": "module",
|
|
8
|
+
"main": "src/index.ts",
|
|
9
|
+
"files": [
|
|
10
|
+
"src"
|
|
11
|
+
],
|
|
12
|
+
"scripts": {
|
|
13
|
+
"test": "bun test",
|
|
14
|
+
"typecheck": "tsc --noEmit"
|
|
15
|
+
},
|
|
16
|
+
"dependencies": {
|
|
17
|
+
"@opencode-ai/plugin": "^1.2.4"
|
|
18
|
+
},
|
|
19
|
+
"devDependencies": {
|
|
20
|
+
"@tsconfig/bun": "^1.0.10",
|
|
21
|
+
"@types/bun": "^1.3.3",
|
|
22
|
+
"typescript": "^5.9.3"
|
|
23
|
+
},
|
|
24
|
+
"engines": {
|
|
25
|
+
"bun": ">=1.0.0"
|
|
26
|
+
}
|
|
27
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { PluginInput } from "@opencode-ai/plugin";
|
|
2
|
+
|
|
3
|
+
interface EventProperties {
|
|
4
|
+
sessionID?: string;
|
|
5
|
+
info?: { id?: string };
|
|
6
|
+
[key: string]: unknown;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export async function handleSessionIdle(
|
|
10
|
+
_props: EventProperties,
|
|
11
|
+
_client: PluginInput["client"],
|
|
12
|
+
): Promise<void> {
|
|
13
|
+
// Phase advancement happens in tool.execute.after on task() completion.
|
|
14
|
+
// Standalone coordinator self-directs after task() returns.
|
|
15
|
+
}
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import * as fs from "node:fs/promises";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import { lookupParent, lookupRunDir, registerChild } from "../state/active-lifecycles.ts";
|
|
4
|
+
import { readLifecycleState, writeLifecycleState } from "../state/lifecycle-state.ts";
|
|
5
|
+
import { atomicWrite } from "../state/frontmatter.ts";
|
|
6
|
+
import { consumeCallStart } from "./tool-before.ts";
|
|
7
|
+
import type { LifecyclePhase } from "../schema.ts";
|
|
8
|
+
|
|
9
|
+
interface ToolAfterInput {
|
|
10
|
+
tool: string;
|
|
11
|
+
sessionID: string;
|
|
12
|
+
callID: string;
|
|
13
|
+
args?: unknown;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
interface ToolAfterOutput {
|
|
17
|
+
output?: unknown;
|
|
18
|
+
metadata?: Record<string, unknown>;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const TOOL_TRACE_HEADER = `## Tool Trace
|
|
22
|
+
| # | Tool | Args Summary | Output Summary | Duration |
|
|
23
|
+
|---|------|-------------|----------------|----------|`;
|
|
24
|
+
|
|
25
|
+
const EXECUTION_MD_INIT_MARKER = "<!-- EXECUTION:TRACE -->";
|
|
26
|
+
|
|
27
|
+
const PHASE_TRANSITIONS: Record<string, LifecyclePhase> = {
|
|
28
|
+
plan: "execute",
|
|
29
|
+
execute: "reflect",
|
|
30
|
+
reflect: "done",
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
// Per-file serialization: one in-flight write per execution.md path at a time
|
|
34
|
+
const writeQueue = new Map<string, Promise<void>>();
|
|
35
|
+
|
|
36
|
+
function serialized(filePath: string, fn: () => Promise<void>): Promise<void> {
|
|
37
|
+
const prev = writeQueue.get(filePath) ?? Promise.resolve();
|
|
38
|
+
const next = prev.then(fn, fn); // run even if previous failed
|
|
39
|
+
writeQueue.set(filePath, next);
|
|
40
|
+
next.finally(() => {
|
|
41
|
+
if (writeQueue.get(filePath) === next) writeQueue.delete(filePath);
|
|
42
|
+
});
|
|
43
|
+
return next;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function truncate(s: string, max: number): string {
|
|
47
|
+
const flat = s.replace(/\s+/g, " ").trim();
|
|
48
|
+
return flat.length > max ? flat.slice(0, max) + "…" : flat;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function argsToString(args: unknown): string {
|
|
52
|
+
if (!args) return "";
|
|
53
|
+
try {
|
|
54
|
+
return truncate(JSON.stringify(args), 120);
|
|
55
|
+
} catch {
|
|
56
|
+
return String(args).slice(0, 120);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function outputToString(output: unknown): string {
|
|
61
|
+
if (output === undefined || output === null) return "";
|
|
62
|
+
const s = typeof output === "string" ? output : JSON.stringify(output);
|
|
63
|
+
return truncate(s, 200);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async function fileExists(p: string): Promise<boolean> {
|
|
67
|
+
try {
|
|
68
|
+
await fs.access(p);
|
|
69
|
+
return true;
|
|
70
|
+
} catch {
|
|
71
|
+
return false;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export async function ensureExecutionMd(executionFile: string, planFile: string): Promise<void> {
|
|
76
|
+
if (await fileExists(executionFile)) return;
|
|
77
|
+
|
|
78
|
+
let planTitle = "Empirical Plan";
|
|
79
|
+
try {
|
|
80
|
+
const planContent = await fs.readFile(planFile, "utf-8");
|
|
81
|
+
const m = /^#\s+(.+)$/m.exec(planContent);
|
|
82
|
+
if (m) planTitle = m[1]!.trim();
|
|
83
|
+
} catch {}
|
|
84
|
+
|
|
85
|
+
const now = new Date().toISOString();
|
|
86
|
+
const skeleton = `---
|
|
87
|
+
Plan: ./plan.md
|
|
88
|
+
Phase: executing
|
|
89
|
+
StartedAt: ${now}
|
|
90
|
+
---
|
|
91
|
+
|
|
92
|
+
# Execution — ${planTitle}
|
|
93
|
+
|
|
94
|
+
${EXECUTION_MD_INIT_MARKER}
|
|
95
|
+
${TOOL_TRACE_HEADER}
|
|
96
|
+
`;
|
|
97
|
+
await atomicWrite(executionFile, skeleton);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async function appendToolTrace(
|
|
101
|
+
executionFile: string,
|
|
102
|
+
rowNum: number,
|
|
103
|
+
toolName: string,
|
|
104
|
+
args: unknown,
|
|
105
|
+
output: unknown,
|
|
106
|
+
durationMs: number | undefined,
|
|
107
|
+
): Promise<void> {
|
|
108
|
+
const content = await fs.readFile(executionFile, "utf-8");
|
|
109
|
+
const duration = durationMs !== undefined ? `${durationMs}ms` : "—";
|
|
110
|
+
const row = `| ${rowNum} | \`${toolName}\` | ${argsToString(args)} | ${outputToString(output)} | ${duration} |`;
|
|
111
|
+
const updated = content.trimEnd() + "\n" + row + "\n";
|
|
112
|
+
await atomicWrite(executionFile, updated);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async function getNextRowNum(executionFile: string): Promise<number> {
|
|
116
|
+
try {
|
|
117
|
+
const content = await fs.readFile(executionFile, "utf-8");
|
|
118
|
+
const rows = content.split("\n").filter((l) => /^\|\s*\d+\s*\|/.test(l));
|
|
119
|
+
return rows.length + 1;
|
|
120
|
+
} catch {
|
|
121
|
+
return 1;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export async function handleToolAfter(
|
|
126
|
+
input: ToolAfterInput,
|
|
127
|
+
output: ToolAfterOutput,
|
|
128
|
+
): Promise<void> {
|
|
129
|
+
const startTime = consumeCallStart(input.callID);
|
|
130
|
+
const durationMs = startTime !== undefined ? Date.now() - startTime : undefined;
|
|
131
|
+
|
|
132
|
+
// --- Child session registration + phase advancement (task() tool) ---
|
|
133
|
+
if (input.tool === "task") {
|
|
134
|
+
const childSessionId = output.metadata?.["sessionId"];
|
|
135
|
+
if (typeof childSessionId === "string" && childSessionId) {
|
|
136
|
+
const runDir = lookupRunDir(input.sessionID);
|
|
137
|
+
if (runDir) {
|
|
138
|
+
const state = await readLifecycleState(runDir);
|
|
139
|
+
if (state && state.phase !== "done") {
|
|
140
|
+
if (!state.childSessionId || state.childSessionId === childSessionId) {
|
|
141
|
+
registerChild(input.sessionID, childSessionId);
|
|
142
|
+
const nextPhase: LifecyclePhase = PHASE_TRANSITIONS[state.phase] ?? "done";
|
|
143
|
+
await writeLifecycleState(runDir, {
|
|
144
|
+
...state,
|
|
145
|
+
phase: nextPhase,
|
|
146
|
+
seq: state.seq + 1,
|
|
147
|
+
childSessionId: undefined,
|
|
148
|
+
updatedAt: new Date().toISOString(),
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// --- Tool Trace append for EXECUTE-phase child sessions ---
|
|
157
|
+
const parentSessionId = lookupParent(input.sessionID);
|
|
158
|
+
if (!parentSessionId) return;
|
|
159
|
+
|
|
160
|
+
const runDir = lookupRunDir(parentSessionId);
|
|
161
|
+
if (!runDir) return;
|
|
162
|
+
|
|
163
|
+
const state = await readLifecycleState(runDir);
|
|
164
|
+
if (!state || state.phase !== "execute") return;
|
|
165
|
+
|
|
166
|
+
if (state.childSessionId && state.childSessionId !== input.sessionID) return;
|
|
167
|
+
|
|
168
|
+
const executionFile = path.join(runDir, "execution.md");
|
|
169
|
+
const planFile = path.join(runDir, "plan.md");
|
|
170
|
+
|
|
171
|
+
await serialized(executionFile, async () => {
|
|
172
|
+
await ensureExecutionMd(executionFile, planFile);
|
|
173
|
+
const rowNum = await getNextRowNum(executionFile);
|
|
174
|
+
await appendToolTrace(executionFile, rowNum, input.tool, input.args, output.output, durationMs);
|
|
175
|
+
});
|
|
176
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
const callStartTimes = new Map<string, number>();
|
|
2
|
+
|
|
3
|
+
export function recordCallStart(callID: string): void {
|
|
4
|
+
callStartTimes.set(callID, Date.now());
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function consumeCallStart(callID: string): number | undefined {
|
|
8
|
+
const t = callStartTimes.get(callID);
|
|
9
|
+
callStartTimes.delete(callID);
|
|
10
|
+
return t;
|
|
11
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import type { Plugin } from "@opencode-ai/plugin";
|
|
2
|
+
import { lifecycleStartTool } from "./tools/lifecycle-start.ts";
|
|
3
|
+
import { lifecycleRecordExecutionTool } from "./tools/lifecycle-record-execution.ts";
|
|
4
|
+
import { lifecycleRecordReflectionTool } from "./tools/lifecycle-record-reflection.ts";
|
|
5
|
+
import { handleToolAfter } from "./hooks/tool-after.ts";
|
|
6
|
+
import { recordCallStart } from "./hooks/tool-before.ts";
|
|
7
|
+
import { handleSessionIdle } from "./hooks/session-idle.ts";
|
|
8
|
+
import { cleanup, cleanupChild } from "./state/active-lifecycles.ts";
|
|
9
|
+
|
|
10
|
+
export const EmpiricalPlanPlugin: Plugin = async ({ client }) => {
|
|
11
|
+
return {
|
|
12
|
+
tool: {
|
|
13
|
+
lifecycle_start: lifecycleStartTool,
|
|
14
|
+
lifecycle_record_execution: lifecycleRecordExecutionTool,
|
|
15
|
+
lifecycle_record_reflection: lifecycleRecordReflectionTool,
|
|
16
|
+
},
|
|
17
|
+
|
|
18
|
+
"tool.execute.before": async (input) => {
|
|
19
|
+
try {
|
|
20
|
+
recordCallStart(input.callID);
|
|
21
|
+
} catch (err) {
|
|
22
|
+
console.error("[empirical-plan] tool.execute.before error:", err);
|
|
23
|
+
}
|
|
24
|
+
},
|
|
25
|
+
|
|
26
|
+
"tool.execute.after": async (input, output) => {
|
|
27
|
+
try {
|
|
28
|
+
await handleToolAfter(
|
|
29
|
+
{ tool: input.tool, sessionID: input.sessionID, callID: input.callID, args: input.args },
|
|
30
|
+
{ output: output?.output, metadata: output?.metadata as Record<string, unknown> | undefined },
|
|
31
|
+
);
|
|
32
|
+
} catch (err) {
|
|
33
|
+
console.error("[empirical-plan] tool.execute.after error:", err);
|
|
34
|
+
}
|
|
35
|
+
},
|
|
36
|
+
|
|
37
|
+
event: async ({ event }) => {
|
|
38
|
+
try {
|
|
39
|
+
const props = (event as { properties?: Record<string, unknown> }).properties ?? {};
|
|
40
|
+
|
|
41
|
+
if (event.type === "session.idle") {
|
|
42
|
+
await handleSessionIdle(props, client);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (event.type === "session.deleted") {
|
|
46
|
+
const sessionID =
|
|
47
|
+
(props.info as { id?: string } | undefined)?.id ??
|
|
48
|
+
(props.id as string | undefined);
|
|
49
|
+
if (sessionID) {
|
|
50
|
+
cleanup(sessionID);
|
|
51
|
+
cleanupChild(sessionID);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
} catch (err) {
|
|
55
|
+
console.error("[empirical-plan] event handler error:", err);
|
|
56
|
+
}
|
|
57
|
+
},
|
|
58
|
+
};
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
export default EmpiricalPlanPlugin;
|
package/src/schema.ts
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lifecycle state schema for opencode-empirical-plan plugin.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
export type LifecyclePhase = "plan" | "execute" | "reflect" | "done";
|
|
6
|
+
export type LifecycleMode = "standalone" | "heartbeat";
|
|
7
|
+
|
|
8
|
+
export interface LifecycleState {
|
|
9
|
+
/** Unique lifecycle ID, format "lc-YYYYMMDD-HHmmss" */
|
|
10
|
+
lifecycleId: string;
|
|
11
|
+
/** Session ID that triggered lifecycle_start */
|
|
12
|
+
parentSessionId: string;
|
|
13
|
+
/** Current lifecycle phase */
|
|
14
|
+
phase: LifecyclePhase;
|
|
15
|
+
/** Monotonically increasing idempotency guard */
|
|
16
|
+
seq: number;
|
|
17
|
+
/** Current phase's child session ID (if known) */
|
|
18
|
+
childSessionId?: string;
|
|
19
|
+
/** Directory containing plan.md / envelope.json / reflect.md */
|
|
20
|
+
runDir: string;
|
|
21
|
+
/** Absolute path to plan.md (populated after lifecycle_start) */
|
|
22
|
+
planFile?: string;
|
|
23
|
+
/** Whether this lifecycle is driven by standalone plugin or Heartbeat runner */
|
|
24
|
+
mode: LifecycleMode;
|
|
25
|
+
createdAt: string;
|
|
26
|
+
updatedAt: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export const STATE_FILENAME = "lifecycle-state.json";
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Validate that an object is a well-formed LifecycleState.
|
|
33
|
+
* Throws if required fields are missing or invalid.
|
|
34
|
+
*/
|
|
35
|
+
export function validateLifecycleState(obj: unknown): LifecycleState {
|
|
36
|
+
if (!obj || typeof obj !== "object") {
|
|
37
|
+
throw new Error("lifecycle-state.json: root must be an object");
|
|
38
|
+
}
|
|
39
|
+
const s = obj as Record<string, unknown>;
|
|
40
|
+
|
|
41
|
+
const required = [
|
|
42
|
+
"lifecycleId",
|
|
43
|
+
"parentSessionId",
|
|
44
|
+
"phase",
|
|
45
|
+
"seq",
|
|
46
|
+
"runDir",
|
|
47
|
+
"mode",
|
|
48
|
+
"createdAt",
|
|
49
|
+
"updatedAt",
|
|
50
|
+
] as const;
|
|
51
|
+
|
|
52
|
+
for (const field of required) {
|
|
53
|
+
if (!(field in s)) {
|
|
54
|
+
throw new Error(`lifecycle-state.json: missing required field "${field}"`);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const validPhases: LifecyclePhase[] = ["plan", "execute", "reflect", "done"];
|
|
59
|
+
if (!validPhases.includes(s["phase"] as LifecyclePhase)) {
|
|
60
|
+
throw new Error(`lifecycle-state.json: invalid phase "${s["phase"]}"`);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const validModes: LifecycleMode[] = ["standalone", "heartbeat"];
|
|
64
|
+
if (!validModes.includes(s["mode"] as LifecycleMode)) {
|
|
65
|
+
throw new Error(`lifecycle-state.json: invalid mode "${s["mode"]}"`);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (typeof s["seq"] !== "number" || !Number.isInteger(s["seq"]) || s["seq"] < 0) {
|
|
69
|
+
throw new Error(`lifecycle-state.json: "seq" must be a non-negative integer`);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return s as unknown as LifecycleState;
|
|
73
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
const KEY = Symbol.for("opencode-empirical-plan.activeLifecycles");
|
|
2
|
+
|
|
3
|
+
interface ActiveStore {
|
|
4
|
+
parentToRunDir: Map<string, string>;
|
|
5
|
+
childToParent: Map<string, string>;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
if (!(globalThis as Record<symbol, unknown>)[KEY]) {
|
|
9
|
+
(globalThis as Record<symbol, unknown>)[KEY] = {
|
|
10
|
+
parentToRunDir: new Map<string, string>(),
|
|
11
|
+
childToParent: new Map<string, string>(),
|
|
12
|
+
} satisfies ActiveStore;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export const activeLifecycles = (globalThis as Record<symbol, unknown>)[KEY] as ActiveStore;
|
|
16
|
+
|
|
17
|
+
export function registerParent(parentSessionId: string, runDir: string): void {
|
|
18
|
+
activeLifecycles.parentToRunDir.set(parentSessionId, runDir);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function registerChild(parentSessionId: string, childSessionId: string): void {
|
|
22
|
+
activeLifecycles.childToParent.set(childSessionId, parentSessionId);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function lookupRunDir(parentSessionId: string): string | undefined {
|
|
26
|
+
return activeLifecycles.parentToRunDir.get(parentSessionId);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function lookupParent(childSessionId: string): string | undefined {
|
|
30
|
+
return activeLifecycles.childToParent.get(childSessionId);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function cleanup(parentSessionId: string): void {
|
|
34
|
+
activeLifecycles.parentToRunDir.delete(parentSessionId);
|
|
35
|
+
for (const [child, parent] of activeLifecycles.childToParent.entries()) {
|
|
36
|
+
if (parent === parentSessionId) {
|
|
37
|
+
activeLifecycles.childToParent.delete(child);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function cleanupChild(childSessionId: string): void {
|
|
43
|
+
activeLifecycles.childToParent.delete(childSessionId);
|
|
44
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import * as fs from "node:fs/promises";
|
|
2
|
+
|
|
3
|
+
const FRONTMATTER_RE = /^---\r?\n([\s\S]*?)\r?\n---/;
|
|
4
|
+
const LIFECYCLE_FIELD_RE = /^Lifecycle:\s*.+$/m;
|
|
5
|
+
|
|
6
|
+
export function applyFrontmatterLifecycle(content: string, value: string): string {
|
|
7
|
+
const fmMatch = FRONTMATTER_RE.exec(content);
|
|
8
|
+
if (fmMatch) {
|
|
9
|
+
const fmBlock = fmMatch[0];
|
|
10
|
+
if (LIFECYCLE_FIELD_RE.test(fmBlock)) {
|
|
11
|
+
return content.replace(LIFECYCLE_FIELD_RE, `Lifecycle: ${value}`);
|
|
12
|
+
}
|
|
13
|
+
const insertAt = fmMatch.index + fmMatch[0].lastIndexOf("\n---") + 1;
|
|
14
|
+
return content.slice(0, insertAt) + `Lifecycle: ${value}\n` + content.slice(insertAt);
|
|
15
|
+
}
|
|
16
|
+
return `---\nLifecycle: ${value}\n---\n\n` + content;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export async function atomicWrite(filePath: string, content: string): Promise<void> {
|
|
20
|
+
const tmpPath = filePath + ".tmp";
|
|
21
|
+
await fs.writeFile(tmpPath, content, "utf-8");
|
|
22
|
+
await fs.rename(tmpPath, filePath);
|
|
23
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import * as fs from "node:fs/promises";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import { type LifecycleState, STATE_FILENAME, validateLifecycleState } from "../schema.ts";
|
|
4
|
+
|
|
5
|
+
export async function readLifecycleState(runDir: string): Promise<LifecycleState | null> {
|
|
6
|
+
const stateFile = path.join(runDir, STATE_FILENAME);
|
|
7
|
+
try {
|
|
8
|
+
const raw = await fs.readFile(stateFile, "utf-8");
|
|
9
|
+
return validateLifecycleState(JSON.parse(raw));
|
|
10
|
+
} catch {
|
|
11
|
+
return null;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export async function writeLifecycleState(runDir: string, state: LifecycleState): Promise<void> {
|
|
16
|
+
await fs.mkdir(runDir, { recursive: true });
|
|
17
|
+
const stateFile = path.join(runDir, STATE_FILENAME);
|
|
18
|
+
const tmpFile = path.join(runDir, `.${STATE_FILENAME}.tmp`);
|
|
19
|
+
await fs.writeFile(tmpFile, JSON.stringify(state, null, 2), "utf-8");
|
|
20
|
+
await fs.rename(tmpFile, stateFile);
|
|
21
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { tool } from "@opencode-ai/plugin";
|
|
2
|
+
import * as fs from "node:fs/promises";
|
|
3
|
+
import * as path from "node:path";
|
|
4
|
+
import { atomicWrite } from "../state/frontmatter.ts";
|
|
5
|
+
import { ensureExecutionMd } from "../hooks/tool-after.ts";
|
|
6
|
+
|
|
7
|
+
const SUMMARY_MARKER = "<!-- EXECUTION:SUMMARY -->";
|
|
8
|
+
|
|
9
|
+
type RecordResult =
|
|
10
|
+
| { success: true; execution_file: string }
|
|
11
|
+
| { success: false; code: string; reason: string; next_action: string };
|
|
12
|
+
|
|
13
|
+
async function fileExists(p: string): Promise<boolean> {
|
|
14
|
+
try {
|
|
15
|
+
await fs.access(p);
|
|
16
|
+
return true;
|
|
17
|
+
} catch {
|
|
18
|
+
return false;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export const lifecycleRecordExecutionTool = tool({
|
|
23
|
+
description: `Record Phase 4 (Execution Summary) into execution.md.
|
|
24
|
+
|
|
25
|
+
execution.md is the dedicated execution record file — separate from plan.md (which stays read-only after Phase 1-3).
|
|
26
|
+
|
|
27
|
+
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)
|
|
30
|
+
- <!-- EXECUTION:SUMMARY --> must NOT already exist in execution.md (idempotent guard)
|
|
31
|
+
|
|
32
|
+
On success: appends the execution_summary section into execution.md and updates its Phase frontmatter field to "summarized".
|
|
33
|
+
|
|
34
|
+
The execution_summary should compare each Plan Step against what actually happened: mark ✅/⚠️/❌, note deviations, list out-of-plan actions, and summarize artifacts produced.`,
|
|
35
|
+
args: {
|
|
36
|
+
run_dir: tool.schema
|
|
37
|
+
.string()
|
|
38
|
+
.describe("Absolute path to the directory containing plan.md and execution.md"),
|
|
39
|
+
execution_summary: tool.schema
|
|
40
|
+
.string()
|
|
41
|
+
.describe(
|
|
42
|
+
"Phase 4 summary content generated by the AI: per-step status table, out-of-plan actions, artifacts",
|
|
43
|
+
),
|
|
44
|
+
},
|
|
45
|
+
async execute(args) {
|
|
46
|
+
const runDir = path.resolve(args.run_dir);
|
|
47
|
+
const planFile = path.join(runDir, "plan.md");
|
|
48
|
+
const executionFile = path.join(runDir, "execution.md");
|
|
49
|
+
|
|
50
|
+
const result = await doRecord(planFile, executionFile, args.execution_summary);
|
|
51
|
+
return JSON.stringify(result);
|
|
52
|
+
},
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
async function doRecord(
|
|
56
|
+
planFile: string,
|
|
57
|
+
executionFile: string,
|
|
58
|
+
executionSummary: string,
|
|
59
|
+
): Promise<RecordResult> {
|
|
60
|
+
// Guard 1: plan.md must exist with Phase 1-3 complete
|
|
61
|
+
if (!(await fileExists(planFile))) {
|
|
62
|
+
return {
|
|
63
|
+
success: false,
|
|
64
|
+
code: "PHASE_NOT_READY",
|
|
65
|
+
reason: `plan.md not found at ${planFile}`,
|
|
66
|
+
next_action: "Complete the PLAN phase first — run empirical-plan skill to produce plan.md",
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const planContent = await fs.readFile(planFile, "utf-8");
|
|
71
|
+
if (!planContent.includes("## Plan Steps")) {
|
|
72
|
+
return {
|
|
73
|
+
success: false,
|
|
74
|
+
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
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Guard 2: execution.md must exist — auto-create skeleton if EXECUTE ran no tools
|
|
81
|
+
if (!(await fileExists(executionFile))) {
|
|
82
|
+
await ensureExecutionMd(executionFile, planFile);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const executionContent = await fs.readFile(executionFile, "utf-8");
|
|
86
|
+
|
|
87
|
+
// Guard 3: idempotent — summary not already written
|
|
88
|
+
if (executionContent.includes(SUMMARY_MARKER)) {
|
|
89
|
+
return {
|
|
90
|
+
success: false,
|
|
91
|
+
code: "ALREADY_RECORDED",
|
|
92
|
+
reason: "Phase 4 summary already recorded (EXECUTION:SUMMARY marker present in execution.md)",
|
|
93
|
+
next_action: "Execution already recorded — proceed to lifecycle_record_reflection",
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Append summary section + update Phase frontmatter
|
|
98
|
+
const completedAt = new Date().toISOString();
|
|
99
|
+
const section = `\n---\n${SUMMARY_MARKER}\n## Execution Summary\n\n${executionSummary.trim()}\n`;
|
|
100
|
+
const updated = applyExecutionPhase(executionContent + section, "summarized", completedAt);
|
|
101
|
+
await atomicWrite(executionFile, updated);
|
|
102
|
+
|
|
103
|
+
return { success: true, execution_file: executionFile };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function applyExecutionPhase(content: string, phase: string, completedAt: string): string {
|
|
107
|
+
const FRONTMATTER_RE = /^---\r?\n([\s\S]*?)\r?\n---/;
|
|
108
|
+
const fmMatch = FRONTMATTER_RE.exec(content);
|
|
109
|
+
if (!fmMatch) return content;
|
|
110
|
+
|
|
111
|
+
let fm = fmMatch[1]!;
|
|
112
|
+
fm = fm.replace(/^Phase:.*$/m, `Phase: ${phase}`);
|
|
113
|
+
if (fm.includes("CompletedAt:")) {
|
|
114
|
+
fm = fm.replace(/^CompletedAt:.*$/m, `CompletedAt: ${completedAt}`);
|
|
115
|
+
} else {
|
|
116
|
+
fm += `\nCompletedAt: ${completedAt}`;
|
|
117
|
+
}
|
|
118
|
+
return `---\n${fm}\n---` + content.slice(fmMatch[0].length);
|
|
119
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { tool } from "@opencode-ai/plugin";
|
|
2
|
+
import * as fs from "node:fs/promises";
|
|
3
|
+
import * as path from "node:path";
|
|
4
|
+
import { applyFrontmatterLifecycle, atomicWrite } from "../state/frontmatter.ts";
|
|
5
|
+
|
|
6
|
+
const EXECUTION_SUMMARY_MARKER = "<!-- EXECUTION:SUMMARY -->";
|
|
7
|
+
const REFLECTION_MARKER = "<!-- LIFECYCLE:REFLECTION -->";
|
|
8
|
+
|
|
9
|
+
async function fileExists(p: string): Promise<boolean> {
|
|
10
|
+
try {
|
|
11
|
+
await fs.access(p);
|
|
12
|
+
return true;
|
|
13
|
+
} catch {
|
|
14
|
+
return false;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export const lifecycleRecordReflectionTool = tool({
|
|
19
|
+
description: `Record Phase 5 (Reflection & Optimization) into plan.md.
|
|
20
|
+
|
|
21
|
+
Guards (hard-block):
|
|
22
|
+
- execution.md must exist and contain <!-- EXECUTION:SUMMARY --> (Phase 4 already recorded)
|
|
23
|
+
- reflect.md must exist in run_dir (REFLECT phase complete)
|
|
24
|
+
- <!-- LIFECYCLE:REFLECTION --> must NOT already exist in plan.md (idempotent guard)
|
|
25
|
+
|
|
26
|
+
On success: appends the reflection_record block into plan.md and updates frontmatter Lifecycle: complete.
|
|
27
|
+
|
|
28
|
+
The reflection_record should contain: execution quality assessment, plan design issues found, specific actionable optimization suggestions for next time, and reusable principles to promote to MEMORY.md.`,
|
|
29
|
+
args: {
|
|
30
|
+
run_dir: tool.schema
|
|
31
|
+
.string()
|
|
32
|
+
.describe("Absolute path to the directory containing plan.md, execution.md, and reflect.md"),
|
|
33
|
+
reflection_record: tool.schema
|
|
34
|
+
.string()
|
|
35
|
+
.describe("Phase 5 content generated by the AI before calling this tool"),
|
|
36
|
+
},
|
|
37
|
+
async execute(args) {
|
|
38
|
+
const runDir = path.resolve(args.run_dir);
|
|
39
|
+
const planFile = path.join(runDir, "plan.md");
|
|
40
|
+
const executionFile = path.join(runDir, "execution.md");
|
|
41
|
+
const reflectFile = path.join(runDir, "reflect.md");
|
|
42
|
+
|
|
43
|
+
// Guard 1: plan.md must exist
|
|
44
|
+
if (!(await fileExists(planFile))) {
|
|
45
|
+
return JSON.stringify({
|
|
46
|
+
success: false,
|
|
47
|
+
code: "PLAN_MISSING",
|
|
48
|
+
reason: `plan.md not found at ${planFile}`,
|
|
49
|
+
next_action: "Complete the PLAN phase first",
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Guard 2: execution.md must exist and contain EXECUTION:SUMMARY
|
|
54
|
+
if (!(await fileExists(executionFile))) {
|
|
55
|
+
return JSON.stringify({
|
|
56
|
+
success: false,
|
|
57
|
+
code: "EXECUTION_NOT_RECORDED",
|
|
58
|
+
reason: `execution.md not found at ${executionFile}`,
|
|
59
|
+
next_action: "Call lifecycle_record_execution first to record Phase 4",
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
|
|
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
|
+
// Guard 3: reflect.md must exist
|
|
74
|
+
if (!(await fileExists(reflectFile))) {
|
|
75
|
+
return JSON.stringify({
|
|
76
|
+
success: false,
|
|
77
|
+
code: "REFLECT_MISSING",
|
|
78
|
+
reason: `reflect.md not found at ${reflectFile}`,
|
|
79
|
+
next_action: "Complete the REFLECT phase — the reflector must write reflect.md to run_dir",
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Guard 4: idempotent — reflection not already written into plan.md
|
|
84
|
+
const planContent = await fs.readFile(planFile, "utf-8");
|
|
85
|
+
if (planContent.includes(REFLECTION_MARKER)) {
|
|
86
|
+
return JSON.stringify({
|
|
87
|
+
success: false,
|
|
88
|
+
code: "ALREADY_REFLECTED",
|
|
89
|
+
reason: "Phase 5 already recorded (LIFECYCLE:REFLECTION marker present in plan.md)",
|
|
90
|
+
next_action: "Reflection already complete — lifecycle is done",
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const section = `\n---\n${REFLECTION_MARKER}\n## Phase 5: Reflection & Optimization\n\n${args.reflection_record.trim()}\n`;
|
|
95
|
+
const updated = applyFrontmatterLifecycle(planContent + section, "complete");
|
|
96
|
+
await atomicWrite(planFile, updated);
|
|
97
|
+
|
|
98
|
+
return JSON.stringify({ success: true, plan_file: planFile });
|
|
99
|
+
},
|
|
100
|
+
});
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { tool } from "@opencode-ai/plugin";
|
|
2
|
+
import * as fs from "node:fs/promises";
|
|
3
|
+
import * as path from "node:path";
|
|
4
|
+
import { writeLifecycleState } from "../state/lifecycle-state.ts";
|
|
5
|
+
import { readLifecycleState } from "../state/lifecycle-state.ts";
|
|
6
|
+
import { lookupRunDir, registerParent } from "../state/active-lifecycles.ts";
|
|
7
|
+
import type { LifecycleState, LifecycleMode } from "../schema.ts";
|
|
8
|
+
|
|
9
|
+
function makeLifecycleId(): string {
|
|
10
|
+
const now = new Date();
|
|
11
|
+
const pad = (n: number, len = 2) => String(n).padStart(len, "0");
|
|
12
|
+
const rand = Math.random().toString(36).slice(2, 6);
|
|
13
|
+
return [
|
|
14
|
+
"lc",
|
|
15
|
+
`${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}`,
|
|
16
|
+
`${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`,
|
|
17
|
+
rand,
|
|
18
|
+
].join("-");
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export const lifecycleStartTool = tool({
|
|
22
|
+
description: `Start a new empirical-plan lifecycle. Creates run_dir, initializes lifecycle-state.json, and returns the lifecycleId.
|
|
23
|
+
|
|
24
|
+
Call this at the beginning of a PLAN → EXECUTE → REFLECT workflow. Provide:
|
|
25
|
+
- run_dir: absolute path where lifecycle artifacts (plan.md, execution.md, reflect.md) will be stored
|
|
26
|
+
- mode: "standalone" (default, AI-coordinated) or "heartbeat" (runner-coordinated)
|
|
27
|
+
|
|
28
|
+
Returns: { success, lifecycleId, runDir, planFile, stateFile, phase }
|
|
29
|
+
No planPrompt is returned — the coordinator AI constructs prompts using the returned runDir.
|
|
30
|
+
|
|
31
|
+
Phase advancement is automatic: each call to task() that completes a phase sub-session
|
|
32
|
+
advances lifecycle-state.json to the next phase (plan→execute→reflect→done).`,
|
|
33
|
+
args: {
|
|
34
|
+
run_dir: tool.schema
|
|
35
|
+
.string()
|
|
36
|
+
.describe("Absolute path to the directory for this lifecycle's artifacts"),
|
|
37
|
+
mode: tool.schema
|
|
38
|
+
.string()
|
|
39
|
+
.optional()
|
|
40
|
+
.describe('Coordination mode: "standalone" (default) or "heartbeat"'),
|
|
41
|
+
},
|
|
42
|
+
async execute(args, ctx) {
|
|
43
|
+
const runDir = path.resolve(args.run_dir);
|
|
44
|
+
const rawMode = args.mode ?? "standalone";
|
|
45
|
+
if (rawMode !== "standalone" && rawMode !== "heartbeat") {
|
|
46
|
+
throw new Error(`Invalid mode "${rawMode}". Must be "standalone" or "heartbeat".`);
|
|
47
|
+
}
|
|
48
|
+
const mode: LifecycleMode = rawMode;
|
|
49
|
+
|
|
50
|
+
const existingRunDir = lookupRunDir(ctx.sessionID);
|
|
51
|
+
if (existingRunDir) {
|
|
52
|
+
const existingState = await readLifecycleState(existingRunDir);
|
|
53
|
+
if (existingState && existingState.phase !== "done") {
|
|
54
|
+
return JSON.stringify({
|
|
55
|
+
success: false,
|
|
56
|
+
code: "LIFECYCLE_ALREADY_ACTIVE",
|
|
57
|
+
reason: `Session has an in-progress lifecycle (phase: ${existingState.phase}) at run_dir: ${existingRunDir}`,
|
|
58
|
+
next_action: "Wait for the current lifecycle to complete before starting a new one.",
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
console.log(`[empirical-plan] lifecycle_start: stale map entry for session ${ctx.sessionID} (phase=${existingState?.phase ?? "missing"}), proceeding with new lifecycle`);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
await fs.mkdir(runDir, { recursive: true });
|
|
65
|
+
|
|
66
|
+
const lifecycleId = makeLifecycleId();
|
|
67
|
+
const now = new Date().toISOString();
|
|
68
|
+
const planFile = path.join(runDir, "plan.md");
|
|
69
|
+
|
|
70
|
+
const state: LifecycleState = {
|
|
71
|
+
lifecycleId,
|
|
72
|
+
parentSessionId: ctx.sessionID,
|
|
73
|
+
phase: "plan",
|
|
74
|
+
seq: 0,
|
|
75
|
+
runDir,
|
|
76
|
+
planFile,
|
|
77
|
+
mode,
|
|
78
|
+
createdAt: now,
|
|
79
|
+
updatedAt: now,
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
await writeLifecycleState(runDir, state);
|
|
83
|
+
registerParent(ctx.sessionID, runDir);
|
|
84
|
+
|
|
85
|
+
return JSON.stringify({
|
|
86
|
+
success: true,
|
|
87
|
+
lifecycleId,
|
|
88
|
+
runDir,
|
|
89
|
+
planFile,
|
|
90
|
+
stateFile: `${runDir}/lifecycle-state.json`,
|
|
91
|
+
phase: "plan",
|
|
92
|
+
});
|
|
93
|
+
},
|
|
94
|
+
});
|